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">
|
<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.RECEIVE_BOOT_COMPLETED"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="Mie Mapan"
|
android:label="Mie Mapan"
|
||||||
|
|||||||
@ -1,13 +1,25 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:workmanager/workmanager.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/login_screen.dart';
|
||||||
|
import 'screens/loyalty_dashboard.dart';
|
||||||
|
import 'services/odoo_service.dart';
|
||||||
|
import 'services/config.dart';
|
||||||
import 'services/background_service.dart';
|
import 'services/background_service.dart';
|
||||||
|
import 'services/notification_service.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
|
||||||
void main() {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialize local notifications and request permission
|
||||||
|
final notifService = NotificationService();
|
||||||
|
await notifService.initialize();
|
||||||
|
await notifService.requestPermission();
|
||||||
|
|
||||||
if (Platform.isAndroid || Platform.isIOS) {
|
if (Platform.isAndroid || Platform.isIOS) {
|
||||||
Workmanager().initialize(
|
Workmanager().initialize(
|
||||||
callbackDispatcher,
|
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 {
|
class OdooLoyaltyApp extends StatelessWidget {
|
||||||
const OdooLoyaltyApp({super.key});
|
final Widget homeWidget;
|
||||||
|
const OdooLoyaltyApp({super.key, required this.homeWidget});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -33,7 +62,7 @@ class OdooLoyaltyApp extends StatelessWidget {
|
|||||||
title: 'Mie Mapan Loyalty App',
|
title: 'Mie Mapan Loyalty App',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
home: const LoginScreen(),
|
home: homeWidget,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,18 +36,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString('odoo_url', AppConfig.odooUrl);
|
await prefs.setString('odoo_url', AppConfig.odooUrl);
|
||||||
final sessionJson = json.encode({
|
// Use the library's own toJson() to ensure keys exactly match fromJson() on restore
|
||||||
'id': session.id,
|
await prefs.setString('odoo_session', json.encode(session.toJson()));
|
||||||
'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);
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../services/odoo_service.dart';
|
import '../services/odoo_service.dart';
|
||||||
|
import '../services/notification_service.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import 'notifications_screen.dart';
|
import 'notifications_screen.dart';
|
||||||
import 'branches_screen.dart';
|
import 'branches_screen.dart';
|
||||||
@ -16,16 +19,98 @@ class LoyaltyDashboard extends StatefulWidget {
|
|||||||
class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
|
class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
|
||||||
List<dynamic> _loyaltyCards = [];
|
List<dynamic> _loyaltyCards = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
int _unreadNotificationCount = 0;
|
||||||
|
Timer? _notificationTimer;
|
||||||
|
|
||||||
|
// Shared pref keys
|
||||||
|
static const _kLastNotified = 'last_device_notified_id';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_fetchLoyaltyData();
|
_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 {
|
Future<void> _fetchLoyaltyData() async {
|
||||||
try {
|
try {
|
||||||
final cards = await OdooService().getLoyaltyCards(widget.partnerId);
|
final cards = await OdooService().getLoyaltyCards(widget.partnerId);
|
||||||
|
await _fetchNotificationCount();
|
||||||
setState(() {
|
setState(() {
|
||||||
_loyaltyCards = cards;
|
_loyaltyCards = cards;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@ -38,6 +123,7 @@ class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@ -48,9 +134,42 @@ class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
|
|||||||
icon: const Icon(Icons.storefront),
|
icon: const Icon(Icons.storefront),
|
||||||
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const BranchesScreen())),
|
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const BranchesScreen())),
|
||||||
),
|
),
|
||||||
|
Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.notifications),
|
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(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings),
|
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:flutter/material.dart';
|
||||||
import 'package:odoo_rpc/odoo_rpc.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../services/odoo_service.dart';
|
import '../services/odoo_service.dart';
|
||||||
|
import '../services/notification_service.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
import 'notification_detail_screen.dart';
|
||||||
|
|
||||||
class NotificationsScreen extends StatefulWidget {
|
class NotificationsScreen extends StatefulWidget {
|
||||||
const NotificationsScreen({super.key});
|
const NotificationsScreen({super.key});
|
||||||
@ -12,6 +15,7 @@ class NotificationsScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _NotificationsScreenState extends State<NotificationsScreen> {
|
class _NotificationsScreenState extends State<NotificationsScreen> {
|
||||||
List<dynamic> _notifications = [];
|
List<dynamic> _notifications = [];
|
||||||
|
List<String> _readIds = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -23,28 +27,35 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
|
|||||||
Future<void> _fetchNotifications() async {
|
Future<void> _fetchNotifications() async {
|
||||||
try {
|
try {
|
||||||
final client = OdooService().client;
|
final client = OdooService().client;
|
||||||
if (client == null) throw Exception("Not connected");
|
if (client == null) throw Exception('Not connected');
|
||||||
|
|
||||||
final response = await client.callRPC(
|
final response = await client.callRPC(
|
||||||
'/api/loyalty/fetch_notifications',
|
'/api/loyalty/fetch_notifications',
|
||||||
'call',
|
'call',
|
||||||
{'last_id': 0}
|
{'last_id': 0},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response != null && response['status'] == 'success') {
|
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) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_notifications = response['data'];
|
_notifications = fetched;
|
||||||
|
_readIds = readIds;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw Exception("Invalid response from server");
|
throw Exception('Invalid response from server');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isLoading = false);
|
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
|
body: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _notifications.isEmpty
|
: _notifications.isEmpty
|
||||||
? const Center(child: Text('No new promos.', style: TextStyle(fontSize: 16)))
|
? Center(
|
||||||
: ListView.builder(
|
child: Column(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
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,
|
itemCount: _notifications.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final notif = _notifications[index];
|
final notif = _notifications[index];
|
||||||
return Container(
|
final isUnread = !_readIds.contains((notif['id'] as int? ?? 0).toString());
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
return _NotificationCard(
|
||||||
decoration: const BoxDecoration(
|
notif: notif,
|
||||||
color: AppTheme.surfaceContainerLow,
|
isUnread: isUnread,
|
||||||
borderRadius: BorderRadius.zero,
|
onTap: () async {
|
||||||
),
|
final prefs = await SharedPreferences.getInstance();
|
||||||
child: ListTile(
|
final readIds = prefs.getStringList('read_notification_ids') ?? [];
|
||||||
contentPadding: const EdgeInsets.all(16),
|
final notifIdStr = (notif['id'] as int? ?? 0).toString();
|
||||||
leading: Container(
|
if (!readIds.contains(notifIdStr)) {
|
||||||
padding: const EdgeInsets.all(12),
|
readIds.add(notifIdStr);
|
||||||
decoration: BoxDecoration(
|
await prefs.setStringList('read_notification_ids', readIds);
|
||||||
color: AppTheme.primaryContainer.withOpacity(0.1),
|
|
||||||
shape: BoxShape.rectangle,
|
// Recalculate and update system badge count
|
||||||
),
|
final unreadCount = _notifications
|
||||||
child: const Icon(Icons.star, color: AppTheme.primary),
|
.where((n) => !readIds.contains((n['id'] as int? ?? 0).toString()))
|
||||||
),
|
.length;
|
||||||
title: Text(
|
await NotificationService().setBadge(unreadCount);
|
||||||
notif['title'] ?? 'Notice',
|
}
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
if (!mounted) return;
|
||||||
subtitle: Padding(
|
await Navigator.push(
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
context,
|
||||||
child: Text(
|
MaterialPageRoute(
|
||||||
notif['body'] ?? '',
|
builder: (_) =>
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
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:flutter/material.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../services/odoo_service.dart';
|
import '../services/odoo_service.dart';
|
||||||
|
import '../services/notification_service.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import 'login_screen.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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_phraseController.dispose();
|
_phraseController.dispose();
|
||||||
@ -234,6 +250,25 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 40),
|
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(
|
ElevatedButton(
|
||||||
onPressed: _showDeleteConfirmationDialog,
|
onPressed: _showDeleteConfirmationDialog,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:workmanager/workmanager.dart';
|
import 'package:workmanager/workmanager.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:odoo_rpc/odoo_rpc.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')
|
@pragma('vm:entry-point')
|
||||||
void callbackDispatcher() {
|
void callbackDispatcher() {
|
||||||
@ -11,52 +15,70 @@ void callbackDispatcher() {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final url = prefs.getString('odoo_url');
|
final url = prefs.getString('odoo_url');
|
||||||
final sessionStr = prefs.getString('odoo_session');
|
final sessionStr = prefs.getString('odoo_session');
|
||||||
final lastNotificationId = prefs.getInt('last_notification_id') ?? 0;
|
|
||||||
|
|
||||||
if (url == null || sessionStr == null) {
|
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 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 client = OdooClient(url, sessionId: session);
|
||||||
|
|
||||||
final response = await client.callRPC(
|
final response = await client.callRPC(
|
||||||
'/api/loyalty/fetch_notifications',
|
'/api/loyalty/fetch_notifications',
|
||||||
'call',
|
'call',
|
||||||
{'last_id': lastNotificationId}
|
{'last_id': 0},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
client.close();
|
||||||
|
|
||||||
if (response != null && response['status'] == 'success') {
|
if (response != null && response['status'] == 'success') {
|
||||||
final notifications = response['data'] as List;
|
final List<dynamic> notifications =
|
||||||
if (notifications.isNotEmpty) {
|
List<dynamic>.from(response['data'] ?? []);
|
||||||
int highestId = lastNotificationId;
|
|
||||||
|
|
||||||
final flnp = FlutterLocalNotificationsPlugin();
|
// Filter to only truly new ones not yet shown on device tray
|
||||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
final newNotifs = notifications
|
||||||
const initSettings = InitializationSettings(android: androidSettings);
|
.where((n) => (n['id'] as int? ?? 0) > lastDeviceNotifiedId)
|
||||||
await flnp.initialize(settings: initSettings);
|
.toList();
|
||||||
|
|
||||||
for (final notif in notifications) {
|
final notifService = NotificationService();
|
||||||
final int notifId = notif['id'];
|
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;
|
if (notifId > highestId) highestId = notifId;
|
||||||
|
|
||||||
const androidConfig = AndroidNotificationDetails(
|
await notifService.showNotification(
|
||||||
'loyalty_channel', 'Promos',
|
|
||||||
importance: Importance.max, priority: Priority.high
|
|
||||||
);
|
|
||||||
await flnp.show(
|
|
||||||
id: notifId,
|
id: notifId,
|
||||||
title: notif['title'],
|
title: notif['title'] as String? ?? 'Mie Mapan',
|
||||||
body: notif['body'],
|
body: notif['body'] as String? ?? '',
|
||||||
notificationDetails: const NotificationDetails(android: androidConfig)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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) {
|
} catch (_) {
|
||||||
print('Background Fetch Error: $e');
|
// Silently swallow — background isolate must never crash
|
||||||
}
|
}
|
||||||
return Future.value(true);
|
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;
|
OdooClient? client;
|
||||||
|
|
||||||
void connect(String url) {
|
void connect(String url, {OdooSession? session}) {
|
||||||
client = OdooClient(url);
|
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 {
|
Future<OdooSession> login(String db, String username, String password) async {
|
||||||
if (client == null) throw Exception("Connect to Odoo first");
|
if (client == null) throw Exception("Connect to Odoo first");
|
||||||
return await client!.authenticate(db, username, password);
|
return await client!.authenticate(db, username, password);
|
||||||
|
|||||||
@ -5,11 +5,13 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import app_badge_plus
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
AppBadgePlusPlugin.register(with: registry.registrar(forPlugin: "AppBadgePlusPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
|
|||||||
56
pubspec.lock
56
pubspec.lock
@ -1,6 +1,14 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
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:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -400,6 +408,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
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:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -40,6 +40,8 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.4
|
shared_preferences: ^2.5.4
|
||||||
google_fonts: ^6.2.1
|
google_fonts: ^6.2.1
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
|
app_badge_plus: ^1.3.1
|
||||||
|
permission_handler: ^12.0.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@ -1,29 +1,12 @@
|
|||||||
// This is a basic Flutter widget test.
|
// 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:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:odoo_loyalty_app/main.dart';
|
import 'package:odoo_loyalty_app/main.dart';
|
||||||
|
import 'package:odoo_loyalty_app/screens/login_screen.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
testWidgets('App renders without crash', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(const OdooLoyaltyApp());
|
await tester.pumpWidget(const OdooLoyaltyApp(homeWidget: LoginScreen()));
|
||||||
|
expect(find.byType(OdooLoyaltyApp), findsOneWidget);
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,12 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
permission_handler_windows
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user