diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b31ac4d..dfa2458 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -22,6 +22,7 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true } kotlinOptions { @@ -60,3 +61,7 @@ android { flutter { source = "../.." } + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1587d4d..e052974 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + UIStatusBarHidden + UIBackgroundModes + + fetch + processing + diff --git a/lib/main.dart b/lib/main.dart index 7deb3ab..137fb71 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,18 +1,23 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'i18n.dart'; import 'services/auth_service.dart'; +import 'services/background_notification_task.dart'; import 'services/category_service.dart'; import 'services/checklist_service.dart'; import 'services/house_service.dart'; +import 'services/local_notifications_service.dart'; import 'services/note_service.dart'; import 'services/photo_service.dart'; import 'services/prefs_service.dart'; import 'services/theming_service.dart'; import 'views/home/home_view.dart'; import 'views/login/login_view.dart'; +import 'views/notifications_intro/notifications_intro_view.dart'; final rootNavigatorKey = GlobalKey(); @@ -23,6 +28,7 @@ void main() async { } await AuthService.instance.loadCredentials(); await PrefsService.instance.load(); + await LocalNotificationsService.instance.init(); if (AuthService.instance.isLoggedIn) { await Future.wait([ ThemingService.instance.fetchTheme(), @@ -32,6 +38,10 @@ void main() async { PhotoService.instance.cache.load(), NoteService.instance.cache.load(), ]); + // Kick off the periodic background poll if notifications are enabled. + if (PrefsService.instance.notificationsEnabled) { + unawaited(registerBackgroundNotificationPoll()); + } } runApp(const PantryApp()); } @@ -49,11 +59,20 @@ class PantryAppState extends State { Future _onLoginSuccess() async { await ThemingService.instance.fetchTheme(); _isLoggedIn = true; - rootNavigatorKey.currentState?.pushReplacementNamed('/home'); + final nextRoute = PrefsService.instance.notificationsIntroSeen + ? '/home' + : '/notifications-intro'; + rootNavigatorKey.currentState?.pushReplacementNamed(nextRoute); if (mounted) setState(() {}); } + void _onIntroDone() { + rootNavigatorKey.currentState?.pushReplacementNamed('/home'); + } + Future _onLogout() async { + await cancelBackgroundNotificationPoll(); + await LocalNotificationsService.instance.cancelAll(); await AuthService.instance.logout(); ThemingService.instance.clear(); await Future.wait([ @@ -106,7 +125,9 @@ class PantryAppState extends State { onGenerateInitialRoutes: (initialRoute) => [ MaterialPageRoute( builder: (_) => _isLoggedIn - ? HomeView(onLogout: _onLogout) + ? (PrefsService.instance.notificationsIntroSeen + ? HomeView(onLogout: _onLogout) + : NotificationsIntroView(onDone: _onIntroDone)) : LoginView(onLoginSuccess: _onLoginSuccess), ), ], @@ -116,6 +137,10 @@ class PantryAppState extends State { return MaterialPageRoute( builder: (_) => HomeView(onLogout: _onLogout), ); + case '/notifications-intro': + return MaterialPageRoute( + builder: (_) => NotificationsIntroView(onDone: _onIntroDone), + ); case '/login': default: return MaterialPageRoute( diff --git a/lib/messages.i18n.dart b/lib/messages.i18n.dart index 95cf791..f3bc165 100644 --- a/lib/messages.i18n.dart +++ b/lib/messages.i18n.dart @@ -66,6 +66,10 @@ class Messages { LoginMessages get login => LoginMessages(this); HomeMessages get home => HomeMessages(this); NavMessages get nav => NavMessages(this); + NotificationsIntroMessages get notificationsIntro => + NotificationsIntroMessages(this); + SettingsMessages get settings => SettingsMessages(this); + NotificationsMessages get notifications => NotificationsMessages(this); CategoriesMessages get categories => CategoriesMessages(this); ChecklistsMessages get checklists => ChecklistsMessages(this); NotesWallMessages get notesWall => NotesWallMessages(this); @@ -245,6 +249,170 @@ class NavMessages { String get notesWall => """Notes Wall"""; } +class NotificationsIntroMessages { + final Messages _parent; + const NotificationsIntroMessages(this._parent); + + /// ```dart + /// "Stay in the loop" + /// ``` + String get title => """Stay in the loop"""; + + /// ```dart + /// "Pantry can notify you when household members add items to checklists, upload photos, or leave notes. Notifications are fetched from your own Nextcloud server — nothing goes through Google or third parties." + /// ``` + String get body => + """Pantry can notify you when household members add items to checklists, upload photos, or leave notes. Notifications are fetched from your own Nextcloud server — nothing goes through Google or third parties."""; + + /// ```dart + /// "Household activity alerts" + /// ``` + String get bullet1 => """Household activity alerts"""; + + /// ```dart + /// "Fetched directly from your server" + /// ``` + String get bullet2 => """Fetched directly from your server"""; + + /// ```dart + /// "Works even when the app is closed" + /// ``` + String get bullet3 => """Works even when the app is closed"""; + + /// ```dart + /// "Enable notifications" + /// ``` + String get enableButton => """Enable notifications"""; + + /// ```dart + /// "Not now" + /// ``` + String get skipButton => """Not now"""; + + /// ```dart + /// "Permission denied" + /// ``` + String get permissionDeniedTitle => """Permission denied"""; + + /// ```dart + /// "You can enable notifications later in App Settings. If your device blocks them, you'll need to allow them in system settings first." + /// ``` + String get permissionDeniedBody => + """You can enable notifications later in App Settings. If your device blocks them, you'll need to allow them in system settings first."""; + + /// ```dart + /// "OK" + /// ``` + String get ok => """OK"""; +} + +class SettingsMessages { + final Messages _parent; + const SettingsMessages(this._parent); + + /// ```dart + /// "App Settings" + /// ``` + String get title => """App Settings"""; + + /// ```dart + /// "Notifications" + /// ``` + String get notificationsSection => """Notifications"""; + + /// ```dart + /// "Enable notifications" + /// ``` + String get enableNotifications => """Enable notifications"""; + + /// ```dart + /// "Show alerts when household members add or update content." + /// ``` + String get enableNotificationsBody => + """Show alerts when household members add or update content."""; + + /// ```dart + /// "Check for new activity" + /// ``` + String get pollInterval => """Check for new activity"""; + + /// ```dart + /// "Every 15 minutes" + /// ``` + String get pollInterval15m => """Every 15 minutes"""; + + /// ```dart + /// "Every 30 minutes" + /// ``` + String get pollInterval30m => """Every 30 minutes"""; + + /// ```dart + /// "Every hour" + /// ``` + String get pollInterval1h => """Every hour"""; + + /// ```dart + /// "Every 2 hours" + /// ``` + String get pollInterval2h => """Every 2 hours"""; + + /// ```dart + /// "Every 6 hours" + /// ``` + String get pollInterval6h => """Every 6 hours"""; + + /// ```dart + /// "Notification permission was denied. Enable it in system settings." + /// ``` + String get permissionDenied => + """Notification permission was denied. Enable it in system settings."""; +} + +class NotificationsMessages { + final Messages _parent; + const NotificationsMessages(this._parent); + + /// ```dart + /// "Notifications" + /// ``` + String get title => """Notifications"""; + + /// ```dart + /// "No new notifications." + /// ``` + String get empty => """No new notifications."""; + + /// ```dart + /// "Failed to load notifications." + /// ``` + String get failedToLoad => """Failed to load notifications."""; + + /// ```dart + /// "Dismiss all" + /// ``` + String get dismissAll => """Dismiss all"""; + + /// ```dart + /// "just now" + /// ``` + String get justNow => """just now"""; + + /// ```dart + /// "${count}m ago" + /// ``` + String minutesAgo(int count) => """${count}m ago"""; + + /// ```dart + /// "${count}h ago" + /// ``` + String hoursAgo(int count) => """${count}h ago"""; + + /// ```dart + /// "${count}d ago" + /// ``` + String daysAgo(int count) => """${count}d ago"""; +} + class CategoriesMessages { final Messages _parent; const CategoriesMessages(this._parent); @@ -988,6 +1156,36 @@ Please complete login in your browser.""", """nav.checklists""": """Checklists""", """nav.photoBoard""": """Photo Board""", """nav.notesWall""": """Notes Wall""", + """notificationsIntro.title""": """Stay in the loop""", + """notificationsIntro.body""": + """Pantry can notify you when household members add items to checklists, upload photos, or leave notes. Notifications are fetched from your own Nextcloud server — nothing goes through Google or third parties.""", + """notificationsIntro.bullet1""": """Household activity alerts""", + """notificationsIntro.bullet2""": """Fetched directly from your server""", + """notificationsIntro.bullet3""": """Works even when the app is closed""", + """notificationsIntro.enableButton""": """Enable notifications""", + """notificationsIntro.skipButton""": """Not now""", + """notificationsIntro.permissionDeniedTitle""": """Permission denied""", + """notificationsIntro.permissionDeniedBody""": + """You can enable notifications later in App Settings. If your device blocks them, you'll need to allow them in system settings first.""", + """notificationsIntro.ok""": """OK""", + """settings.title""": """App Settings""", + """settings.notificationsSection""": """Notifications""", + """settings.enableNotifications""": """Enable notifications""", + """settings.enableNotificationsBody""": + """Show alerts when household members add or update content.""", + """settings.pollInterval""": """Check for new activity""", + """settings.pollInterval15m""": """Every 15 minutes""", + """settings.pollInterval30m""": """Every 30 minutes""", + """settings.pollInterval1h""": """Every hour""", + """settings.pollInterval2h""": """Every 2 hours""", + """settings.pollInterval6h""": """Every 6 hours""", + """settings.permissionDenied""": + """Notification permission was denied. Enable it in system settings.""", + """notifications.title""": """Notifications""", + """notifications.empty""": """No new notifications.""", + """notifications.failedToLoad""": """Failed to load notifications.""", + """notifications.dismissAll""": """Dismiss all""", + """notifications.justNow""": """just now""", """categories.manageTitle""": """Manage categories""", """categories.noCategories""": """No categories yet.""", """categories.editTitle""": """Edit category""", diff --git a/lib/messages.i18n.yaml b/lib/messages.i18n.yaml index fa81cd2..cef9d77 100644 --- a/lib/messages.i18n.yaml +++ b/lib/messages.i18n.yaml @@ -35,6 +35,41 @@ nav: photoBoard: Photo Board notesWall: Notes Wall +notificationsIntro: + title: Stay in the loop + body: "Pantry can notify you when household members add items to checklists, upload photos, or leave notes. Notifications are fetched from your own Nextcloud server — nothing goes through Google or third parties." + bullet1: Household activity alerts + bullet2: Fetched directly from your server + bullet3: Works even when the app is closed + enableButton: Enable notifications + skipButton: Not now + permissionDeniedTitle: Permission denied + permissionDeniedBody: "You can enable notifications later in App Settings. If your device blocks them, you'll need to allow them in system settings first." + ok: OK + +settings: + title: App Settings + notificationsSection: Notifications + enableNotifications: Enable notifications + enableNotificationsBody: Show alerts when household members add or update content. + pollInterval: Check for new activity + pollInterval15m: Every 15 minutes + pollInterval30m: Every 30 minutes + pollInterval1h: Every hour + pollInterval2h: Every 2 hours + pollInterval6h: Every 6 hours + permissionDenied: Notification permission was denied. Enable it in system settings. + +notifications: + title: Notifications + empty: No new notifications. + failedToLoad: Failed to load notifications. + dismissAll: Dismiss all + justNow: just now + minutesAgo(int count): "${count}m ago" + hoursAgo(int count): "${count}h ago" + daysAgo(int count): "${count}d ago" + categories: manageTitle: Manage categories noCategories: No categories yet. diff --git a/lib/models/notification.dart b/lib/models/notification.dart new file mode 100644 index 0000000..6a54a8a --- /dev/null +++ b/lib/models/notification.dart @@ -0,0 +1,77 @@ +/// Which tab a notification maps to on the home screen. +enum NotificationTarget { checklists, photos, notes } + +/// A Nextcloud notification as returned by the Notifications OCS API. +class NcNotification { + final int notificationId; + final String app; + final String user; + final String subject; + final String message; + final String datetime; + final String objectType; + final String objectId; + final String? icon; + final String? link; + final Map subjectRichParameters; + + const NcNotification({ + required this.notificationId, + required this.app, + required this.user, + required this.subject, + required this.message, + required this.datetime, + required this.objectType, + required this.objectId, + this.icon, + this.link, + this.subjectRichParameters = const {}, + }); + + factory NcNotification.fromJson(Map json) => NcNotification( + notificationId: json['notification_id'] as int, + app: json['app'] as String? ?? '', + user: json['user'] as String? ?? '', + // Prefer `subject` (already-parsed plain text) over `subjectRich` + // (template with `{placeholder}` tokens). Nextcloud populates both. + subject: + (json['subject'] as String?) ?? (json['subjectRich'] as String?) ?? '', + message: + (json['message'] as String?) ?? (json['messageRich'] as String?) ?? '', + datetime: json['datetime'] as String? ?? '', + objectType: json['object_type'] as String? ?? '', + objectId: json['object_id'] as String? ?? '', + icon: json['icon'] as String?, + link: json['link'] as String?, + subjectRichParameters: + (json['subjectRichParameters'] as Map?) ?? const {}, + ); + + /// Parsed timestamp or null if unparseable. + DateTime? get parsedDatetime => DateTime.tryParse(datetime); + + /// House id extracted from the rich parameters, if present. + int? get houseId { + final house = subjectRichParameters['house']; + if (house is! Map) return null; + final id = house['id']; + if (id is int) return id; + if (id is String) return int.tryParse(id); + return null; + } + + /// Which tab this notification should open on tap. + NotificationTarget? get target { + switch (objectType) { + case 'photo': + return NotificationTarget.photos; + case 'note': + return NotificationTarget.notes; + case 'item': + return NotificationTarget.checklists; + default: + return null; + } + } +} diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart index 4dc8ae0..845fb77 100644 --- a/lib/services/api_client.dart +++ b/lib/services/api_client.dart @@ -14,10 +14,17 @@ class ApiException implements Exception { } class ApiClient { - ApiClient._(); - static final ApiClient instance = ApiClient._(); + final String basePath; - static const _basePath = '/ocs/v2.php/apps/pantry/api'; + /// Creates a client for the given base path (appended to the server URL + /// from [AuthService]). Use [ApiClient.instance] for the default Pantry + /// endpoint. + const ApiClient({required this.basePath}); + + /// Default Pantry app API client. + static const ApiClient instance = ApiClient( + basePath: '/ocs/v2.php/apps/pantry/api', + ); NextcloudCredentials get _credentials { final creds = AuthService.instance.credentials; @@ -28,7 +35,7 @@ class ApiClient { Uri _uri(String path, [Map? queryParameters]) { final base = Uri.parse(_credentials.serverUrl); return base.replace( - path: '$_basePath$path', + path: '$basePath$path', queryParameters: queryParameters, ); } diff --git a/lib/services/background_notification_task.dart b/lib/services/background_notification_task.dart new file mode 100644 index 0000000..dc64dc6 --- /dev/null +++ b/lib/services/background_notification_task.dart @@ -0,0 +1,119 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:pantry/models/notification.dart'; +import 'package:pantry/services/auth_service.dart'; +import 'package:pantry/services/deep_link_service.dart'; +import 'package:pantry/services/local_notifications_service.dart'; +import 'package:pantry/services/notification_service.dart'; +import 'package:pantry/services/prefs_service.dart'; +import 'package:workmanager/workmanager.dart'; + +/// Unique name for the periodic notification poll task. +const notificationPollTaskName = 'pantry-notification-poll'; + +/// Secure storage key used to persist which notification IDs we've +/// already shown as local notifications (to avoid re-notifying). +const _seenIdsKey = 'seen_notification_ids'; + +/// Top-level function required by workmanager. Must be annotated +/// `@pragma('vm:entry-point')` so tree-shaking doesn't strip it. +@pragma('vm:entry-point') +void backgroundCallbackDispatcher() { + Workmanager().executeTask((task, inputData) async { + if (task != notificationPollTaskName) return true; + try { + await _pollAndNotify(); + } catch (e) { + debugPrint('[bg-notify] task failed: $e'); + } + return true; + }); +} + +Future _pollAndNotify() async { + // In a background isolate, AuthService and PrefsService are fresh + // instances — load credentials + user prefs. + await AuthService.instance.loadCredentials(); + if (!AuthService.instance.isLoggedIn) return; + + await PrefsService.instance.load(); + if (!PrefsService.instance.notificationsEnabled) return; + + final notifications = await NotificationService.instance.getNotifications(); + if (notifications.isEmpty) return; + + // Compare against stored seen IDs. + const storage = FlutterSecureStorage(); + final seenRaw = await storage.read(key: _seenIdsKey); + final seen = seenRaw == null || seenRaw.isEmpty + ? {} + : seenRaw.split(',').map(int.parse).toSet(); + + final newOnes = notifications + .where((n) => !seen.contains(n.notificationId)) + .toList(); + + if (newOnes.isEmpty) return; + + await LocalNotificationsService.instance.init(); + + for (final n in newOnes) { + await LocalNotificationsService.instance.show( + id: n.notificationId, + title: n.subject, + body: n.message.isNotEmpty ? n.message : null, + payload: _payloadFor(n), + ); + } + + // Persist the current set of IDs (drop stale entries — keep only what the + // server still returns, to avoid the list growing unbounded). + final currentIds = notifications.map((n) => n.notificationId).toSet(); + await storage.write(key: _seenIdsKey, value: currentIds.join(',')); +} + +/// Marks the currently visible notifications as "seen" without showing +/// a local notification. Called from the foreground after the user +/// opens the app so we don't re-alert them. +Future markCurrentNotificationsAsSeen(List ids) async { + const storage = FlutterSecureStorage(); + await storage.write(key: _seenIdsKey, value: ids.join(',')); +} + +/// Schedule the periodic background poll using the user's configured +/// interval from [PrefsService] (minimum 15 minutes on Android). +Future registerBackgroundNotificationPoll() async { + await Workmanager().initialize(backgroundCallbackDispatcher); + final minutes = PrefsService.instance.pollIntervalMinutes; + // Android enforces a 15-minute minimum for periodic tasks. + final clamped = minutes < 15 ? 15 : minutes; + await Workmanager().registerPeriodicTask( + notificationPollTaskName, + notificationPollTaskName, + frequency: Duration(minutes: clamped), + constraints: Constraints(networkType: NetworkType.connected), + existingWorkPolicy: ExistingPeriodicWorkPolicy.replace, + ); +} + +Future cancelBackgroundNotificationPoll() async { + await Workmanager().cancelByUniqueName(notificationPollTaskName); +} + +/// Cancel then re-register with the latest interval. Call this after +/// the user changes poll frequency in settings. +Future rescheduleBackgroundNotificationPoll() async { + await cancelBackgroundNotificationPoll(); + await registerBackgroundNotificationPoll(); +} + +String? _payloadFor(NcNotification n) { + final target = n.target; + if (target == null) return null; + final tab = switch (target) { + NotificationTarget.checklists => 0, + NotificationTarget.photos => 1, + NotificationTarget.notes => 2, + }; + return DeepLink(tabIndex: tab, houseId: n.houseId).encode(); +} diff --git a/lib/services/deep_link_service.dart b/lib/services/deep_link_service.dart new file mode 100644 index 0000000..3a0b56a --- /dev/null +++ b/lib/services/deep_link_service.dart @@ -0,0 +1,48 @@ +import 'package:flutter/foundation.dart'; + +/// A deferred navigation intent produced by a notification tap. The home +/// view consumes these and switches to the correct tab + house. +class DeepLink { + /// 0 = checklists, 1 = photos, 2 = notes + final int tabIndex; + final int? houseId; + + const DeepLink({required this.tabIndex, this.houseId}); + + /// Serialize to a compact string for notification payloads. + String encode() => '$tabIndex:${houseId ?? ''}'; + + /// Parse a payload string. Returns null if invalid. + static DeepLink? decode(String? payload) { + if (payload == null || payload.isEmpty) return null; + final parts = payload.split(':'); + if (parts.isEmpty) return null; + final tab = int.tryParse(parts[0]); + if (tab == null || tab < 0 || tab > 2) return null; + final houseId = parts.length > 1 && parts[1].isNotEmpty + ? int.tryParse(parts[1]) + : null; + return DeepLink(tabIndex: tab, houseId: houseId); + } +} + +/// Singleton holding the most recent pending deep link. The home view +/// observes [pending] via [ValueListenable] and consumes it on navigation. +class DeepLinkService { + DeepLinkService._(); + static final DeepLinkService instance = DeepLinkService._(); + + final ValueNotifier pending = ValueNotifier(null); + + /// Schedule a deep link. Called from notification tap handlers. + void push(DeepLink link) { + pending.value = link; + } + + /// Consume (and clear) the current pending link. Returns null if none. + DeepLink? consume() { + final link = pending.value; + pending.value = null; + return link; + } +} diff --git a/lib/services/local_notifications_service.dart b/lib/services/local_notifications_service.dart new file mode 100644 index 0000000..8e1b593 --- /dev/null +++ b/lib/services/local_notifications_service.dart @@ -0,0 +1,119 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:pantry/services/deep_link_service.dart'; + +class LocalNotificationsService { + LocalNotificationsService._(); + static final LocalNotificationsService instance = + LocalNotificationsService._(); + + final _plugin = FlutterLocalNotificationsPlugin(); + bool _initialized = false; + + static const _channelId = 'pantry_notifications'; + static const _channelName = 'Pantry notifications'; + static const _channelDescription = 'Household activity from your pantry'; + + Future init() async { + if (_initialized) return; + + const androidSettings = AndroidInitializationSettings( + '@mipmap/ic_launcher', + ); + const iosSettings = DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ); + const settings = InitializationSettings( + android: androidSettings, + iOS: iosSettings, + ); + + await _plugin.initialize( + settings, + onDidReceiveNotificationResponse: _onTap, + ); + + // If the app was launched by tapping a notification (cold start), + // capture that payload so the home view can consume it after startup. + final launchDetails = await _plugin.getNotificationAppLaunchDetails(); + if (launchDetails?.didNotificationLaunchApp ?? false) { + final payload = launchDetails?.notificationResponse?.payload; + final link = DeepLink.decode(payload); + if (link != null) DeepLinkService.instance.push(link); + } + + // Create the Android channel (no-op on iOS). + final androidPlugin = _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + await androidPlugin?.createNotificationChannel( + const AndroidNotificationChannel( + _channelId, + _channelName, + description: _channelDescription, + importance: Importance.defaultImportance, + ), + ); + + _initialized = true; + } + + /// Request runtime notification permission (Android 13+ and iOS). + Future requestPermission() async { + await init(); + + final android = _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + final androidGranted = + await android?.requestNotificationsPermission() ?? true; + + final ios = _plugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >(); + final iosGranted = + await ios?.requestPermissions(alert: true, badge: true, sound: true) ?? + true; + + return androidGranted && iosGranted; + } + + Future show({ + required int id, + required String title, + String? body, + String? payload, + }) async { + await init(); + await _plugin.show( + id, + title, + body, + const NotificationDetails( + android: AndroidNotificationDetails( + _channelId, + _channelName, + channelDescription: _channelDescription, + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + ), + iOS: DarwinNotificationDetails(), + ), + payload: payload, + ); + } + + static void _onTap(NotificationResponse response) { + final link = DeepLink.decode(response.payload); + if (link != null) DeepLinkService.instance.push(link); + } + + Future cancelAll() async { + await init(); + await _plugin.cancelAll(); + } +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart new file mode 100644 index 0000000..f476573 --- /dev/null +++ b/lib/services/notification_service.dart @@ -0,0 +1,50 @@ +import 'package:flutter/foundation.dart'; +import 'package:pantry/models/notification.dart'; +import 'package:pantry/services/api_client.dart'; + +class NotificationService { + NotificationService._(); + static final NotificationService instance = NotificationService._(); + + static const _client = ApiClient( + basePath: '/ocs/v2.php/apps/notifications/api/v2', + ); + + /// Fetch all notifications, filtered to this app only. + Future> getNotifications() async { + try { + return await _client.get>( + '/notifications', + fromJson: (data) => data + .map((e) => NcNotification.fromJson(e as Map)) + .where((n) => n.app == 'pantry') + .toList(), + ); + } on ApiException catch (e) { + // Notifications app not installed / disabled + if (e.statusCode == 404) return []; + rethrow; + } + } + + /// Delete (mark as read) a single notification. + Future dismiss(int notificationId) async { + try { + await _client.delete('/notifications/$notificationId'); + } on ApiException catch (e) { + if (e.statusCode == 404) return; // already gone + rethrow; + } + } + + /// Delete all given notifications. + Future dismissAll(List ids) async { + for (final id in ids) { + try { + await dismiss(id); + } catch (e) { + debugPrint('[NotificationService] Failed to dismiss $id: $e'); + } + } + } +} diff --git a/lib/services/prefs_service.dart b/lib/services/prefs_service.dart index 817d500..fb25a8b 100644 --- a/lib/services/prefs_service.dart +++ b/lib/services/prefs_service.dart @@ -5,16 +5,38 @@ class PrefsService { static final PrefsService instance = PrefsService._(); static const _lastHouseKey = 'last_house_id'; + static const _notificationsEnabledKey = 'notifications_enabled'; + static const _pollIntervalMinutesKey = 'poll_interval_minutes'; + static const _notificationsIntroSeenKey = 'notifications_intro_seen'; final _storage = const FlutterSecureStorage(); int? _lastHouseId; int? get lastHouseId => _lastHouseId; + bool _notificationsEnabled = true; + bool get notificationsEnabled => _notificationsEnabled; + + int _pollIntervalMinutes = 15; + int get pollIntervalMinutes => _pollIntervalMinutes; + + bool _notificationsIntroSeen = false; + bool get notificationsIntroSeen => _notificationsIntroSeen; + Future load() async { - final value = await _storage.read(key: _lastHouseKey); - if (value != null) { - _lastHouseId = int.tryParse(value); + final lastHouse = await _storage.read(key: _lastHouseKey); + if (lastHouse != null) _lastHouseId = int.tryParse(lastHouse); + + final notif = await _storage.read(key: _notificationsEnabledKey); + if (notif != null) _notificationsEnabled = notif == 'true'; + + final poll = await _storage.read(key: _pollIntervalMinutesKey); + if (poll != null) { + final parsed = int.tryParse(poll); + if (parsed != null && parsed > 0) _pollIntervalMinutes = parsed; } + + final intro = await _storage.read(key: _notificationsIntroSeenKey); + if (intro != null) _notificationsIntroSeen = intro == 'true'; } Future setLastHouseId(int id) async { @@ -22,8 +44,38 @@ class PrefsService { await _storage.write(key: _lastHouseKey, value: id.toString()); } + Future setNotificationsEnabled(bool value) async { + _notificationsEnabled = value; + await _storage.write( + key: _notificationsEnabledKey, + value: value.toString(), + ); + } + + Future setPollIntervalMinutes(int minutes) async { + _pollIntervalMinutes = minutes; + await _storage.write( + key: _pollIntervalMinutesKey, + value: minutes.toString(), + ); + } + + Future setNotificationsIntroSeen(bool value) async { + _notificationsIntroSeen = value; + await _storage.write( + key: _notificationsIntroSeenKey, + value: value.toString(), + ); + } + Future clear() async { _lastHouseId = null; + _notificationsEnabled = true; + _pollIntervalMinutes = 15; + _notificationsIntroSeen = false; await _storage.delete(key: _lastHouseKey); + await _storage.delete(key: _notificationsEnabledKey); + await _storage.delete(key: _pollIntervalMinutesKey); + await _storage.delete(key: _notificationsIntroSeenKey); } } diff --git a/lib/views/home/home_view.dart b/lib/views/home/home_view.dart index 213fe73..e268000 100644 --- a/lib/views/home/home_view.dart +++ b/lib/views/home/home_view.dart @@ -5,9 +5,15 @@ import 'package:pantry/i18n.dart'; import 'package:pantry/views/categories/categories_view.dart'; import 'package:pantry/views/checklists/checklists_view.dart'; import 'package:pantry/views/notes/notes_wall_view.dart'; +import 'package:pantry/models/house.dart'; +import 'package:pantry/services/deep_link_service.dart'; +import 'package:pantry/views/notifications/notifications_controller.dart'; +import 'package:pantry/views/notifications/notifications_view.dart'; import 'package:pantry/views/photos/photo_board_view.dart'; +import 'package:pantry/views/settings/settings_view.dart'; import 'package:pantry/widgets/create_house_dialog.dart'; import 'package:pantry/widgets/no_houses_view.dart'; +import 'package:pantry/widgets/notifications_bell.dart'; import 'package:pantry/widgets/server_app_missing_view.dart'; import 'package:pantry/widgets/user_menu_button.dart'; import 'home_controller.dart'; @@ -54,8 +60,63 @@ class _HomeViewBody extends StatefulWidget { State<_HomeViewBody> createState() => _HomeViewBodyState(); } -class _HomeViewBodyState extends State<_HomeViewBody> { +class _HomeViewBodyState extends State<_HomeViewBody> + with WidgetsBindingObserver { int _tabIndex = 0; + final _notificationsController = NotificationsController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _notificationsController.load(); + + // Consume any deep link that arrived before we mounted (e.g. from a + // cold-start notification tap). + WidgetsBinding.instance.addPostFrameCallback((_) { + _consumePendingDeepLink(); + }); + + // Listen for deep links that arrive while the home view is mounted + // (notification tapped while app is in foreground or background). + DeepLinkService.instance.pending.addListener(_consumePendingDeepLink); + } + + @override + void dispose() { + DeepLinkService.instance.pending.removeListener(_consumePendingDeepLink); + WidgetsBinding.instance.removeObserver(this); + _notificationsController.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _notificationsController.refresh(); + _consumePendingDeepLink(); + } + } + + void _consumePendingDeepLink() { + final link = DeepLinkService.instance.consume(); + if (link == null) return; + final homeController = context.read(); + + // Switch house if specified and different from current. + if (link.houseId != null && + link.houseId != homeController.currentHouse?.id) { + final house = homeController.houses.cast().firstWhere( + (h) => h!.id == link.houseId, + orElse: () => null, + ); + if (house != null) { + homeController.selectHouse(house); + } + } + + if (mounted) setState(() => _tabIndex = link.tabIndex); + } String get _tabTitle => switch (_tabIndex) { 0 => m.nav.checklists, @@ -86,11 +147,27 @@ class _HomeViewBodyState extends State<_HomeViewBody> { ); }, ), + NotificationsBell( + controller: _notificationsController, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + NotificationsView(controller: _notificationsController), + ), + ); + }, + ), UserMenuButton( houses: controller.houses, currentHouse: controller.currentHouse, onHouseSelected: controller.selectHouse, onCreateHouse: () => showCreateHouseDialog(context, controller), + onOpenSettings: () { + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const SettingsView())); + }, onLogout: widget.onLogout, ), ], diff --git a/lib/views/notifications/notifications_controller.dart b/lib/views/notifications/notifications_controller.dart new file mode 100644 index 0000000..21cf76f --- /dev/null +++ b/lib/views/notifications/notifications_controller.dart @@ -0,0 +1,81 @@ +import 'package:flutter/foundation.dart'; +import 'package:pantry/models/notification.dart'; +import 'package:pantry/services/background_notification_task.dart'; +import 'package:pantry/services/notification_service.dart'; + +class NotificationsController extends ChangeNotifier { + NotificationsController(); + + List _notifications = []; + List get notifications => _notifications; + + int get unreadCount => _notifications.length; + + bool _isLoading = false; + bool get isLoading => _isLoading; + + String? _error; + String? get error => _error; + + Future load() async { + _error = null; + if (_notifications.isEmpty) { + _isLoading = true; + notifyListeners(); + } + + try { + _notifications = await NotificationService.instance.getNotifications(); + _isLoading = false; + notifyListeners(); + await _markAllSeen(); + } catch (e) { + debugPrint('[NotificationsController] Failed to load: $e'); + _isLoading = false; + _error = e.toString(); + notifyListeners(); + } + } + + Future refresh() async { + try { + _notifications = await NotificationService.instance.getNotifications(); + notifyListeners(); + await _markAllSeen(); + } catch (e) { + debugPrint('[NotificationsController] Failed to refresh: $e'); + } + } + + Future _markAllSeen() async { + final ids = _notifications.map((n) => n.notificationId).toList(); + try { + await markCurrentNotificationsAsSeen(ids); + } catch (e) { + debugPrint('[NotificationsController] Failed to mark seen: $e'); + } + } + + Future dismiss(NcNotification notification) async { + _notifications = _notifications + .where((n) => n.notificationId != notification.notificationId) + .toList(); + notifyListeners(); + try { + await NotificationService.instance.dismiss(notification.notificationId); + } catch (e) { + debugPrint('[NotificationsController] Failed to dismiss: $e'); + } + } + + Future dismissAll() async { + final ids = _notifications.map((n) => n.notificationId).toList(); + _notifications = []; + notifyListeners(); + try { + await NotificationService.instance.dismissAll(ids); + } catch (e) { + debugPrint('[NotificationsController] Failed to dismiss all: $e'); + } + } +} diff --git a/lib/views/notifications/notifications_view.dart b/lib/views/notifications/notifications_view.dart new file mode 100644 index 0000000..8ab7272 --- /dev/null +++ b/lib/views/notifications/notifications_view.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:pantry/i18n.dart'; +import 'package:pantry/models/notification.dart'; +import 'package:pantry/services/deep_link_service.dart'; +import 'notifications_controller.dart'; + +class NotificationsView extends StatefulWidget { + final NotificationsController controller; + + const NotificationsView({super.key, required this.controller}); + + @override + State createState() => _NotificationsViewState(); +} + +class _NotificationsViewState extends State { + @override + void initState() { + super.initState(); + widget.controller.refresh(); + } + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: widget.controller, + child: const _NotificationsBody(), + ); + } +} + +class _NotificationsBody extends StatelessWidget { + const _NotificationsBody(); + + void _openNotification(BuildContext context, NcNotification n) { + final target = n.target; + if (target == null) return; + final tab = switch (target) { + NotificationTarget.checklists => 0, + NotificationTarget.photos => 1, + NotificationTarget.notes => 2, + }; + DeepLinkService.instance.push(DeepLink(tabIndex: tab, houseId: n.houseId)); + // Pop back to home so it consumes the deep link. + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final controller = context.watch(); + + return Scaffold( + appBar: AppBar( + title: Text(m.notifications.title), + actions: [ + if (controller.notifications.isNotEmpty) + IconButton( + icon: const Icon(Icons.done_all), + tooltip: m.notifications.dismissAll, + onPressed: controller.dismissAll, + ), + ], + ), + body: _buildBody(context, controller), + ); + } + + Widget _buildBody(BuildContext context, NotificationsController controller) { + if (controller.isLoading && controller.notifications.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.error != null && controller.notifications.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(m.notifications.failedToLoad, textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton( + onPressed: controller.load, + child: Text(m.common.retry), + ), + ], + ), + ), + ); + } + + if (controller.notifications.isEmpty) { + return RefreshIndicator( + onRefresh: controller.refresh, + child: ListView( + children: [ + const SizedBox(height: 100), + Center(child: Text(m.notifications.empty)), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: controller.refresh, + child: ListView.separated( + itemCount: controller.notifications.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final n = controller.notifications[index]; + return _NotificationTile( + notification: n, + onDismiss: () => controller.dismiss(n), + onTap: () => _openNotification(context, n), + ); + }, + ), + ); + } +} + +class _NotificationTile extends StatelessWidget { + final NcNotification notification; + final VoidCallback onDismiss; + final VoidCallback onTap; + + const _NotificationTile({ + required this.notification, + required this.onDismiss, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Dismissible( + key: ValueKey(notification.notificationId), + direction: DismissDirection.endToStart, + onDismissed: (_) => onDismiss(), + background: Container( + color: theme.colorScheme.errorContainer, + alignment: Alignment.centerRight, + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Icon(Icons.delete, color: theme.colorScheme.onErrorContainer), + ), + child: ListTile( + onTap: notification.target != null ? onTap : null, + leading: CircleAvatar( + backgroundColor: theme.colorScheme.primaryContainer, + child: Icon( + Icons.notifications, + color: theme.colorScheme.onPrimaryContainer, + size: 20, + ), + ), + title: Text( + notification.subject, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: notification.message.isNotEmpty + ? Text( + notification.message, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ) + : null, + trailing: Text( + _formatRelative(notification.parsedDatetime), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + String _formatRelative(DateTime? dt) { + if (dt == null) return ''; + final diff = DateTime.now().toUtc().difference(dt.toUtc()); + if (diff.inMinutes < 1) return m.notifications.justNow; + if (diff.inHours < 1) return m.notifications.minutesAgo(diff.inMinutes); + if (diff.inDays < 1) return m.notifications.hoursAgo(diff.inHours); + return m.notifications.daysAgo(diff.inDays); + } +} diff --git a/lib/views/notifications_intro/notifications_intro_view.dart b/lib/views/notifications_intro/notifications_intro_view.dart new file mode 100644 index 0000000..2467059 --- /dev/null +++ b/lib/views/notifications_intro/notifications_intro_view.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; + +import 'package:pantry/i18n.dart'; +import 'package:pantry/services/background_notification_task.dart'; +import 'package:pantry/services/local_notifications_service.dart'; +import 'package:pantry/services/prefs_service.dart'; + +class NotificationsIntroView extends StatefulWidget { + final VoidCallback onDone; + + const NotificationsIntroView({super.key, required this.onDone}); + + @override + State createState() => _NotificationsIntroViewState(); +} + +class _NotificationsIntroViewState extends State { + bool _working = false; + + Future _enable() async { + setState(() => _working = true); + final granted = await LocalNotificationsService.instance + .requestPermission(); + if (!mounted) return; + + if (!granted) { + setState(() => _working = false); + await _showPermissionDeniedDialog(); + await _complete(enabled: false); + return; + } + + await PrefsService.instance.setNotificationsEnabled(true); + await registerBackgroundNotificationPoll(); + await _complete(enabled: true); + } + + Future _skip() async { + await PrefsService.instance.setNotificationsEnabled(false); + await _complete(enabled: false); + } + + Future _complete({required bool enabled}) async { + await PrefsService.instance.setNotificationsIntroSeen(true); + if (mounted) widget.onDone(); + } + + Future _showPermissionDeniedDialog() async { + final intro = m.notificationsIntro; + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(intro.permissionDeniedTitle), + content: Text(intro.permissionDeniedBody), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(ctx), + child: Text(intro.ok), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final intro = m.notificationsIntro; + + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(32, 48, 32, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.notifications_active_outlined, + size: 56, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(height: 32), + Text( + intro.title, + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + intro.body, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + _Bullet(icon: Icons.group_outlined, text: intro.bullet1), + const SizedBox(height: 12), + _Bullet(icon: Icons.dns_outlined, text: intro.bullet2), + const SizedBox(height: 12), + _Bullet( + icon: Icons.schedule_outlined, + text: intro.bullet3, + ), + ], + ), + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _working ? null : _enable, + icon: _working + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.notifications_active), + label: Text(intro.enableButton), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: _working ? null : _skip, + style: TextButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + child: Text(intro.skipButton), + ), + ], + ), + ), + ), + ); + } +} + +class _Bullet extends StatelessWidget { + final IconData icon; + final String text; + + const _Bullet({required this.icon, required this.text}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + Icon(icon, size: 22, color: theme.colorScheme.primary), + const SizedBox(width: 12), + Expanded(child: Text(text, style: theme.textTheme.bodyMedium)), + ], + ); + } +} diff --git a/lib/views/settings/settings_view.dart b/lib/views/settings/settings_view.dart new file mode 100644 index 0000000..1592469 --- /dev/null +++ b/lib/views/settings/settings_view.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; + +import 'package:pantry/i18n.dart'; +import 'package:pantry/services/background_notification_task.dart'; +import 'package:pantry/services/local_notifications_service.dart'; +import 'package:pantry/services/prefs_service.dart'; + +class SettingsView extends StatefulWidget { + const SettingsView({super.key}); + + @override + State createState() => _SettingsViewState(); +} + +class _SettingsViewState extends State { + late bool _notificationsEnabled; + late int _pollIntervalMinutes; + + static const _pollOptions = [15, 30, 60, 120, 360]; + + @override + void initState() { + super.initState(); + _notificationsEnabled = PrefsService.instance.notificationsEnabled; + _pollIntervalMinutes = PrefsService.instance.pollIntervalMinutes; + } + + Future _toggleNotifications(bool value) async { + if (value) { + final granted = await LocalNotificationsService.instance + .requestPermission(); + if (!granted) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(m.settings.permissionDenied))); + } + return; + } + } + + await PrefsService.instance.setNotificationsEnabled(value); + setState(() => _notificationsEnabled = value); + + if (value) { + await registerBackgroundNotificationPoll(); + } else { + await cancelBackgroundNotificationPoll(); + await LocalNotificationsService.instance.cancelAll(); + } + } + + Future _setPollInterval(int? minutes) async { + if (minutes == null || minutes == _pollIntervalMinutes) return; + await PrefsService.instance.setPollIntervalMinutes(minutes); + setState(() => _pollIntervalMinutes = minutes); + if (_notificationsEnabled) { + await rescheduleBackgroundNotificationPoll(); + } + } + + String _pollIntervalLabel(int minutes) => switch (minutes) { + 15 => m.settings.pollInterval15m, + 30 => m.settings.pollInterval30m, + 60 => m.settings.pollInterval1h, + 120 => m.settings.pollInterval2h, + 360 => m.settings.pollInterval6h, + _ => '$minutes min', + }; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: Text(m.settings.title)), + body: ListView( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + m.settings.notificationsSection, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + SwitchListTile( + title: Text(m.settings.enableNotifications), + subtitle: Text(m.settings.enableNotificationsBody), + value: _notificationsEnabled, + onChanged: _toggleNotifications, + ), + ListTile( + enabled: _notificationsEnabled, + title: Text(m.settings.pollInterval), + subtitle: Text(_pollIntervalLabel(_pollIntervalMinutes)), + trailing: DropdownButton( + value: _pollIntervalMinutes, + onChanged: _notificationsEnabled ? _setPollInterval : null, + items: [ + for (final minutes in _pollOptions) + DropdownMenuItem( + value: minutes, + child: Text(_pollIntervalLabel(minutes)), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/notifications_bell.dart b/lib/widgets/notifications_bell.dart new file mode 100644 index 0000000..2f30569 --- /dev/null +++ b/lib/widgets/notifications_bell.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:pantry/views/notifications/notifications_controller.dart'; + +class NotificationsBell extends StatelessWidget { + final NotificationsController controller; + final VoidCallback onTap; + + const NotificationsBell({ + super.key, + required this.controller, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: controller, + child: Consumer( + builder: (context, c, _) { + final theme = Theme.of(context); + final count = c.unreadCount; + return IconButton( + onPressed: onTap, + icon: Stack( + clipBehavior: Clip.none, + children: [ + const Icon(Icons.notifications_outlined), + if (count > 0) + Positioned( + right: -6, + top: -4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 1, + ), + decoration: BoxDecoration( + color: theme.colorScheme.error, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: theme.colorScheme.surface, + width: 1.5, + ), + ), + constraints: const BoxConstraints( + minWidth: 16, + minHeight: 16, + ), + child: Text( + count > 99 ? '99+' : '$count', + style: TextStyle( + color: theme.colorScheme.onError, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/user_menu_button.dart b/lib/widgets/user_menu_button.dart index 5e499b4..5fdd374 100644 --- a/lib/widgets/user_menu_button.dart +++ b/lib/widgets/user_menu_button.dart @@ -10,6 +10,7 @@ class UserMenuButton extends StatelessWidget { final House? currentHouse; final ValueChanged onHouseSelected; final VoidCallback onCreateHouse; + final VoidCallback onOpenSettings; final VoidCallback onLogout; const UserMenuButton({ @@ -18,6 +19,7 @@ class UserMenuButton extends StatelessWidget { required this.currentHouse, required this.onHouseSelected, required this.onCreateHouse, + required this.onOpenSettings, required this.onLogout, }); @@ -160,6 +162,16 @@ class UserMenuButton extends StatelessWidget { ), ), const PopupMenuDivider(), + PopupMenuItem( + value: 'settings', + child: Row( + children: [ + const Icon(Icons.settings_outlined, size: 18), + const SizedBox(width: 8), + Text(m.settings.title), + ], + ), + ), PopupMenuItem( value: 'logout', child: Row( @@ -177,6 +189,8 @@ class UserMenuButton extends StatelessWidget { onHouseSelected(value); } else if (value == 'create_house') { onCreateHouse(); + } else if (value == 'settings') { + onOpenSettings(); } else if (value == 'logout') { onLogout(); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index e7b0845..32a7f26 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import file_selector_macos +import flutter_local_notifications import flutter_secure_storage_darwin import package_info_plus import sqflite_darwin @@ -14,6 +15,7 @@ import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/pubspec.lock b/pubspec.lock index 106eff6..186a609 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -350,6 +350,30 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610 + url: "https://pub.dev" + source: hosted + version: "18.0.1" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" + url: "https://pub.dev" + source: hosted + version: "8.0.0" flutter_markdown_plus: dependency: "direct main" description: @@ -1046,6 +1070,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" timing: dependency: transitive description: @@ -1238,6 +1270,38 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + workmanager: + dependency: "direct main" + description: + name: workmanager + sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10" + url: "https://pub.dev" + source: hosted + version: "0.9.0+3" + workmanager_android: + dependency: transitive + description: + name: workmanager_android + sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7" + url: "https://pub.dev" + source: hosted + version: "0.9.0+2" + workmanager_apple: + dependency: transitive + description: + name: workmanager_apple + sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca" + url: "https://pub.dev" + source: hosted + version: "0.9.1+2" + workmanager_platform_interface: + dependency: transitive + description: + name: workmanager_platform_interface + sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47 + url: "https://pub.dev" + source: hosted + version: "0.9.1+1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f8d46ff..3166d36 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,8 +43,10 @@ dependencies: path_provider: ^2.1.5 intl: ^0.20.2 cached_network_image: ^3.4.1 + flutter_local_notifications: ^18.0.1 flutter_markdown_plus: ^1.0.3 image_picker: ^1.1.2 + workmanager: ^0.9.0+3 i18n: git: https://github.com/chenasraf/i18n diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index 99ba2a0..db889e4 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -3,9 +3,11 @@ import 'dart:typed_data'; import 'package:image_picker/image_picker.dart'; import 'package:pantry/models/house.dart'; import 'package:pantry/models/note.dart'; +import 'package:pantry/models/notification.dart'; import 'package:pantry/models/photo.dart'; import 'package:pantry/views/home/home_controller.dart'; import 'package:pantry/views/notes/notes_controller.dart'; +import 'package:pantry/views/notifications/notifications_controller.dart'; import 'package:pantry/views/photos/photo_board_controller.dart'; /// A fake [PhotoBoardController] that does not touch any services. @@ -174,6 +176,65 @@ class FakeHomeController extends HomeController { } } +/// A fake [NotificationsController] that does not touch any services. +class FakeNotificationsController extends NotificationsController { + FakeNotificationsController({ + List? notifications, + bool isLoading = false, + String? error, + }) : _notifications = notifications ?? [], + _isLoading = isLoading, + _error = error; + + final List _notifications; + @override + List get notifications => _notifications; + + @override + int get unreadCount => _notifications.length; + + final bool _isLoading; + @override + bool get isLoading => _isLoading; + + final String? _error; + @override + String? get error => _error; + + int loadCalls = 0; + int refreshCalls = 0; + int dismissCalls = 0; + int dismissAllCalls = 0; + NcNotification? lastDismissed; + + @override + Future load() async { + loadCalls++; + } + + @override + Future refresh() async { + refreshCalls++; + } + + @override + Future dismiss(NcNotification notification) async { + dismissCalls++; + lastDismissed = notification; + _notifications.removeWhere( + (n) => n.notificationId == notification.notificationId, + ); + notifyListeners(); + } + + @override + Future dismissAll() async { + dismissAllCalls++; + _notifications.clear(); + notifyListeners(); + } +} + /// Builds a fake [UploadTask] for tests. UploadTask makeUploadTask({ String fileName = 'photo.jpg', diff --git a/test/helpers/test_models.dart b/test/helpers/test_models.dart index 36125de..8760172 100644 --- a/test/helpers/test_models.dart +++ b/test/helpers/test_models.dart @@ -2,6 +2,7 @@ import 'package:pantry/models/category.dart'; import 'package:pantry/models/checklist.dart'; import 'package:pantry/models/house.dart'; import 'package:pantry/models/note.dart'; +import 'package:pantry/models/notification.dart'; import 'package:pantry/models/photo.dart'; const _now = 1700000000; @@ -124,6 +125,30 @@ ChecklistList makeChecklistList({ updatedAt: updatedAt ?? _now, ); +NcNotification makeNotification({ + int notificationId = 1, + String app = 'pantry', + String user = 'alice', + String subject = 'alice added an item', + String message = '', + String? datetime, + String objectType = 'item', + String objectId = '1', + String? icon, + String? link, +}) => NcNotification( + notificationId: notificationId, + app: app, + user: user, + subject: subject, + message: message, + datetime: datetime ?? '2026-04-11T12:00:00+00:00', + objectType: objectType, + objectId: objectId, + icon: icon, + link: link, +); + ListItem makeListItem({ int id = 1, int listId = 1, diff --git a/test/models/notification_test.dart b/test/models/notification_test.dart new file mode 100644 index 0000000..c3ba1f6 --- /dev/null +++ b/test/models/notification_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pantry/models/notification.dart'; + +void main() { + group('NcNotification.fromJson', () { + test('parses a minimal JSON payload', () { + final n = NcNotification.fromJson({ + 'notification_id': 42, + 'app': 'pantry', + 'user': 'alice', + 'subject': 'alice added an item', + 'datetime': '2026-04-11T12:00:00+00:00', + 'object_type': 'item', + 'object_id': '5', + }); + + expect(n.notificationId, 42); + expect(n.app, 'pantry'); + expect(n.user, 'alice'); + expect(n.subject, 'alice added an item'); + expect(n.message, ''); + expect(n.datetime, '2026-04-11T12:00:00+00:00'); + expect(n.objectType, 'item'); + expect(n.objectId, '5'); + expect(n.icon, null); + expect(n.link, null); + }); + + test('prefers parsed subject/message over rich templates', () { + final n = NcNotification.fromJson({ + 'notification_id': 1, + 'app': 'pantry', + 'user': 'u', + 'subject': 'Alice uploaded a photo in My Home', + 'subjectRich': '{user} uploaded a photo in {house}', + 'message': 'plain message', + 'messageRich': 'rich message template', + 'datetime': '2026-01-01T00:00:00+00:00', + 'object_type': 't', + 'object_id': '1', + }); + + expect(n.subject, 'Alice uploaded a photo in My Home'); + expect(n.message, 'plain message'); + }); + + test('falls back to rich templates when parsed subject is missing', () { + final n = NcNotification.fromJson({ + 'notification_id': 1, + 'subjectRich': '{user} uploaded a photo', + 'messageRich': 'rich message', + }); + + expect(n.subject, '{user} uploaded a photo'); + expect(n.message, 'rich message'); + }); + + test('falls back to empty strings when fields are missing', () { + final n = NcNotification.fromJson({'notification_id': 1}); + + expect(n.app, ''); + expect(n.user, ''); + expect(n.subject, ''); + expect(n.message, ''); + expect(n.datetime, ''); + expect(n.objectType, ''); + expect(n.objectId, ''); + }); + + test('preserves optional icon and link', () { + final n = NcNotification.fromJson({ + 'notification_id': 1, + 'app': 'pantry', + 'user': 'u', + 'subject': 's', + 'datetime': '2026-01-01T00:00:00+00:00', + 'object_type': 't', + 'object_id': '1', + 'icon': 'https://example.com/icon.png', + 'link': 'https://example.com/link', + }); + + expect(n.icon, 'https://example.com/icon.png'); + expect(n.link, 'https://example.com/link'); + }); + }); + + group('NcNotification.parsedDatetime', () { + test('parses a valid ISO-8601 string', () { + final n = NcNotification.fromJson({ + 'notification_id': 1, + 'datetime': '2026-04-11T12:30:45+00:00', + }); + + final dt = n.parsedDatetime; + expect(dt, isNotNull); + expect(dt!.year, 2026); + expect(dt.month, 4); + expect(dt.day, 11); + }); + + test('returns null for unparseable strings', () { + final n = NcNotification.fromJson({ + 'notification_id': 1, + 'datetime': 'not a date', + }); + + expect(n.parsedDatetime, null); + }); + + test('returns null for empty datetime', () { + final n = NcNotification.fromJson({'notification_id': 1}); + expect(n.parsedDatetime, null); + }); + }); +} diff --git a/test/views/notifications_view_test.dart b/test/views/notifications_view_test.dart new file mode 100644 index 0000000..6899725 --- /dev/null +++ b/test/views/notifications_view_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pantry/i18n.dart'; +import 'package:pantry/views/notifications/notifications_view.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/test_models.dart'; + +void main() { + group('NotificationsView', () { + testWidgets('shows empty state when there are no notifications', ( + tester, + ) async { + final controller = FakeNotificationsController(); + + await tester.pumpWidget( + MaterialApp(home: NotificationsView(controller: controller)), + ); + await tester.pumpAndSettle(); + + expect(find.text(m.notifications.empty), findsOneWidget); + }); + + testWidgets('renders a list of notifications', (tester) async { + final controller = FakeNotificationsController( + notifications: [ + makeNotification(notificationId: 1, subject: 'alice added Milk'), + makeNotification(notificationId: 2, subject: 'bob uploaded a photo'), + ], + ); + + await tester.pumpWidget( + MaterialApp(home: NotificationsView(controller: controller)), + ); + await tester.pumpAndSettle(); + + expect(find.text('alice added Milk'), findsOneWidget); + expect(find.text('bob uploaded a photo'), findsOneWidget); + }); + + testWidgets('shows the dismiss-all action when notifications exist', ( + tester, + ) async { + final controller = FakeNotificationsController( + notifications: [makeNotification()], + ); + + await tester.pumpWidget( + MaterialApp(home: NotificationsView(controller: controller)), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.done_all), findsOneWidget); + }); + + testWidgets('hides the dismiss-all action when empty', (tester) async { + final controller = FakeNotificationsController(); + + await tester.pumpWidget( + MaterialApp(home: NotificationsView(controller: controller)), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.done_all), findsNothing); + }); + + testWidgets('tapping dismiss-all calls controller.dismissAll', ( + tester, + ) async { + final controller = FakeNotificationsController( + notifications: [makeNotification()], + ); + + await tester.pumpWidget( + MaterialApp(home: NotificationsView(controller: controller)), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.done_all)); + await tester.pumpAndSettle(); + + expect(controller.dismissAllCalls, 1); + }); + + testWidgets('swipe-to-dismiss removes a notification', (tester) async { + final controller = FakeNotificationsController( + notifications: [ + makeNotification(notificationId: 1, subject: 'first'), + makeNotification(notificationId: 2, subject: 'second'), + ], + ); + + await tester.pumpWidget( + MaterialApp(home: NotificationsView(controller: controller)), + ); + await tester.pumpAndSettle(); + + await tester.drag(find.text('first'), const Offset(-500, 0)); + await tester.pumpAndSettle(); + + expect(controller.dismissCalls, 1); + expect(controller.lastDismissed?.notificationId, 1); + expect(find.text('first'), findsNothing); + expect(find.text('second'), findsOneWidget); + }); + }); +} diff --git a/test/widgets/notifications_bell_test.dart b/test/widgets/notifications_bell_test.dart new file mode 100644 index 0000000..6aa7d7b --- /dev/null +++ b/test/widgets/notifications_bell_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pantry/widgets/notifications_bell.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/test_app.dart'; +import '../helpers/test_models.dart'; + +void main() { + group('NotificationsBell', () { + testWidgets('renders the bell icon', (tester) async { + final controller = FakeNotificationsController(); + var tapped = 0; + + await tester.pumpWidget( + wrapForTest( + NotificationsBell(controller: controller, onTap: () => tapped++), + ), + ); + + expect(find.byIcon(Icons.notifications_outlined), findsOneWidget); + expect(tapped, 0); + }); + + testWidgets('shows no badge when there are no notifications', ( + tester, + ) async { + final controller = FakeNotificationsController(); + + await tester.pumpWidget( + wrapForTest(NotificationsBell(controller: controller, onTap: () {})), + ); + + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsNothing); + }); + + testWidgets('shows badge with count when there are notifications', ( + tester, + ) async { + final controller = FakeNotificationsController( + notifications: [ + makeNotification(notificationId: 1), + makeNotification(notificationId: 2), + makeNotification(notificationId: 3), + ], + ); + + await tester.pumpWidget( + wrapForTest(NotificationsBell(controller: controller, onTap: () {})), + ); + + expect(find.text('3'), findsOneWidget); + }); + + testWidgets('shows "99+" when count exceeds 99', (tester) async { + final controller = FakeNotificationsController( + notifications: List.generate( + 120, + (i) => makeNotification(notificationId: i), + ), + ); + + await tester.pumpWidget( + wrapForTest(NotificationsBell(controller: controller, onTap: () {})), + ); + + expect(find.text('99+'), findsOneWidget); + }); + + testWidgets('invokes onTap when pressed', (tester) async { + final controller = FakeNotificationsController(); + var tapped = 0; + + await tester.pumpWidget( + wrapForTest( + NotificationsBell(controller: controller, onTap: () => tapped++), + ), + ); + + await tester.tap(find.byIcon(Icons.notifications_outlined)); + await tester.pumpAndSettle(); + + expect(tapped, 1); + }); + }); +}