feat: implement notification detail view and add support for permission handling and app badges
This commit is contained in:
parent
f36ee79577
commit
f0c2942861
File diff suppressed because one or more lines are too long
@ -1,6 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
|
||||
<application
|
||||
android:label="Mie Mapan"
|
||||
|
||||
@ -1,13 +1,25 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:odoo_rpc/odoo_rpc.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/loyalty_dashboard.dart';
|
||||
import 'services/odoo_service.dart';
|
||||
import 'services/config.dart';
|
||||
import 'services/background_service.dart';
|
||||
import 'services/notification_service.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize local notifications and request permission
|
||||
final notifService = NotificationService();
|
||||
await notifService.initialize();
|
||||
await notifService.requestPermission();
|
||||
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
Workmanager().initialize(
|
||||
callbackDispatcher,
|
||||
@ -21,11 +33,28 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
runApp(const OdooLoyaltyApp());
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final sessionStr = prefs.getString('odoo_session');
|
||||
Widget homeWidget = const LoginScreen();
|
||||
|
||||
if (sessionStr != null) {
|
||||
try {
|
||||
final sessionMap = json.decode(sessionStr) as Map<String, dynamic>;
|
||||
final session = OdooSession.fromJson(sessionMap);
|
||||
final service = OdooService();
|
||||
service.connect(AppConfig.odooUrl, session: session);
|
||||
homeWidget = LoyaltyDashboard(partnerId: session.partnerId);
|
||||
} catch (e) {
|
||||
homeWidget = const LoginScreen();
|
||||
}
|
||||
}
|
||||
|
||||
runApp(OdooLoyaltyApp(homeWidget: homeWidget));
|
||||
}
|
||||
|
||||
class OdooLoyaltyApp extends StatelessWidget {
|
||||
const OdooLoyaltyApp({super.key});
|
||||
final Widget homeWidget;
|
||||
const OdooLoyaltyApp({super.key, required this.homeWidget});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -33,7 +62,7 @@ class OdooLoyaltyApp extends StatelessWidget {
|
||||
title: 'Mie Mapan Loyalty App',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.lightTheme,
|
||||
home: const LoginScreen(),
|
||||
home: homeWidget,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,18 +36,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('odoo_url', AppConfig.odooUrl);
|
||||
final sessionJson = json.encode({
|
||||
'id': session.id,
|
||||
'user_id': session.userId,
|
||||
'partner_id': session.partnerId,
|
||||
'user_login': session.userLogin,
|
||||
'user_name': session.userName,
|
||||
'user_lang': session.userLang,
|
||||
'user_tz': session.userTz,
|
||||
'is_system': session.isSystem,
|
||||
'server_version': session.serverVersion,
|
||||
});
|
||||
await prefs.setString('odoo_session', sessionJson);
|
||||
// Use the library's own toJson() to ensure keys exactly match fromJson() on restore
|
||||
await prefs.setString('odoo_session', json.encode(session.toJson()));
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../services/odoo_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'notifications_screen.dart';
|
||||
import 'branches_screen.dart';
|
||||
@ -16,16 +19,98 @@ class LoyaltyDashboard extends StatefulWidget {
|
||||
class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
|
||||
List<dynamic> _loyaltyCards = [];
|
||||
bool _isLoading = true;
|
||||
int _unreadNotificationCount = 0;
|
||||
Timer? _notificationTimer;
|
||||
|
||||
// Shared pref keys
|
||||
static const _kLastNotified = 'last_device_notified_id';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchLoyaltyData();
|
||||
_fetchNotificationCount();
|
||||
_notificationTimer = Timer.periodic(const Duration(seconds: 10), (_) {
|
||||
_fetchNotificationCount();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_notificationTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _fetchNotificationCount() async {
|
||||
try {
|
||||
final client = OdooService().client;
|
||||
if (client == null) return;
|
||||
|
||||
final response = await client.callRPC(
|
||||
'/api/loyalty/fetch_notifications',
|
||||
'call',
|
||||
{'last_id': 0},
|
||||
);
|
||||
|
||||
if (response != null && response['status'] == 'success') {
|
||||
final List<dynamic> notifs = response['data'] ?? [];
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lastNotifiedId = prefs.getInt(_kLastNotified) ?? 0;
|
||||
|
||||
// Check read list
|
||||
final readIds = prefs.getStringList('read_notification_ids');
|
||||
int unreadCount = 0;
|
||||
|
||||
if (readIds == null) {
|
||||
final initialRead = notifs.map((n) => (n['id'] as int? ?? 0).toString()).toList();
|
||||
await prefs.setStringList('read_notification_ids', initialRead);
|
||||
unreadCount = 0;
|
||||
} else {
|
||||
unreadCount = notifs
|
||||
.where((n) => !readIds.contains((n['id'] as int? ?? 0).toString()))
|
||||
.length;
|
||||
}
|
||||
|
||||
int highestNewId = lastNotifiedId;
|
||||
final List<dynamic> toNotify = [];
|
||||
|
||||
for (var notif in notifs) {
|
||||
final id = notif['id'] as int? ?? 0;
|
||||
if (id > lastNotifiedId) {
|
||||
toNotify.add(notif);
|
||||
if (id > highestNewId) highestNewId = id;
|
||||
}
|
||||
}
|
||||
|
||||
// Show device tray notifications for any not yet shown
|
||||
if (toNotify.isNotEmpty) {
|
||||
final notifService = NotificationService();
|
||||
for (final notif in toNotify) {
|
||||
await notifService.showNotification(
|
||||
id: notif['id'] as int,
|
||||
title: notif['title'] ?? 'Mie Mapan',
|
||||
body: notif['body'] ?? '',
|
||||
);
|
||||
}
|
||||
await prefs.setInt(_kLastNotified, highestNewId);
|
||||
}
|
||||
|
||||
// Always update system badge to match the unread count
|
||||
await NotificationService().setBadge(unreadCount);
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _unreadNotificationCount = unreadCount);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchLoyaltyData() async {
|
||||
try {
|
||||
final cards = await OdooService().getLoyaltyCards(widget.partnerId);
|
||||
await _fetchNotificationCount();
|
||||
setState(() {
|
||||
_loyaltyCards = cards;
|
||||
_isLoading = false;
|
||||
@ -38,6 +123,7 @@ class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@ -48,9 +134,42 @@ class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
|
||||
icon: const Icon(Icons.storefront),
|
||||
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const BranchesScreen())),
|
||||
),
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.notifications),
|
||||
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const NotificationsScreen())),
|
||||
onPressed: () async {
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (_) => const NotificationsScreen()));
|
||||
_fetchNotificationCount();
|
||||
},
|
||||
),
|
||||
if (_unreadNotificationCount > 0)
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 16,
|
||||
minHeight: 16,
|
||||
),
|
||||
child: Text(
|
||||
'$_unreadNotificationCount',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
|
||||
131
lib/screens/notification_detail_screen.dart
Normal file
131
lib/screens/notification_detail_screen.dart
Normal file
@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../services/odoo_service.dart';
|
||||
|
||||
class NotificationDetailScreen extends StatelessWidget {
|
||||
final dynamic notif;
|
||||
|
||||
const NotificationDetailScreen({super.key, required this.notif});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = notif['title'] as String? ?? 'Notice';
|
||||
final body = notif['body'] as String? ?? '';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Notification Detail'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header icon block
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
color: AppTheme.surfaceContainerLow,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primary.withValues(alpha: 0.15),
|
||||
shape: BoxShape.rectangle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.campaign_outlined,
|
||||
color: AppTheme.secondary,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: AppTheme.onSurface,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (notif['has_image'] == true) ...[
|
||||
const SizedBox(height: 24),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
OdooService().notificationImageUrl(notif['id'] as int),
|
||||
headers: {
|
||||
'Cookie': OdooService().sessionCookie,
|
||||
},
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Container(
|
||||
height: 200,
|
||||
color: AppTheme.surfaceContainerLow,
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
height: 150,
|
||||
color: AppTheme.surfaceContainerLow,
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image_outlined, size: 48, color: AppTheme.onSurfaceVariant),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Body label
|
||||
Text(
|
||||
'Message',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: AppTheme.onSurfaceVariant,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Divider line (editorial style)
|
||||
const Divider(
|
||||
color: AppTheme.surfaceContainerHighest,
|
||||
thickness: 2,
|
||||
height: 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Body text
|
||||
body.isEmpty
|
||||
? Text(
|
||||
'No additional details.',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: AppTheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
body,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: AppTheme.onSurface,
|
||||
height: 1.7,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:odoo_rpc/odoo_rpc.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../services/odoo_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'notification_detail_screen.dart';
|
||||
|
||||
class NotificationsScreen extends StatefulWidget {
|
||||
const NotificationsScreen({super.key});
|
||||
@ -12,6 +15,7 @@ class NotificationsScreen extends StatefulWidget {
|
||||
|
||||
class _NotificationsScreenState extends State<NotificationsScreen> {
|
||||
List<dynamic> _notifications = [];
|
||||
List<String> _readIds = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
@ -23,28 +27,35 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
|
||||
Future<void> _fetchNotifications() async {
|
||||
try {
|
||||
final client = OdooService().client;
|
||||
if (client == null) throw Exception("Not connected");
|
||||
if (client == null) throw Exception('Not connected');
|
||||
|
||||
final response = await client.callRPC(
|
||||
'/api/loyalty/fetch_notifications',
|
||||
'call',
|
||||
{'last_id': 0}
|
||||
{'last_id': 0},
|
||||
);
|
||||
|
||||
if (response != null && response['status'] == 'success') {
|
||||
final List<dynamic> fetched = response['data'] ?? [];
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final readIds = prefs.getStringList('read_notification_ids') ?? [];
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_notifications = response['data'];
|
||||
_notifications = fetched;
|
||||
_readIds = readIds;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw Exception("Invalid response from server");
|
||||
throw Exception('Invalid response from server');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error loading notifications: $e')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error loading notifications: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -56,42 +67,153 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _notifications.isEmpty
|
||||
? const Center(child: Text('No new promos.', style: TextStyle(fontSize: 16)))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.notifications_none,
|
||||
size: 64,
|
||||
color: AppTheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No notifications yet.',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: AppTheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _fetchNotifications,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 20),
|
||||
itemCount: _notifications.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final notif = _notifications[index];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryContainer.withOpacity(0.1),
|
||||
shape: BoxShape.rectangle,
|
||||
),
|
||||
child: const Icon(Icons.star, color: AppTheme.primary),
|
||||
),
|
||||
title: Text(
|
||||
notif['title'] ?? 'Notice',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
notif['body'] ?? '',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
final isUnread = !_readIds.contains((notif['id'] as int? ?? 0).toString());
|
||||
return _NotificationCard(
|
||||
notif: notif,
|
||||
isUnread: isUnread,
|
||||
onTap: () async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final readIds = prefs.getStringList('read_notification_ids') ?? [];
|
||||
final notifIdStr = (notif['id'] as int? ?? 0).toString();
|
||||
if (!readIds.contains(notifIdStr)) {
|
||||
readIds.add(notifIdStr);
|
||||
await prefs.setStringList('read_notification_ids', readIds);
|
||||
|
||||
// Recalculate and update system badge count
|
||||
final unreadCount = _notifications
|
||||
.where((n) => !readIds.contains((n['id'] as int? ?? 0).toString()))
|
||||
.length;
|
||||
await NotificationService().setBadge(unreadCount);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
NotificationDetailScreen(notif: notif),
|
||||
),
|
||||
);
|
||||
_fetchNotifications();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NotificationCard extends StatelessWidget {
|
||||
final dynamic notif;
|
||||
final bool isUnread;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _NotificationCard({
|
||||
required this.notif,
|
||||
required this.isUnread,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = notif['title'] as String? ?? 'Notice';
|
||||
|
||||
return Material(
|
||||
color: AppTheme.surfaceContainerLow,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
|
||||
child: Row(
|
||||
children: [
|
||||
notif['has_image'] == true && notif['image_128'] != null
|
||||
? Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.rectangle,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
image: DecorationImage(
|
||||
image: MemoryImage(
|
||||
base64Decode(notif['image_128'] as String),
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primary.withValues(alpha: 0.15),
|
||||
shape: BoxShape.rectangle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.campaign_outlined,
|
||||
color: AppTheme.secondary,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: AppTheme.onSurface,
|
||||
fontWeight: isUnread ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (isUnread) ...[
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppTheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../services/odoo_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'login_screen.dart';
|
||||
|
||||
@ -177,6 +178,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _logout() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('odoo_session');
|
||||
await prefs.remove('last_seen_notification_id');
|
||||
await prefs.remove('last_device_notified_id');
|
||||
await prefs.remove('read_notification_ids');
|
||||
await NotificationService().clearBadge();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phraseController.dispose();
|
||||
@ -234,6 +250,25 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
ElevatedButton(
|
||||
onPressed: _logout,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: AppTheme.onSurface,
|
||||
side: const BorderSide(color: AppTheme.surfaceContainer, width: 2),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Text(
|
||||
'Log Out',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _showDeleteConfirmationDialog,
|
||||
style: ElevatedButton.styleFrom(
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import 'dart:convert';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:odoo_rpc/odoo_rpc.dart';
|
||||
import 'notification_service.dart';
|
||||
|
||||
// NOTE: This key tracks what IDs have been shown as device tray notifications.
|
||||
// It is separate from 'last_seen_notification_id' (which tracks what the user READ in-app).
|
||||
const String _kLastDeviceNotifiedId = 'last_device_notified_id';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void callbackDispatcher() {
|
||||
@ -11,52 +15,70 @@ void callbackDispatcher() {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final url = prefs.getString('odoo_url');
|
||||
final sessionStr = prefs.getString('odoo_session');
|
||||
final lastNotificationId = prefs.getInt('last_notification_id') ?? 0;
|
||||
|
||||
if (url == null || sessionStr == null) {
|
||||
return Future.value(true); // Cannot fetch if not logged in
|
||||
return Future.value(true); // Not logged in, nothing to do
|
||||
}
|
||||
|
||||
final lastDeviceNotifiedId = prefs.getInt(_kLastDeviceNotifiedId) ?? 0;
|
||||
|
||||
final sessionArgs = json.decode(sessionStr);
|
||||
final session = OdooSession.fromJson(sessionArgs);
|
||||
final session = OdooSession.fromJson(
|
||||
Map<String, dynamic>.from(sessionArgs as Map));
|
||||
final client = OdooClient(url, sessionId: session);
|
||||
|
||||
final response = await client.callRPC(
|
||||
'/api/loyalty/fetch_notifications',
|
||||
'call',
|
||||
{'last_id': lastNotificationId}
|
||||
{'last_id': 0},
|
||||
);
|
||||
|
||||
client.close();
|
||||
|
||||
if (response != null && response['status'] == 'success') {
|
||||
final notifications = response['data'] as List;
|
||||
if (notifications.isNotEmpty) {
|
||||
int highestId = lastNotificationId;
|
||||
final List<dynamic> notifications =
|
||||
List<dynamic>.from(response['data'] ?? []);
|
||||
|
||||
final flnp = FlutterLocalNotificationsPlugin();
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const initSettings = InitializationSettings(android: androidSettings);
|
||||
await flnp.initialize(settings: initSettings);
|
||||
// Filter to only truly new ones not yet shown on device tray
|
||||
final newNotifs = notifications
|
||||
.where((n) => (n['id'] as int? ?? 0) > lastDeviceNotifiedId)
|
||||
.toList();
|
||||
|
||||
for (final notif in notifications) {
|
||||
final int notifId = notif['id'];
|
||||
final notifService = NotificationService();
|
||||
await notifService.initialize();
|
||||
|
||||
if (newNotifs.isNotEmpty) {
|
||||
int highestId = lastDeviceNotifiedId;
|
||||
for (final notif in newNotifs) {
|
||||
final int notifId = notif['id'] as int? ?? 0;
|
||||
if (notifId > highestId) highestId = notifId;
|
||||
|
||||
const androidConfig = AndroidNotificationDetails(
|
||||
'loyalty_channel', 'Promos',
|
||||
importance: Importance.max, priority: Priority.high
|
||||
);
|
||||
await flnp.show(
|
||||
await notifService.showNotification(
|
||||
id: notifId,
|
||||
title: notif['title'],
|
||||
body: notif['body'],
|
||||
notificationDetails: const NotificationDetails(android: androidConfig)
|
||||
title: notif['title'] as String? ?? 'Mie Mapan',
|
||||
body: notif['body'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
await prefs.setInt('last_notification_id', highestId);
|
||||
|
||||
await prefs.setInt(_kLastDeviceNotifiedId, highestId);
|
||||
}
|
||||
|
||||
// Always compute badge count based on read_notification_ids
|
||||
final readIds = prefs.getStringList('read_notification_ids');
|
||||
if (readIds == null) {
|
||||
// Initialize read list with all currently fetched notifications on first install/run
|
||||
final initialRead = notifications.map((n) => (n['id'] as int? ?? 0).toString()).toList();
|
||||
await prefs.setStringList('read_notification_ids', initialRead);
|
||||
await notifService.setBadge(0);
|
||||
} else {
|
||||
final unreadCount = notifications
|
||||
.where((n) => !readIds.contains((n['id'] as int? ?? 0).toString()))
|
||||
.length;
|
||||
await notifService.setBadge(unreadCount);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('Background Fetch Error: $e');
|
||||
} catch (_) {
|
||||
// Silently swallow — background isolate must never crash
|
||||
}
|
||||
return Future.value(true);
|
||||
});
|
||||
|
||||
109
lib/services/notification_service.dart
Normal file
109
lib/services/notification_service.dart
Normal file
@ -0,0 +1,109 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:app_badge_plus/app_badge_plus.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
/// Central service for all local notification and badge operations.
|
||||
/// Call [initialize] once in main(), then use [showNotification] and [setBadge].
|
||||
class NotificationService {
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
NotificationService._internal();
|
||||
|
||||
static const String _channelId = 'mapan_loyalty_channel';
|
||||
static const String _channelName = 'Mie Mapan Promos';
|
||||
static const String _channelDescription =
|
||||
'Notifications for new promos and loyalty rewards from Mie Mapan.';
|
||||
|
||||
final FlutterLocalNotificationsPlugin _flnp = FlutterLocalNotificationsPlugin();
|
||||
|
||||
bool _initialized = false;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const darwinInit = DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
);
|
||||
const initSettings = InitializationSettings(
|
||||
android: androidInit,
|
||||
iOS: darwinInit,
|
||||
);
|
||||
|
||||
await _flnp.initialize(
|
||||
settings: initSettings,
|
||||
);
|
||||
await _createNotificationChannel();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
Future<void> _createNotificationChannel() async {
|
||||
const channel = AndroidNotificationChannel(
|
||||
_channelId,
|
||||
_channelName,
|
||||
description: _channelDescription,
|
||||
importance: Importance.max,
|
||||
);
|
||||
await _flnp
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
/// Request POST_NOTIFICATIONS permission on Android 13+.
|
||||
Future<void> requestPermission() async {
|
||||
if (await Permission.notification.isDenied) {
|
||||
await Permission.notification.request();
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a single notification in the device tray.
|
||||
Future<void> showNotification({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
await initialize();
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
_channelId,
|
||||
_channelName,
|
||||
channelDescription: _channelDescription,
|
||||
importance: Importance.max,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
const details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
await _flnp.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: details,
|
||||
);
|
||||
}
|
||||
|
||||
/// Set the badge count on the app icon.
|
||||
Future<void> setBadge(int count) async {
|
||||
try {
|
||||
final supported = await AppBadgePlus.isSupported();
|
||||
if (supported) {
|
||||
await AppBadgePlus.updateBadge(count);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Badge update error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the badge count (set to 0).
|
||||
Future<void> clearBadge() => setBadge(0);
|
||||
}
|
||||
@ -8,10 +8,21 @@ class OdooService {
|
||||
|
||||
OdooClient? client;
|
||||
|
||||
void connect(String url) {
|
||||
client = OdooClient(url);
|
||||
void connect(String url, {OdooSession? session}) {
|
||||
client = OdooClient(url, sessionId: session);
|
||||
}
|
||||
|
||||
/// Returns the session cookie header value for authenticated image loading.
|
||||
/// Usage: Image.network(url, headers: {'Cookie': OdooService().sessionCookie})
|
||||
String get sessionCookie {
|
||||
final sessionId = client?.sessionId?.id ?? '';
|
||||
return 'session_id=$sessionId';
|
||||
}
|
||||
|
||||
/// Returns the URL for the full notification image.
|
||||
String notificationImageUrl(int notifId) =>
|
||||
'${AppConfig.odooUrl}/web/image/mapan.app.notification/$notifId/image';
|
||||
|
||||
Future<OdooSession> login(String db, String username, String password) async {
|
||||
if (client == null) throw Exception("Connect to Odoo first");
|
||||
return await client!.authenticate(db, username, password);
|
||||
|
||||
@ -5,11 +5,13 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import app_badge_plus
|
||||
import flutter_local_notifications
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AppBadgePlusPlugin.register(with: registry.registrar(forPlugin: "AppBadgePlusPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
|
||||
56
pubspec.lock
56
pubspec.lock
@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
app_badge_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: app_badge_plus
|
||||
sha256: "60b24ecbcb792f5b185a837b066ee436ef9f6435f2a3006eb314d0c6a80f1d75"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -400,6 +408,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.3"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.0.1"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.10"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_html
|
||||
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3+5"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_platform_interface
|
||||
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
permission_handler_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_windows
|
||||
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -40,6 +40,8 @@ dependencies:
|
||||
shared_preferences: ^2.5.4
|
||||
google_fonts: ^6.2.1
|
||||
url_launcher: ^6.3.2
|
||||
app_badge_plus: ^1.3.1
|
||||
permission_handler: ^12.0.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@ -1,29 +1,12 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:odoo_loyalty_app/main.dart';
|
||||
import 'package:odoo_loyalty_app/screens/login_screen.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const OdooLoyaltyApp());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
testWidgets('App renders without crash', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const OdooLoyaltyApp(homeWidget: LoginScreen()));
|
||||
expect(find.byType(OdooLoyaltyApp), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@ -6,9 +6,12 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
permission_handler_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user