feat: Implement trip invitation functionality and notification handling

- Added TripInvitationRepository for managing trip invitations.
- Created TripInvitation model with serialization methods.
- Implemented notification payload parser for handling FCM notifications.
- Enhanced NotificationService to manage trip invitations and related actions.
- Updated UserRepository to include user search functionality.
- Modified AuthRepository to store multiple FCM tokens.
- Added tests for trip invitation logic and notification payload parsing.
- Updated pubspec.yaml and pubspec.lock for dependency management.
This commit is contained in:
Van Leemput Dayron
2026-03-13 13:54:47 +01:00
parent e665dea82a
commit 3215a990d1
27 changed files with 1961 additions and 321 deletions

View File

@@ -8,16 +8,22 @@ import 'package:travel_mate/blocs/auth/auth_state.dart';
import 'package:travel_mate/repositories/auth_repository.dart';
import 'package:travel_mate/models/user.dart';
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_core_platform_interface/test.dart';
import 'package:travel_mate/services/notification_service.dart';
import 'package:travel_mate/services/analytics_service.dart';
import 'auth_bloc_test.mocks.dart';
@GenerateMocks([AuthRepository, NotificationService])
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('AuthBloc', () {
late MockAuthRepository mockAuthRepository;
late MockNotificationService mockNotificationService;
late FakeAnalyticsService fakeAnalyticsService;
late AuthBloc authBloc;
final user = User(
@@ -28,12 +34,19 @@ void main() {
platform: 'email',
);
setUpAll(() async {
setupFirebaseCoreMocks();
await Firebase.initializeApp();
});
setUp(() {
mockAuthRepository = MockAuthRepository();
mockNotificationService = MockNotificationService();
fakeAnalyticsService = FakeAnalyticsService();
authBloc = AuthBloc(
authRepository: mockAuthRepository,
notificationService: mockNotificationService,
analyticsService: fakeAnalyticsService,
);
// Default stub for saveTokenToFirestore to avoid strict mock errors
@@ -128,3 +141,14 @@ class MockFirebaseUser extends Mock implements firebase_auth.User {
MockFirebaseUser({required this.uid, this.email});
}
class FakeAnalyticsService extends AnalyticsService {
@override
Future<void> logEvent({
required String name,
Map<String, Object>? parameters,
}) async {}
@override
Future<void> setUserId(String? id) async {}
}

View File

@@ -0,0 +1,49 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:travel_mate/models/trip_invitation.dart';
void main() {
group('TripInvitation', () {
test('toMap serializes required fields', () {
final invitation = TripInvitation(
id: 'id_1',
tripId: 'trip_1',
tripTitle: 'Weekend Rome',
inviterId: 'user_a',
inviterName: 'Alice',
inviteeId: 'user_b',
inviteeEmail: 'bob@example.com',
createdAt: DateTime(2026, 3, 3),
);
final map = invitation.toMap();
expect(map['tripId'], 'trip_1');
expect(map['status'], 'pending');
expect(map['respondedAt'], isNull);
expect(map['createdAt'], isA<Timestamp>());
});
test('copyWith updates selected values', () {
final invitation = TripInvitation(
id: 'id_1',
tripId: 'trip_1',
tripTitle: 'Trip',
inviterId: 'user_a',
inviterName: 'Alice',
inviteeId: 'user_b',
inviteeEmail: 'bob@example.com',
createdAt: DateTime(2026, 3, 3),
);
final updated = invitation.copyWith(
status: 'accepted',
respondedAt: DateTime(2026, 3, 4),
);
expect(updated.status, 'accepted');
expect(updated.respondedAt, DateTime(2026, 3, 4));
expect(updated.tripId, invitation.tripId);
});
});
}

View File

@@ -0,0 +1,124 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:travel_mate/models/trip_invitation.dart';
import 'package:travel_mate/pages/trip_invitations_page.dart';
import 'package:travel_mate/repositories/trip_invitation_repository.dart';
class _FakeTripInvitationDataSource implements TripInvitationDataSource {
final StreamController<List<TripInvitation>> _controller =
StreamController<List<TripInvitation>>();
bool acceptedCalled = false;
bool rejectedCalled = false;
void emitInvitations(List<TripInvitation> invitations) {
_controller.add(invitations);
}
@override
Future<void> respondToInvitation({
required String invitationId,
required bool isAccepted,
}) async {
if (isAccepted) {
acceptedCalled = true;
return;
}
rejectedCalled = true;
}
@override
Stream<List<TripInvitation>> watchInvitationsForUser(String userId) {
return _controller.stream;
}
@override
Future<void> createInvitation({
required String tripId,
required String tripTitle,
required String inviterId,
required String inviterName,
required String inviteeId,
required String inviteeEmail,
}) async {}
@override
Future<TripInvitation?> getInvitationById(String invitationId) async {
return null;
}
@override
Future<TripInvitation?> getPendingInvitation({
required String tripId,
required String inviteeId,
}) async {
return null;
}
@override
Stream<List<TripInvitation>> watchPendingInvitationsForUser(String userId) {
return _controller.stream;
}
Future<void> dispose() async {
await _controller.close();
}
}
void main() {
group('TripInvitationsPage', () {
testWidgets('shows invitations in correct tabs', (tester) async {
final fakeRepository = _FakeTripInvitationDataSource();
await tester.pumpWidget(
MaterialApp(
home: TripInvitationsPage(
repository: fakeRepository,
userIdOverride: 'user_1',
),
),
);
final invitations = [
TripInvitation(
id: 'pending_1',
tripId: 'trip_1',
tripTitle: 'Road Trip',
inviterId: 'u2',
inviterName: 'Alice',
inviteeId: 'user_1',
inviteeEmail: 'user@example.com',
status: 'pending',
createdAt: DateTime(2026, 3, 6),
),
TripInvitation(
id: 'accepted_1',
tripId: 'trip_2',
tripTitle: 'Tokyo',
inviterId: 'u3',
inviterName: 'Bob',
inviteeId: 'user_1',
inviteeEmail: 'user@example.com',
status: 'accepted',
createdAt: DateTime(2026, 3, 6),
),
];
fakeRepository.emitInvitations(invitations);
await tester.pump();
expect(find.text('Road Trip'), findsOneWidget);
expect(find.text('Accepter'), findsOneWidget);
await tester.tap(find.text('Acceptées'));
await tester.pumpAndSettle();
expect(find.text('Tokyo'), findsOneWidget);
expect(find.text('Accepter'), findsNothing);
await fakeRepository.dispose();
});
});
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:travel_mate/services/notification_payload_parser.dart';
void main() {
group('NotificationPayloadParser', () {
test('uses explicit type when present', () {
final action = NotificationPayloadParser.parse({
'type': 'activity',
'tripId': 'trip_1',
'activityId': 'activity_42',
});
expect(action.type, 'activity');
expect(action.tripId, 'trip_1');
expect(action.activityId, 'activity_42');
});
test('infers invitation type when invitationId exists', () {
final action = NotificationPayloadParser.parse({
'invitationId': 'inv_1',
'tripId': 'trip_1',
});
expect(action.type, 'trip_invitation');
expect(action.invitationId, 'inv_1');
});
test('infers message type when groupId exists', () {
final action = NotificationPayloadParser.parse({'groupId': 'group_1'});
expect(action.type, 'message');
expect(action.groupId, 'group_1');
});
test('falls back to trip type when only tripId exists', () {
final action = NotificationPayloadParser.parse({'tripId': 'trip_1'});
expect(action.type, 'trip');
expect(action.tripId, 'trip_1');
});
});
}