feat: implement notification detail view and add support for permission handling and app badges

This commit is contained in:
Suherdy Yacob 2026-06-13 22:32:10 +07:00
parent f36ee79577
commit f0c2942861
17 changed files with 737 additions and 121 deletions

File diff suppressed because one or more lines are too long

View File

@ -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"

View File

@ -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,
);
}
}

View File

@ -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(

View File

@ -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),

View 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,
),
),
],
),
),
);
}
}

View File

@ -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,43 +67,154 @@ 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,
),
],
),
),
),
);
}
}

View File

@ -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(

View File

@ -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);
});

View 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);
}

View File

@ -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);

View File

@ -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"))

View File

@ -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:

View File

@ -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:

View File

@ -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);
});
}

View File

@ -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"));
}

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
permission_handler_windows
url_launcher_windows
)