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

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