mirror of
https://github.com/chenasraf/pantry-flutter.git
synced 2026-05-17 17:28:03 +00:00
feat: notifications support
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
116
test/models/notification_test.dart
Normal file
116
test/models/notification_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
107
test/views/notifications_view_test.dart
Normal file
107
test/views/notifications_view_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
87
test/widgets/notifications_bell_test.dart
Normal file
87
test/widgets/notifications_bell_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user