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:
@@ -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 {}
|
||||
}
|
||||
|
||||
49
test/models/trip_invitation_test.dart
Normal file
49
test/models/trip_invitation_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
124
test/pages/trip_invitations_page_test.dart
Normal file
124
test/pages/trip_invitations_page_test.dart
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
42
test/services/notification_payload_parser_test.dart
Normal file
42
test/services/notification_payload_parser_test.dart
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user