feat: notifications support

This commit is contained in:
2026-04-11 23:24:01 +03:00
parent 3b897982d6
commit 4d0c28f263
28 changed files with 1933 additions and 10 deletions

View File

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

View File

@@ -1,5 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<application
android:label="Pantry"
android:name="${applicationName}"

View File

@@ -68,5 +68,10 @@
</array>
<key>UIStatusBarHidden</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
</dict>
</plist>

View File

@@ -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<NavigatorState>();
@@ -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<PantryApp> {
Future<void> _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<void> _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<PantryApp> {
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<PantryApp> {
return MaterialPageRoute(
builder: (_) => HomeView(onLogout: _onLogout),
);
case '/notifications-intro':
return MaterialPageRoute(
builder: (_) => NotificationsIntroView(onDone: _onIntroDone),
);
case '/login':
default:
return MaterialPageRoute(

View File

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

View File

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

View File

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

View File

@@ -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<String, String>? queryParameters]) {
final base = Uri.parse(_credentials.serverUrl);
return base.replace(
path: '$_basePath$path',
path: '$basePath$path',
queryParameters: queryParameters,
);
}

View File

@@ -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<void> _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
? <int>{}
: 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<void> markCurrentNotificationsAsSeen(List<int> 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<void> 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<void> 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<void> 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();
}

View File

@@ -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<DeepLink?> 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;
}
}

View File

@@ -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<void> 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<bool> 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<void> 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<void> cancelAll() async {
await init();
await _plugin.cancelAll();
}
}

View File

@@ -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<List<NcNotification>> getNotifications() async {
try {
return await _client.get<List, List<NcNotification>>(
'/notifications',
fromJson: (data) => data
.map((e) => NcNotification.fromJson(e as Map<String, dynamic>))
.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<void> 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<void> dismissAll(List<int> ids) async {
for (final id in ids) {
try {
await dismiss(id);
} catch (e) {
debugPrint('[NotificationService] Failed to dismiss $id: $e');
}
}
}
}

View File

@@ -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<void> 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<void> setLastHouseId(int id) async {
@@ -22,8 +44,38 @@ class PrefsService {
await _storage.write(key: _lastHouseKey, value: id.toString());
}
Future<void> setNotificationsEnabled(bool value) async {
_notificationsEnabled = value;
await _storage.write(
key: _notificationsEnabledKey,
value: value.toString(),
);
}
Future<void> setPollIntervalMinutes(int minutes) async {
_pollIntervalMinutes = minutes;
await _storage.write(
key: _pollIntervalMinutesKey,
value: minutes.toString(),
);
}
Future<void> setNotificationsIntroSeen(bool value) async {
_notificationsIntroSeen = value;
await _storage.write(
key: _notificationsIntroSeenKey,
value: value.toString(),
);
}
Future<void> 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);
}
}

View File

@@ -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<HomeController>();
// Switch house if specified and different from current.
if (link.houseId != null &&
link.houseId != homeController.currentHouse?.id) {
final house = homeController.houses.cast<House?>().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,
),
],

View File

@@ -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<NcNotification> _notifications = [];
List<NcNotification> get notifications => _notifications;
int get unreadCount => _notifications.length;
bool _isLoading = false;
bool get isLoading => _isLoading;
String? _error;
String? get error => _error;
Future<void> 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<void> refresh() async {
try {
_notifications = await NotificationService.instance.getNotifications();
notifyListeners();
await _markAllSeen();
} catch (e) {
debugPrint('[NotificationsController] Failed to refresh: $e');
}
}
Future<void> _markAllSeen() async {
final ids = _notifications.map((n) => n.notificationId).toList();
try {
await markCurrentNotificationsAsSeen(ids);
} catch (e) {
debugPrint('[NotificationsController] Failed to mark seen: $e');
}
}
Future<void> 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<void> 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');
}
}
}

View File

@@ -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<NotificationsView> createState() => _NotificationsViewState();
}
class _NotificationsViewState extends State<NotificationsView> {
@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<NotificationsController>();
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);
}
}

View File

@@ -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<NotificationsIntroView> createState() => _NotificationsIntroViewState();
}
class _NotificationsIntroViewState extends State<NotificationsIntroView> {
bool _working = false;
Future<void> _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<void> _skip() async {
await PrefsService.instance.setNotificationsEnabled(false);
await _complete(enabled: false);
}
Future<void> _complete({required bool enabled}) async {
await PrefsService.instance.setNotificationsIntroSeen(true);
if (mounted) widget.onDone();
}
Future<void> _showPermissionDeniedDialog() async {
final intro = m.notificationsIntro;
await showDialog<void>(
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)),
],
);
}
}

View File

@@ -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<SettingsView> createState() => _SettingsViewState();
}
class _SettingsViewState extends State<SettingsView> {
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<void> _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<void> _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<int>(
value: _pollIntervalMinutes,
onChanged: _notificationsEnabled ? _setPollInterval : null,
items: [
for (final minutes in _pollOptions)
DropdownMenuItem(
value: minutes,
child: Text(_pollIntervalLabel(minutes)),
),
],
),
),
],
),
);
}
}

View File

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

View File

@@ -10,6 +10,7 @@ class UserMenuButton extends StatelessWidget {
final House? currentHouse;
final ValueChanged<House> 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<String>(
value: 'settings',
child: Row(
children: [
const Icon(Icons.settings_outlined, size: 18),
const SizedBox(width: 8),
Text(m.settings.title),
],
),
),
PopupMenuItem<String>(
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();
}

View File

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

View File

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

View File

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

View File

@@ -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<NcNotification>? notifications,
bool isLoading = false,
String? error,
}) : _notifications = notifications ?? [],
_isLoading = isLoading,
_error = error;
final List<NcNotification> _notifications;
@override
List<NcNotification> 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<void> load() async {
loadCalls++;
}
@override
Future<void> refresh() async {
refreshCalls++;
}
@override
Future<void> dismiss(NcNotification notification) async {
dismissCalls++;
lastDismissed = notification;
_notifications.removeWhere(
(n) => n.notificationId == notification.notificationId,
);
notifyListeners();
}
@override
Future<void> dismissAll() async {
dismissAllCalls++;
_notifications.clear();
notifyListeners();
}
}
/// Builds a fake [UploadTask] for tests.
UploadTask makeUploadTask({
String fileName = 'photo.jpg',

View File

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

View File

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

View File

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

View File

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