From 618447ad8f9f0ed798247bc45d57f4b3f89e14da Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Mar 2026 09:26:53 -0400 Subject: [PATCH 01/79] Adds tests for change_email_verification.dart --- .../change_email_verification_test.dart | 144 ++++++++++++ .../controller_test.dart | 187 +++++++++++++++ .../change_email_verification/mocks.dart | 30 +++ .../widgets/resend_verification_test.dart | 222 ++++++++++++++++++ .../widgets/verification_error_test.dart | 80 +++++++ 5 files changed, 663 insertions(+) create mode 100644 test/features/change_email_verification/change_email_verification_test.dart create mode 100644 test/features/change_email_verification/controller_test.dart create mode 100644 test/features/change_email_verification/mocks.dart create mode 100644 test/features/change_email_verification/widgets/resend_verification_test.dart create mode 100644 test/features/change_email_verification/widgets/verification_error_test.dart diff --git a/test/features/change_email_verification/change_email_verification_test.dart b/test/features/change_email_verification/change_email_verification_test.dart new file mode 100644 index 0000000..c4c44ba --- /dev/null +++ b/test/features/change_email_verification/change_email_verification_test.dart @@ -0,0 +1,144 @@ +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/features/change_email_verification/change_email_verification.dart'; +import 'package:clean_stream_laundry_app/pages/home_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late FakeAppLinks fakeAppLinks; + late MockLocationService mockLocationService; + late MockMachineService mockMachineService; + late MockProfileService mockProfileService; + + setUpAll(() { + registerFallbackValue(''); + }); + + setUp(() { + mockAuthService = MockAuthService(); + fakeAppLinks = FakeAppLinks(); + mockLocationService = MockLocationService(); + mockMachineService = MockMachineService(); + mockProfileService = MockProfileService(); + + GetIt.instance.registerSingleton(mockAuthService); + GetIt.instance.registerSingleton(mockLocationService); + GetIt.instance.registerSingleton(mockMachineService); + GetIt.instance.registerSingleton(mockProfileService); + + when(() => mockLocationService.getLocations()) + .thenAnswer((_) async => >[]); + when(() => mockMachineService.getWasherCountByLocation(any())) + .thenAnswer((_) async => 0); + when(() => mockMachineService.getIdleWasherCountByLocation(any())) + .thenAnswer((_) async => 0); + when(() => mockMachineService.getDryerCountByLocation(any())) + .thenAnswer((_) async => 0); + when(() => mockMachineService.getIdleDryerCountByLocation(any())) + .thenAnswer((_) async => 0); + when(() => mockAuthService.refreshSession()).thenAnswer((_) async => {}); + when(() => mockAuthService.getCurrentUser()).thenAnswer((_) => null); + }); + + tearDown(() { + fakeAppLinks.dispose(); + GetIt.instance.reset(); + }); + + Widget createTestWidget() { + final router = GoRouter( + initialLocation: '/change-email-verification', + routes: [ + GoRoute( + path: '/change-email-verification', + builder: (context, state) => + ChangeEmailVerificationPage(appLinks: fakeAppLinks), + ), + GoRoute( + path: '/homePage', + builder: (context, state) => HomePage(), + ), + ], + ); + + return MaterialApp.router(routerConfig: router); + } + + group('Static UI Elements', () { + testWidgets('displays all required UI elements', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.email), findsOneWidget); + expect( + find.text('Please verify your new email address'), + findsOneWidget, + ); + expect( + find.text( + 'Check your new email\'s inbox and click the verification link.', + ), + findsOneWidget, + ); + expect(find.text('Resend Verification'), findsOneWidget); + }); + + testWidgets('email icon has correct styling', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final icon = tester.widget(find.byIcon(Icons.email)); + expect(icon.size, equals(80)); + expect(icon.color, equals(Colors.blueAccent)); + }); + + testWidgets('text uses center alignment', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final titleText = tester.widget( + find.text('Please verify your new email address'), + ); + final descText = tester.widget( + find.text( + 'Check your new email\'s inbox and click the verification link.', + ), + ); + + expect(titleText.textAlign, equals(TextAlign.center)); + expect(descText.textAlign, equals(TextAlign.center)); + }); + }); + + group('Lifecycle', () { + testWidgets('uses theme surface color as scaffold background', + (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final scaffold = tester.widget(find.byType(Scaffold)); + expect(scaffold.backgroundColor, isNotNull); + }); + + testWidgets('properly disposes controller on navigation away', + (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final context = + tester.element(find.byType(ChangeEmailVerificationPage)); + GoRouter.of(context).go('/homePage'); + await tester.pumpAndSettle(); + + expect(find.byType(ChangeEmailVerificationPage), findsNothing); + }); + }); +} \ No newline at end of file diff --git a/test/features/change_email_verification/controller_test.dart b/test/features/change_email_verification/controller_test.dart new file mode 100644 index 0000000..3430a42 --- /dev/null +++ b/test/features/change_email_verification/controller_test.dart @@ -0,0 +1,187 @@ +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/features/change_email_verification/controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late FakeAppLinks fakeAppLinks; + + setUpAll(() { + registerFallbackValue(''); + }); + + setUp(() { + mockAuthService = MockAuthService(); + fakeAppLinks = FakeAppLinks(); + GetIt.instance.registerSingleton(mockAuthService); + }); + + tearDown(() { + fakeAppLinks.dispose(); + GetIt.instance.reset(); + }); + + /// Minimal widget tree + Widget buildWithContext( + Widget Function(BuildContext context) builder) { + return MaterialApp(home: Builder(builder: builder)); + } + + /// GoRouter widget tree - initial route creates and inits controller, + /// use for deep-link and nav tests. + Widget buildWithRouter({ + required FakeAppLinks appLinks, + required void Function(ChangeEmailVerificationController) onControllerReady, + }) { + final router = GoRouter( + initialLocation: '/change-email-verification', + routes: [ + GoRoute( + path: '/change-email-verification', + builder: (context, state) { + final controller = ChangeEmailVerificationController( + appLinks: appLinks, + context: context, + ); + onControllerReady(controller); + controller.init(); + return const SizedBox(); + }, + ), + GoRoute( + path: '/editProfile', + builder: (context, state) => const SizedBox(), + ), + ], + ); + return MaterialApp.router(routerConfig: router); + } + + group('resendVerification', () { + testWidgets('resend verification is called', (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.success); + + late ChangeEmailVerificationController controller; + + await tester.pumpWidget(buildWithContext((context) { + controller = ChangeEmailVerificationController( + appLinks: fakeAppLinks, + context: context, + ); + return const SizedBox(); + })); + + await controller.resendVerification(); + + verify(() => mockAuthService.resendVerification()).called(1); + }); + + testWidgets('sets resent to true on success', (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.success); + + late ChangeEmailVerificationController controller; + + await tester.pumpWidget(buildWithContext((context) { + controller = ChangeEmailVerificationController( + appLinks: fakeAppLinks, + context: context, + ); + return const SizedBox(); + })); + + await controller.resendVerification(); + + expect(controller.resent, isTrue); + expect(controller.lastResponse, equals(AuthenticationResponses.success)); + }); + + testWidgets('sets lastResponse on failure and does not set resent', + (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.error); + + late ChangeEmailVerificationController controller; + + await tester.pumpWidget(buildWithContext((context) { + controller = ChangeEmailVerificationController( + appLinks: fakeAppLinks, + context: context, + ); + return const SizedBox(); + })); + + await controller.resendVerification(); + + expect(controller.resent, isFalse); + expect(controller.lastResponse, equals(AuthenticationResponses.error)); + }); + + testWidgets('does not call resend again when already resent', + (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.success); + + late ChangeEmailVerificationController controller; + + await tester.pumpWidget(buildWithContext((context) { + controller = ChangeEmailVerificationController( + appLinks: fakeAppLinks, + context: context, + ); + return const SizedBox(); + })); + + await controller.resendVerification(); + await controller.resendVerification(); + + verify(() => mockAuthService.resendVerification()).called(1); + }); + }); + + group('Deep link handling', () { + testWidgets('verifies session refresh and getCurrentUser() is called on deep link nav', (tester) async { + when(() => mockAuthService.refreshSession()).thenAnswer((_) async => {}); + when(() => mockAuthService.getCurrentUser()).thenAnswer((_) => null); + + await tester.pumpWidget( + buildWithRouter( + appLinks: fakeAppLinks, + onControllerReady: (_) {}, + ), + ); + await tester.pumpAndSettle(); + + fakeAppLinks.emit(Uri.parse('clean-stream://change-email')); + await tester.pumpAndSettle(); + + verify(() => mockAuthService.refreshSession()).called(1); + verify(() => mockAuthService.getCurrentUser()).called(1); + }); + }); + + group('Lifecycle', () { + testWidgets('dispose cancels stream subscription without error', + (tester) async { + late ChangeEmailVerificationController controller; + + await tester.pumpWidget(buildWithContext((context) { + controller = ChangeEmailVerificationController( + appLinks: fakeAppLinks, + context: context, + ); + controller.init(); + return const SizedBox(); + })); + + expect(() => controller.dispose(), returnsNormally); + }); + }); +} \ No newline at end of file diff --git a/test/features/change_email_verification/mocks.dart b/test/features/change_email_verification/mocks.dart new file mode 100644 index 0000000..0fe9f95 --- /dev/null +++ b/test/features/change_email_verification/mocks.dart @@ -0,0 +1,30 @@ +import 'dart:async'; +import 'package:app_links/app_links.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthService extends Mock implements AuthService {} + +class MockLocationService extends Mock implements LocationService {} + +class MockMachineService extends Mock implements MachineService {} + +class MockProfileService extends Mock implements ProfileService {} + +class FakeAppLinks extends Fake implements AppLinks { + final StreamController _controller = StreamController.broadcast(); + + @override + Stream get uriLinkStream => _controller.stream; + + void emit(Uri uri) { + _controller.add(uri); + } + + void dispose() { + _controller.close(); + } +} \ No newline at end of file diff --git a/test/features/change_email_verification/widgets/resend_verification_test.dart b/test/features/change_email_verification/widgets/resend_verification_test.dart new file mode 100644 index 0000000..d1b3b3f --- /dev/null +++ b/test/features/change_email_verification/widgets/resend_verification_test.dart @@ -0,0 +1,222 @@ +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/features/change_email_verification/controller.dart'; +import 'package:clean_stream_laundry_app/features/change_email_verification/widgets/resend_verification.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import '../mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late FakeAppLinks fakeAppLinks; + + setUpAll(() { + registerFallbackValue(''); + }); + + setUp(() { + mockAuthService = MockAuthService(); + fakeAppLinks = FakeAppLinks(); + GetIt.instance.registerSingleton(mockAuthService); + }); + + tearDown(() { + fakeAppLinks.dispose(); + GetIt.instance.reset(); + }); + + /// builds minimal widget tree + Widget buildWidget({ + required ChangeEmailVerificationController controller, + VoidCallback? onStateChange, + }) { + return MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + return ResendVerificationWidget( + controller: controller, + onStateChange: onStateChange ?? () => setState(() {}), + ); + }, + ), + ), + ); + } + + /// Creates a ChangeEmailVerificationController bound to tester's context + /// Must be called after tester.pumpWidget so a context is accessible to test + Future buildController( + WidgetTester tester) async { + late ChangeEmailVerificationController controller; + + await tester.pumpWidget( + MaterialApp( + home: Builder(builder: (context) { + controller = ChangeEmailVerificationController( + appLinks: fakeAppLinks, + context: context, + ); + return const SizedBox(); + }), + ), + ); + + return controller; + } + + + group('Initial state', () { + testWidgets('shows Resend Verification link text', (tester) async { + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); + + expect(find.text('Resend Verification'), findsOneWidget); + }); + + testWidgets('link text has correct styling', (tester) async { + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); + + final textWidget = + tester.widget(find.text('Resend Verification')); + expect(textWidget.style?.color, equals(Colors.blue)); + expect( + textWidget.style?.decoration, + equals(TextDecoration.underline), + ); + }); + + testWidgets('link is wrapped in an InkWell', (tester) async { + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); + + final inkWell = find.ancestor( + of: find.text('Resend Verification'), + matching: find.byType(InkWell), + ); + expect(inkWell, findsOneWidget); + }); + }); + + group('Success state', () { + testWidgets('shows check_circle icon after successful resend', + (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resend Verification')); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.check_circle), findsOneWidget); + expect(find.text('Resend Verification'), findsNothing); + }); + + testWidgets('check_circle icon has correct styling', (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resend Verification')); + await tester.pumpAndSettle(); + + final icon = + tester.widget(find.byIcon(Icons.check_circle)); + expect(icon.size, equals(40)); + expect(icon.color, equals(Colors.green)); + }); + + testWidgets('tapping check_circle does not trigger another resend', + (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resend Verification')); + await tester.pumpAndSettle(); + + verify(() => mockAuthService.resendVerification()).called(1); + + await tester.tap(find.byIcon(Icons.check_circle)); + await tester.pumpAndSettle(); + + verifyNever(() => mockAuthService.resendVerification()); + }); + }); + + group('Failure state', () { + testWidgets('shows VerificationError widget on failure', (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.failure); + + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resend Verification')); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.close), findsOneWidget); + expect( + find.text('Please resend verification again at another time.'), + findsOneWidget, + ); + }); + + testWidgets('tapping error icon does not trigger another resend', + (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.failure); + + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resend Verification')); + await tester.pumpAndSettle(); + + verify(() => mockAuthService.resendVerification()).called(1); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + verify(() => mockAuthService.resendVerification()).called(1); + }); + }); + + group('InkWell interaction', () { + testWidgets('tapping InkWell calls resendVerification', (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); + + final inkWell = find.ancestor( + of: find.text('Resend Verification'), + matching: find.byType(InkWell), + ); + + await tester.tap(inkWell); + await tester.pumpAndSettle(); + + verify(() => mockAuthService.resendVerification()).called(1); + }); + }); +} \ No newline at end of file diff --git a/test/features/change_email_verification/widgets/verification_error_test.dart b/test/features/change_email_verification/widgets/verification_error_test.dart new file mode 100644 index 0000000..835a054 --- /dev/null +++ b/test/features/change_email_verification/widgets/verification_error_test.dart @@ -0,0 +1,80 @@ +import 'package:clean_stream_laundry_app/features/change_email_verification/widgets/verification_error.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget() { + return const MaterialApp( + home: Scaffold( + body: VerificationError(), + ), + ); + } + + group('VerificationError', () { + testWidgets('displays error message text', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect( + find.text('Please resend verification again at another time.'), + findsOneWidget, + ); + }); + + testWidgets('error message uses center alignment', (tester) async { + await tester.pumpWidget(buildWidget()); + + final text = tester.widget( + find.text('Please resend verification again at another time.'), + ); + expect(text.textAlign, equals(TextAlign.center)); + }); + + testWidgets('shows close icon', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect(find.byIcon(Icons.close), findsOneWidget); + }); + + testWidgets('close icon has correct styling', (tester) async { + await tester.pumpWidget(buildWidget()); + + final icon = tester.widget(find.byIcon(Icons.close)); + expect(icon.size, equals(40)); + expect(icon.color, equals(Colors.white)); + }); + + testWidgets('container has red circular decoration', (tester) async { + await tester.pumpWidget(buildWidget()); + + final container = tester.widget( + find + .ancestor( + of: find.byIcon(Icons.close), + matching: find.byType(Container), + ) + .first, + ); + + final decoration = container.decoration as BoxDecoration; + expect(decoration.color, equals(Colors.red)); + expect(decoration.shape, equals(BoxShape.circle)); + }); + + testWidgets('container has correct dimensions', (tester) async { + await tester.pumpWidget(buildWidget()); + + final container = tester.widget( + find + .ancestor( + of: find.byIcon(Icons.close), + matching: find.byType(Container), + ) + .first, + ); + + expect(container.constraints?.maxWidth, equals(80)); + expect(container.constraints?.maxHeight, equals(80)); + }); + }); +} \ No newline at end of file From 867a7e0e78ef302c6b62b3c404828cadc7c51a64 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Mar 2026 09:46:30 -0400 Subject: [PATCH 02/79] Separates edit_profile.dart --- lib/features/edit_profile/controller.dart | 109 ++++++++++++++++++ lib/features/edit_profile/edit_profile.dart | 77 +++++++++++++ .../edit_profile/widgets/info_card.dart | 23 ++++ .../edit_profile/widgets/section_header.dart | 12 ++ 4 files changed, 221 insertions(+) create mode 100644 lib/features/edit_profile/controller.dart create mode 100644 lib/features/edit_profile/edit_profile.dart create mode 100644 lib/features/edit_profile/widgets/info_card.dart create mode 100644 lib/features/edit_profile/widgets/section_header.dart diff --git a/lib/features/edit_profile/controller.dart b/lib/features/edit_profile/controller.dart new file mode 100644 index 0000000..2a8e9fb --- /dev/null +++ b/lib/features/edit_profile/controller.dart @@ -0,0 +1,109 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; + +class EditProfileController extends ChangeNotifier { + final authService = GetIt.instance(); + final profileService = GetIt.instance(); + final edgeFunctionService = GetIt.instance(); + + final nameController = TextEditingController(); + final emailController = TextEditingController(); + + late StreamSubscription authSub; + + String currentName = ''; + String currentEmail = ''; + + bool isLoading = true; + bool isSaving = false; + + Future init() async { + await loadUserData(); + + authSub = authService.onAuthChange.listen((_) { + loadUserData(); + }); + } + + Future disposeController() async { + nameController.dispose(); + emailController.dispose(); + await authSub.cancel(); + } + + Future loadUserData() async { + try { + final userId = await authService.getCurrentUserId; + + if (userId == null) { + throw Exception("User not found"); + } + + final username = await profileService.getUserNameById(userId); + final email = await authService.getCurrentUserEmail(); + + currentName = username ?? ''; + currentEmail = email ?? ''; + + nameController.text = currentName; + emailController.text = currentEmail; + } catch (e) { + rethrow; + } finally { + isLoading = false; + notifyListeners(); + } + } + + Future saveChanges() async { + if (isSaving) return false; + + final newName = nameController.text.trim(); + final newEmail = emailController.text.trim(); + + final nameChanged = newName != currentName; + final emailChanged = newEmail != currentEmail; + + if (!nameChanged && !emailChanged) { + throw Exception("No changes made"); + } + + isSaving = true; + notifyListeners(); + + try { + await authService.updateUserAttributes( + email: emailChanged ? newEmail : null, + data: nameChanged ? {'full_name': newName} : null, + ); + + currentName = newName; + currentEmail = newEmail; + + return emailChanged; + } finally { + isSaving = false; + notifyListeners(); + } + } + + Future deleteAccount() async { + final userId = await authService.getCurrentUserId; + + final response = await edgeFunctionService.runEdgeFunction( + name: "delete-account", + body: {"user_id": userId}, + ); + + if (response?.status == 200) { + await authService.logout(); + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/lib/features/edit_profile/edit_profile.dart b/lib/features/edit_profile/edit_profile.dart new file mode 100644 index 0000000..c85ecaa --- /dev/null +++ b/lib/features/edit_profile/edit_profile.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'controller.dart'; +import 'widgets/section_header.dart'; +import 'widgets/info_card.dart'; + +class EditProfilePage extends StatefulWidget { + const EditProfilePage({super.key}); + + @override + State createState() => _EditProfilePageState(); +} + +class _EditProfilePageState extends State { + final controller = EditProfileController(); + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + controller.init(); + } + + @override + void dispose() { + controller.disposeController(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + if (controller.isLoading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + appBar: AppBar(title: const Text("Edit Profile")), + body: Padding( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + children: [ + SectionHeader(title: "Full Name"), + InfoCard(label: "Current", value: controller.currentName), + TextFormField(controller: controller.nameController), + + SectionHeader(title: "Email"), + InfoCard(label: "Current", value: controller.currentEmail), + TextFormField(controller: controller.emailController), + + ElevatedButton( + onPressed: controller.isSaving + ? null + : () async { + if (!_formKey.currentState!.validate()) return; + final emailChanged = await controller.saveChanges(); + if (emailChanged) { + context.go('/change-email-verification'); + } + }, + child: const Text("Save"), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/edit_profile/widgets/info_card.dart b/lib/features/edit_profile/widgets/info_card.dart new file mode 100644 index 0000000..c4cafdb --- /dev/null +++ b/lib/features/edit_profile/widgets/info_card.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class InfoCard extends StatelessWidget { + final String label; + final String value; + + const InfoCard({ + super.key, + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text(label), + const SizedBox(width: 8), + Text(value), + ], + ); + } +} \ No newline at end of file diff --git a/lib/features/edit_profile/widgets/section_header.dart b/lib/features/edit_profile/widgets/section_header.dart new file mode 100644 index 0000000..f4c48db --- /dev/null +++ b/lib/features/edit_profile/widgets/section_header.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +class SectionHeader extends StatelessWidget { + final String title; + + const SectionHeader({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + return Text(title); + } +} \ No newline at end of file From 3787749fa5a038465ae0474be3e17b788e5aefd1 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Mar 2026 10:22:02 -0400 Subject: [PATCH 03/79] Correct edit_profile feature --- lib/features/edit_profile/controller.dart | 18 +- lib/features/edit_profile/edit_profile.dart | 356 +++++++++++++++--- .../edit_profile/widgets/name_form.dart | 89 +++++ .../edit_profile/widgets/save_button.dart | 51 +++ 4 files changed, 459 insertions(+), 55 deletions(-) create mode 100644 lib/features/edit_profile/widgets/name_form.dart create mode 100644 lib/features/edit_profile/widgets/save_button.dart diff --git a/lib/features/edit_profile/controller.dart b/lib/features/edit_profile/controller.dart index 2a8e9fb..056852b 100644 --- a/lib/features/edit_profile/controller.dart +++ b/lib/features/edit_profile/controller.dart @@ -13,7 +13,7 @@ class EditProfileController extends ChangeNotifier { final nameController = TextEditingController(); final emailController = TextEditingController(); - late StreamSubscription authSub; + StreamSubscription? authSub; String currentName = ''; String currentEmail = ''; @@ -21,6 +21,10 @@ class EditProfileController extends ChangeNotifier { bool isLoading = true; bool isSaving = false; + bool get hasChanges => + nameController.text.trim() != currentName || + emailController.text.trim() != currentEmail; + Future init() async { await loadUserData(); @@ -32,7 +36,7 @@ class EditProfileController extends ChangeNotifier { Future disposeController() async { nameController.dispose(); emailController.dispose(); - await authSub.cancel(); + await authSub?.cancel(); } Future loadUserData() async { @@ -62,16 +66,16 @@ class EditProfileController extends ChangeNotifier { Future saveChanges() async { if (isSaving) return false; + if (!hasChanges) { + throw Exception("No changes made"); + } + final newName = nameController.text.trim(); final newEmail = emailController.text.trim(); final nameChanged = newName != currentName; final emailChanged = newEmail != currentEmail; - if (!nameChanged && !emailChanged) { - throw Exception("No changes made"); - } - isSaving = true; notifyListeners(); @@ -84,7 +88,7 @@ class EditProfileController extends ChangeNotifier { currentName = newName; currentEmail = newEmail; - return emailChanged; + return emailChanged; } finally { isSaving = false; notifyListeners(); diff --git a/lib/features/edit_profile/edit_profile.dart b/lib/features/edit_profile/edit_profile.dart index c85ecaa..26dd964 100644 --- a/lib/features/edit_profile/edit_profile.dart +++ b/lib/features/edit_profile/edit_profile.dart @@ -1,8 +1,14 @@ +import 'package:clean_stream_laundry_app/features/edit_profile/controller.dart'; +import 'package:clean_stream_laundry_app/features/edit_profile/widgets/danger_zone.dart'; +import 'package:clean_stream_laundry_app/features/edit_profile/widgets/email_form.dart'; +import 'package:clean_stream_laundry_app/features/edit_profile/widgets/info_card.dart'; +import 'package:clean_stream_laundry_app/features/edit_profile/widgets/name_form.dart'; +import 'package:clean_stream_laundry_app/features/edit_profile/widgets/save_button.dart'; +import 'package:clean_stream_laundry_app/features/edit_profile/widgets/section_header.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'controller.dart'; -import 'widgets/section_header.dart'; -import 'widgets/info_card.dart'; class EditProfilePage extends StatefulWidget { const EditProfilePage({super.key}); @@ -12,66 +18,320 @@ class EditProfilePage extends StatefulWidget { } class _EditProfilePageState extends State { - final controller = EditProfileController(); final _formKey = GlobalKey(); + late final EditProfileController _controller; @override void initState() { super.initState(); - controller.init(); + _controller = EditProfileController(); + _controller.init().catchError((e) { + if (mounted) _showErrorDialog('Failed to load profile data: $e'); + }); + _controller.addListener(() { + if (mounted) setState(() {}); + }); } @override void dispose() { - controller.disposeController(); + _controller.disposeController(); + _controller.dispose(); super.dispose(); } + void _onSavePressed() async { + if (!_controller.hasChanges) { + statusDialog( + context, + title: 'No Changes', + message: "You haven't changed anything.", + isSuccess: false, + ); + return; + } + + final confirmed = await _confirmSaveChanges(); + if (!confirmed) return; + + if (!_formKey.currentState!.validate()) return; + + try { + final emailChanged = await _controller.saveChanges(); + + if (!mounted) return; + + if (emailChanged) { + context.go('/change-email-verification'); + return; + } + + statusDialog( + context, + title: 'Profile Updated', + message: 'Your information has been updated successfully.', + isSuccess: true, + ); + } catch (e) { + if (!mounted) return; + statusDialog( + context, + title: 'Update Failed', + message: e.toString(), + isSuccess: false, + ); + } + } + + void _onDeletePressed() async { + final confirmed = await _confirmDeleteAccount(); + if (!confirmed) return; + + try { + final deleted = await _controller.deleteAccount(); + + if (!mounted) return; + + if (deleted) { + statusDialog( + context, + title: 'Account Deleted', + message: 'Your account has been deleted successfully.', + isSuccess: true, + ); + context.go('/login'); + } else { + statusDialog( + context, + title: 'Error', + message: 'An error occurred, please try again later.', + isSuccess: false, + ); + } + } catch (e) { + if (!mounted) return; + statusDialog( + context, + title: 'Error', + message: e.toString(), + isSuccess: false, + ); + } + } + + void _showErrorDialog(String message) { + showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + title: Text( + 'Error', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontWeight: FontWeight.bold, + ), + ), + content: Text( + message, + style: TextStyle(color: Theme.of(context).colorScheme.fontSecondary), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'OK', + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + ), + ], + ), + ); + } + + Future _confirmSaveChanges() async { + return await showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + title: Text( + 'Confirm Changes', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontWeight: FontWeight.bold, + ), + ), + content: Text( + 'Are you sure you want to save these changes to your profile?', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text( + 'Cancel', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + ), + child: const Text('Save'), + ), + ], + ), + ) ?? + false; + } + + Future _confirmDeleteAccount() async { + return await showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + title: Row( + children: [ + const Icon(Icons.warning_amber_rounded, color: Colors.red), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Delete Account?', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + content: Text( + 'Are you sure you want to delete your account? Any money on your loyalty card will be lost. This action cannot be undone.', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text( + 'Cancel', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Delete'), + ), + ], + ), + ) ?? + false; + } + @override Widget build(BuildContext context) { - return AnimatedBuilder( - animation: controller, - builder: (context, _) { - if (controller.isLoading) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); - } - - return Scaffold( - appBar: AppBar(title: const Text("Edit Profile")), - body: Padding( - padding: const EdgeInsets.all(20), - child: Form( - key: _formKey, - child: Column( - children: [ - SectionHeader(title: "Full Name"), - InfoCard(label: "Current", value: controller.currentName), - TextFormField(controller: controller.nameController), - - SectionHeader(title: "Email"), - InfoCard(label: "Current", value: controller.currentEmail), - TextFormField(controller: controller.emailController), - - ElevatedButton( - onPressed: controller.isSaving - ? null - : () async { - if (!_formKey.currentState!.validate()) return; - final emailChanged = await controller.saveChanges(); - if (emailChanged) { - context.go('/change-email-verification'); - } - }, - child: const Text("Save"), - ), - ], - ), + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + flexibleSpace: Container( + decoration: BoxDecoration( + gradient: Theme.of(context).colorScheme.primaryGradient, + ), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => context.go('/settings'), + ), + title: const Text( + 'Edit Profile', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + centerTitle: true, + elevation: 0, + ), + body: _controller.isLoading + ? Center( + child: CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), + ) + : SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 24, + ), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SectionHeader(title: 'Full Name'), + const SizedBox(height: 12), + InfoCard( + label: 'Current', + value: _controller.currentName.isNotEmpty + ? _controller.currentName + : 'Not set', + ), + const SizedBox(height: 16), + NameFormField( + controller: _controller.nameController, + enabled: !_controller.isSaving, + ), + const SizedBox(height: 10), + + const SectionHeader(title: 'Email Address'), + const SizedBox(height: 12), + InfoCard( + label: 'Current', + value: _controller.currentEmail.isNotEmpty + ? _controller.currentEmail + : 'Not set', + ), + const SizedBox(height: 16), + EmailFormField( + controller: _controller.emailController, + enabled: !_controller.isSaving, + ), + const SizedBox(height: 20), + + SaveButton( + isSaving: _controller.isSaving, + onPressed: _onSavePressed, + ), + const SizedBox(height: 30), + + DangerZoneSection( + isSaving: _controller.isSaving, + onDeletePressed: _onDeletePressed, + ), + const SizedBox(height: 24), + ], ), ), - ); - }, + ), + ), ); } -} +} \ No newline at end of file diff --git a/lib/features/edit_profile/widgets/name_form.dart b/lib/features/edit_profile/widgets/name_form.dart new file mode 100644 index 0000000..9469ece --- /dev/null +++ b/lib/features/edit_profile/widgets/name_form.dart @@ -0,0 +1,89 @@ +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class NameFormField extends StatelessWidget { + final TextEditingController controller; + final bool enabled; + + const NameFormField({ + super.key, + required this.controller, + required this.enabled, + }); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + enabled: enabled, + inputFormatters: [ + LengthLimitingTextInputFormatter(36), + FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9 ]')), + ], + maxLength: 36, + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontSize: 16, + ), + decoration: InputDecoration( + labelText: 'New Full Name', + hintText: 'Enter your full name', + hintStyle: TextStyle( + color: Theme.of(context) + .colorScheme + .fontSecondary + .withValues(alpha: 0.5), + ), + labelStyle: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 14, + ), + counterStyle: TextStyle( + color: Theme.of(context) + .colorScheme + .fontSecondary + .withValues(alpha: 0.6), + ), + filled: true, + fillColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.03), + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2.0, + ), + borderRadius: BorderRadius.circular(14), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context) + .colorScheme + .fontSecondary + .withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(14), + ), + prefixIcon: Icon( + Icons.person_outline, + color: Theme.of(context).colorScheme.primary, + ), + ), + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Name cannot be empty'; + } + return null; + }, + ); + } +} \ No newline at end of file diff --git a/lib/features/edit_profile/widgets/save_button.dart b/lib/features/edit_profile/widgets/save_button.dart new file mode 100644 index 0000000..4db7567 --- /dev/null +++ b/lib/features/edit_profile/widgets/save_button.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +class SaveButton extends StatelessWidget { + final bool isSaving; + final VoidCallback onPressed; + + const SaveButton({ + super.key, + required this.isSaving, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: isSaving ? null : onPressed, + child: isSaving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle_outline, size: 20), + SizedBox(width: 8), + Text( + 'Save Changes', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} \ No newline at end of file From bd49a115e1ec3ae42a687cdfd227b0deb2c7d0ee Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Mar 2026 10:29:10 -0400 Subject: [PATCH 04/79] Adds tests for edit_profile feature --- .../edit_profile/widgets/danger_zone.dart | 92 +++ .../edit_profile/widgets/email_form.dart | 80 +++ .../edit_profile/controller_test.dart | 336 ++++++++++ .../edit_profile/edit_profile_test.dart | 578 ++++++++++++++++++ test/features/edit_profile/mocks.dart | 24 + .../widgets/danger_zone_test.dart | 114 ++++ .../edit_profile/widgets/email_form_test.dart | 150 +++++ .../edit_profile/widgets/info_card_test.dart | 48 ++ .../edit_profile/widgets/name_form_test.dart | 165 +++++ .../widgets/save_button_test.dart | 88 +++ .../widgets/section_header_test.dart | 33 + 11 files changed, 1708 insertions(+) create mode 100644 lib/features/edit_profile/widgets/danger_zone.dart create mode 100644 lib/features/edit_profile/widgets/email_form.dart create mode 100644 test/features/edit_profile/controller_test.dart create mode 100644 test/features/edit_profile/edit_profile_test.dart create mode 100644 test/features/edit_profile/mocks.dart create mode 100644 test/features/edit_profile/widgets/danger_zone_test.dart create mode 100644 test/features/edit_profile/widgets/email_form_test.dart create mode 100644 test/features/edit_profile/widgets/info_card_test.dart create mode 100644 test/features/edit_profile/widgets/name_form_test.dart create mode 100644 test/features/edit_profile/widgets/save_button_test.dart create mode 100644 test/features/edit_profile/widgets/section_header_test.dart diff --git a/lib/features/edit_profile/widgets/danger_zone.dart b/lib/features/edit_profile/widgets/danger_zone.dart new file mode 100644 index 0000000..03873b7 --- /dev/null +++ b/lib/features/edit_profile/widgets/danger_zone.dart @@ -0,0 +1,92 @@ +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; + +class DangerZoneSection extends StatelessWidget { + final bool isSaving; + final VoidCallback onDeletePressed; + + const DangerZoneSection({ + super.key, + required this.isSaving, + required this.onDeletePressed, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Colors.red.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Column( + children: [ + Row( + children: [ + const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 20), + const SizedBox(width: 8), + Text( + 'Danger Zone', + style: TextStyle( + color: Colors.red, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Once you delete your account, there is no going back. Any loyalty points will be permanently lost.', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .fontSecondary + .withValues(alpha: 0.7), + fontSize: 13, + ), + ), + const SizedBox(height: 16), + OutlinedButton( + onPressed: isSaving ? null : onDeletePressed, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide(color: Colors.red, width: 1.5), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: isSaving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.red, + ), + ) + : const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.delete_outline, size: 18), + SizedBox(width: 8), + Text( + 'Delete Account', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/edit_profile/widgets/email_form.dart b/lib/features/edit_profile/widgets/email_form.dart new file mode 100644 index 0000000..52e9594 --- /dev/null +++ b/lib/features/edit_profile/widgets/email_form.dart @@ -0,0 +1,80 @@ +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; + +class EmailFormField extends StatelessWidget { + final TextEditingController controller; + final bool enabled; + + const EmailFormField({ + super.key, + required this.controller, + required this.enabled, + }); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + enabled: enabled, + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontSize: 16, + ), + decoration: InputDecoration( + labelText: 'New Email', + hintText: 'Enter your email address', + hintStyle: TextStyle( + color: Theme.of(context) + .colorScheme + .fontSecondary + .withValues(alpha: 0.5), + ), + labelStyle: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 14, + ), + filled: true, + fillColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.03), + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2.0, + ), + borderRadius: BorderRadius.circular(14), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context) + .colorScheme + .fontSecondary + .withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(14), + ), + prefixIcon: Icon( + Icons.email_outlined, + color: Theme.of(context).colorScheme.primary, + ), + ), + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Email cannot be empty'; + } + if (!value.trim().contains('@')) { + return 'Please enter a valid email'; + } + return null; + }, + ); + } +} \ No newline at end of file diff --git a/test/features/edit_profile/controller_test.dart b/test/features/edit_profile/controller_test.dart new file mode 100644 index 0000000..4e5c170 --- /dev/null +++ b/test/features/edit_profile/controller_test.dart @@ -0,0 +1,336 @@ +import 'dart:async'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/features/edit_profile/controller.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService authService; + late MockProfileService profileService; + late MockEdgeFunctionService edgeFunctionService; + late StreamController authController; + late EditProfileController controller; + + setUp(() { + authService = MockAuthService(); + profileService = MockProfileService(); + edgeFunctionService = MockEdgeFunctionService(); + authController = StreamController.broadcast(); + + GetIt.instance.reset(); + GetIt.instance.registerSingleton(authService); + GetIt.instance.registerSingleton(profileService); + GetIt.instance.registerSingleton(edgeFunctionService); + + when(() => authService.onAuthChange) + .thenAnswer((_) => authController.stream); + when(() => authService.getCurrentUserId).thenAnswer((_) => 'user-id'); + when(() => authService.getCurrentUserEmail()) + .thenAnswer((_) => 'test@example.com'); + when(() => profileService.getUserNameById('user-id')) + .thenAnswer((_) async => 'John Doe'); + + controller = EditProfileController(); + }); + + tearDown(() async { + await authController.close(); + controller.disposeController(); + GetIt.instance.reset(); + }); + + // --------------------------------------------------------------------------- + // loadUserData + // --------------------------------------------------------------------------- + + group('loadUserData', () { + test('populates currentName and currentEmail from services', () async { + await controller.init(); + + expect(controller.currentName, 'John Doe'); + expect(controller.currentEmail, 'test@example.com'); + }); + + test('sets controller text fields after loading', () async { + await controller.init(); + + expect(controller.nameController.text, 'John Doe'); + expect(controller.emailController.text, 'test@example.com'); + }); + + test('sets isLoading to false after data loads', () async { + expect(controller.isLoading, isTrue); + + await controller.init(); + + expect(controller.isLoading, isFalse); + }); + + test('sets isLoading to false even when service throws', () async { + when(() => profileService.getUserNameById(any())) + .thenThrow(Exception('Network error')); + + await expectLater(controller.init(), throwsException); + + expect(controller.isLoading, isFalse); + }); + + test('reloads data when auth state changes', () async { + await controller.init(); + + when(() => profileService.getUserNameById('user-id')) + .thenAnswer((_) async => 'Jane Doe'); + + authController.add(true); + await Future.delayed(Duration.zero); + + expect(controller.currentName, 'Jane Doe'); + }); + }); + + // --------------------------------------------------------------------------- + // hasChanges + // --------------------------------------------------------------------------- + + group('hasChanges', () { + setUp(() async { + await controller.init(); + }); + + test('returns false when nothing has changed', () { + expect(controller.hasChanges, isFalse); + }); + + test('returns true when name differs from currentName', () { + controller.nameController.text = 'New Name'; + + expect(controller.hasChanges, isTrue); + }); + + test('returns true when email differs from currentEmail', () { + controller.emailController.text = 'new@example.com'; + + expect(controller.hasChanges, isTrue); + }); + + test('returns false when text matches after trimming', () { + // Text controllers already hold the trimmed current values + controller.nameController.text = controller.currentName; + controller.emailController.text = controller.currentEmail; + + expect(controller.hasChanges, isFalse); + }); + + test('returns true when only whitespace differs', () { + // Whitespace-only changes still register as a change before trimming + controller.nameController.text = ' ${controller.currentName} '; + + expect(controller.hasChanges, isFalse); + }); + }); + + // --------------------------------------------------------------------------- + // saveChanges + // --------------------------------------------------------------------------- + + group('saveChanges', () { + setUp(() async { + await controller.init(); + when(() => authService.updateUserAttributes( + email: any(named: 'email'), + data: any(named: 'data'), + )).thenAnswer((_) async {}); + }); + + test('throws when no fields have changed', () async { + await expectLater(controller.saveChanges(), throwsException); + + verifyNever(() => authService.updateUserAttributes( + email: any(named: 'email'), + data: any(named: 'data'), + )); + }); + + test('sends only name when only name changed', () async { + controller.nameController.text = 'Jane Smith'; + + await controller.saveChanges(); + + verify(() => authService.updateUserAttributes( + email: null, + data: {'full_name': 'Jane Smith'}, + )).called(1); + }); + + test('sends only email when only email changed', () async { + controller.emailController.text = 'new@example.com'; + + await controller.saveChanges(); + + verify(() => authService.updateUserAttributes( + email: 'new@example.com', + data: null, + )).called(1); + }); + + test('sends both name and email when both changed', () async { + controller.nameController.text = 'Jane Smith'; + controller.emailController.text = 'jane@example.com'; + + await controller.saveChanges(); + + verify(() => authService.updateUserAttributes( + email: 'jane@example.com', + data: {'full_name': 'Jane Smith'}, + )).called(1); + }); + + test('trims whitespace from name and email before saving', () async { + controller.nameController.text = ' Jane '; + controller.emailController.text = ' jane@email.com '; + + await controller.saveChanges(); + + verify(() => authService.updateUserAttributes( + email: 'jane@email.com', + data: {'full_name': 'Jane'}, + )).called(1); + }); + + test('returns true when email changed', () async { + controller.emailController.text = 'new@example.com'; + + final result = await controller.saveChanges(); + + expect(result, isTrue); + }); + + test('returns false when only name changed', () async { + controller.nameController.text = 'Jane Smith'; + + final result = await controller.saveChanges(); + + expect(result, isFalse); + }); + + test('updates currentName after successful save', () async { + controller.nameController.text = 'Jane Smith'; + + await controller.saveChanges(); + + expect(controller.currentName, 'Jane Smith'); + }); + + test('sets and clears isSaving around the service call', () async { + final completer = Completer(); + when(() => authService.updateUserAttributes( + email: any(named: 'email'), + data: any(named: 'data'), + )).thenAnswer((_) => completer.future); + + controller.nameController.text = 'Jane Smith'; + + final future = controller.saveChanges(); + + expect(controller.isSaving, isTrue); + + completer.complete(); + await future; + + expect(controller.isSaving, isFalse); + }); + + test('clears isSaving even when service throws', () async { + when(() => authService.updateUserAttributes( + email: any(named: 'email'), + data: any(named: 'data'), + )).thenThrow(Exception('Save failed')); + + controller.nameController.text = 'Jane Smith'; + + await expectLater(controller.saveChanges(), throwsException); + + expect(controller.isSaving, isFalse); + }); + + test('does nothing when called while already saving', () async { + final completer = Completer(); + when(() => authService.updateUserAttributes( + email: any(named: 'email'), + data: any(named: 'data'), + )).thenAnswer((_) => completer.future); + + controller.nameController.text = 'Jane Smith'; + + final firstCall = controller.saveChanges(); + final secondResult = await controller.saveChanges(); + + expect(secondResult, isFalse); + + completer.complete(); + await firstCall; + + verify(() => authService.updateUserAttributes( + email: any(named: 'email'), + data: any(named: 'data'), + )).called(1); + }); + }); + + // --------------------------------------------------------------------------- + // deleteAccount + // --------------------------------------------------------------------------- + + group('deleteAccount', () { + setUp(() async { + await controller.init(); + when(() => authService.logout()).thenAnswer((_) async {}); + }); + + test('calls edge function with correct arguments', () async { + when(() => edgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )).thenAnswer( + (_) async => FunctionResponse(data: null, status: 200)); + + await controller.deleteAccount(); + + verify(() => edgeFunctionService.runEdgeFunction( + name: 'delete-account', + body: {'user_id': 'user-id'}, + )).called(1); + }); + + test('calls logout and returns true on 200 response', () async { + when(() => edgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )).thenAnswer( + (_) async => FunctionResponse(data: null, status: 200)); + + final result = await controller.deleteAccount(); + + verify(() => authService.logout()).called(1); + expect(result, isTrue); + }); + + test('does not logout and returns false on non-200 response', () async { + when(() => edgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )).thenAnswer( + (_) async => FunctionResponse(data: null, status: 500)); + + final result = await controller.deleteAccount(); + + verifyNever(() => authService.logout()); + expect(result, isFalse); + }); + }); +} \ No newline at end of file diff --git a/test/features/edit_profile/edit_profile_test.dart b/test/features/edit_profile/edit_profile_test.dart new file mode 100644 index 0000000..609fd91 --- /dev/null +++ b/test/features/edit_profile/edit_profile_test.dart @@ -0,0 +1,578 @@ +import 'dart:async'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/features/edit_profile/edit_profile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService authService; + late MockProfileService profileService; + late MockEdgeFunctionService edgeFunctionService; + late StreamController authController; + + setUp(() { + authService = MockAuthService(); + profileService = MockProfileService(); + edgeFunctionService = MockEdgeFunctionService(); + authController = StreamController.broadcast(); + + GetIt.instance.reset(); + GetIt.instance.registerSingleton(authService); + GetIt.instance.registerSingleton(profileService); + GetIt.instance.registerSingleton(edgeFunctionService); + + when(() => authService.onAuthChange) + .thenAnswer((_) => authController.stream); + when(() => authService.getCurrentUserId).thenAnswer((_) => 'user-id'); + when(() => authService.getCurrentUserEmail()) + .thenAnswer((_) => 'test@example.com'); + when(() => profileService.getUserNameById('user-id')) + .thenAnswer((_) async => 'John Doe'); + when(() => authService.updateUserAttributes( + email: any(named: 'email'), + data: any(named: 'data'), + )).thenAnswer((_) async {}); + }); + + tearDown(() async { + await authController.close(); + GetIt.instance.reset(); + }); + + Widget createWidget() { + return MaterialApp.router( + routerConfig: GoRouter( + routes: [ + GoRoute(path: '/', builder: (_, __) => const EditProfilePage()), + GoRoute( + path: '/settings', + builder: (_, __) => const Scaffold(body: Text('Settings')), + ), + GoRoute( + path: '/change-email-verification', + builder: (_, __) => const Scaffold(body: Text('Verify Email')), + ), + GoRoute( + path: '/login', + builder: (_, __) => const Scaffold(body: Text('Login')), + ), + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // Static UI + // --------------------------------------------------------------------------- + + group('Static UI', () { + testWidgets('displays page title', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Edit Profile'), findsOneWidget); + }); + + testWidgets('loads and displays user data in info cards', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Full Name'), findsOneWidget); + expect(find.text('Email Address'), findsOneWidget); + expect(find.text('Current'), findsNWidgets(2)); + expect(find.text('John Doe'), findsNWidgets(2)); + expect(find.text('test@example.com'), findsNWidgets(2)); + expect(find.text('Save Changes'), findsOneWidget); + expect(find.byIcon(Icons.check_circle_outline), findsOneWidget); + }); + + testWidgets('input fields have proper hint text', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Enter your full name'), findsOneWidget); + expect(find.text('Enter your email address'), findsOneWidget); + }); + + testWidgets('displays danger zone section with delete account button', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Danger Zone'), findsOneWidget); + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + expect(find.text('Delete Account'), findsOneWidget); + expect(find.byIcon(Icons.delete_outline), findsOneWidget); + }); + + testWidgets('page is scrollable', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.byType(SingleChildScrollView), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // Loading state + // --------------------------------------------------------------------------- + + group('Loading state', () { + testWidgets('displays loading indicator while fetching data', + (tester) async { + final completer = Completer(); + when(() => profileService.getUserNameById('user-id')) + .thenAnswer((_) => completer.future); + + await tester.pumpWidget(createWidget()); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + completer.complete('John Doe'); + await tester.pumpAndSettle(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('John Doe'), findsNWidgets(2)); + }); + + testWidgets('shows error dialog when init fails', (tester) async { + when(() => profileService.getUserNameById(any())) + .thenThrow(Exception('Network error')); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Error'), findsOneWidget); + expect(find.textContaining('Failed to load profile data'), findsOneWidget); + }); + + testWidgets('error dialog dismisses on OK tap', (tester) async { + when(() => profileService.getUserNameById(any())) + .thenThrow(Exception('Network error')); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(find.text('Error'), findsNothing); + }); + }); + + // --------------------------------------------------------------------------- + // Navigation + // --------------------------------------------------------------------------- + + group('Navigation', () { + testWidgets('back button navigates to settings', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + + expect(find.text('Settings'), findsOneWidget); + }); + + testWidgets('navigates to verification page when email changes', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'New Email'), + 'new@email.com', + ); + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + verify(() => authService.updateUserAttributes( + email: 'new@email.com', + data: null, + )).called(1); + + expect(find.text('Verify Email'), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // Confirmation dialog + // --------------------------------------------------------------------------- + + group('Confirmation dialog', () { + testWidgets('shows confirmation dialog before saving changes', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'New Full Name'), + 'New Name', + ); + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + expect(find.text('Confirm Changes'), findsOneWidget); + expect( + find.text( + 'Are you sure you want to save these changes to your profile?'), + findsOneWidget, + ); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + }); + + testWidgets('shows No Changes dialog when nothing was edited', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + expect(find.text('No Changes'), findsOneWidget); + expect(find.text("You haven't changed anything."), findsOneWidget); + + verifyNever(() => authService.updateUserAttributes( + email: any(named: 'email'), + data: any(named: 'data'), + )); + }); + + testWidgets('cancels save when user taps Cancel in confirmation', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'New Full Name'), + 'New Name', + ); + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + verifyNever(() => authService.updateUserAttributes( + email: any(named: 'email'), + data: any(named: 'data'), + )); + }); + }); + + // --------------------------------------------------------------------------- + // Form validation + // --------------------------------------------------------------------------- + + group('Form validation', () { + testWidgets('validates empty name', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'New Full Name'), + '', + ); + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(find.text('Name cannot be empty'), findsOneWidget); + }); + + testWidgets('validates invalid email', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'New Email'), + 'invalid-email', + ); + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(find.text('Please enter a valid email'), findsOneWidget); + }); + + testWidgets('validates empty email', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'New Email'), + ' ', + ); + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(find.text('Email cannot be empty'), findsOneWidget); + }); + + testWidgets('enforces name character limit', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'New Full Name'), + 'This is a very long name that exceeds the limit', + ); + await tester.pump(); + + final textField = tester.widget( + find.widgetWithText(TextFormField, 'New Full Name'), + ); + expect(textField.controller!.text.length, lessThanOrEqualTo(36)); + }); + + testWidgets('name field only allows alphanumeric and spaces', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'New Full Name'), + 'Test@#\$%', + ); + await tester.pump(); + + final textField = tester.widget( + find.widgetWithText(TextFormField, 'New Full Name'), + ); + expect(textField.controller!.text, 'Test'); + }); + }); + + // --------------------------------------------------------------------------- + // Save behavior + // --------------------------------------------------------------------------- + + group('Save behavior', () { + testWidgets('updates name and shows success message', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'New Full Name'), + 'Jane Smith', + ); + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + verify(() => authService.updateUserAttributes( + email: null, + data: {'full_name': 'Jane Smith'}, + )).called(1); + + expect(find.text('Profile Updated'), findsOneWidget); + }); + + testWidgets('trims whitespace before saving', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'New Full Name'), + ' Jane ', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'New Email'), + ' jane@email.com ', + ); + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + verify(() => authService.updateUserAttributes( + email: 'jane@email.com', + data: {'full_name': 'Jane'}, + )).called(1); + }); + + testWidgets('disables save button while saving', (tester) async { + final completer = Completer(); + when(() => authService.updateUserAttributes( + email: any(named: 'email'), + data: any(named: 'data'), + )).thenAnswer((_) => completer.future); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'New Full Name'), + 'New Name', + ); + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pump(); + + final saveButton = tester.widget( + find + .ancestor( + of: find.byType(CircularProgressIndicator), + matching: find.byType(ElevatedButton), + ) + .first, + ); + expect(saveButton.onPressed, isNull); + + completer.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('shows Update Failed dialog when saveChanges throws', + (tester) async { + when(() => authService.updateUserAttributes( + email: any(named: 'email'), + data: any(named: 'data'), + )).thenThrow(Exception('Server error')); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'New Full Name'), + 'Jane Smith', + ); + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(find.text('Update Failed'), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // Delete account + // --------------------------------------------------------------------------- + + group('Delete account', () { + // Scrolls to and taps the delete button, then taps Confirm Delete + Future openDeleteDialog(WidgetTester tester) async { + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0, -500), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byType(OutlinedButton)); + await tester.pumpAndSettle(); + } + + testWidgets('shows delete account confirmation dialog', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + await openDeleteDialog(tester); + + expect(find.text('Delete Account?'), findsOneWidget); + expect( + find.text( + 'Are you sure you want to delete your account? ' + 'Any money on your loyalty card will be lost. ' + 'This action cannot be undone.', + ), + findsOneWidget, + ); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Delete'), findsOneWidget); + }); + + testWidgets('cancels delete when user taps Cancel', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + await openDeleteDialog(tester); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + verifyNever(() => edgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )); + }); + + testWidgets('navigates to login after successful delete', (tester) async { + when(() => authService.logout()).thenAnswer((_) async {}); + when(() => edgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )).thenAnswer( + (_) async => FunctionResponse(data: null, status: 200)); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + await openDeleteDialog(tester); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + + expect(find.text('Login'), findsOneWidget); + }); + + testWidgets('shows error dialog when delete returns non-200', + (tester) async { + when(() => edgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )).thenAnswer( + (_) async => FunctionResponse(data: null, status: 500)); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + await openDeleteDialog(tester); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + + expect(find.text('Error'), findsOneWidget); + expect( + find.text('An error occurred, please try again later.'), + findsOneWidget, + ); + }); + + testWidgets('shows error dialog when deleteAccount throws', (tester) async { + when(() => edgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )).thenThrow(Exception('Network failure')); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + await openDeleteDialog(tester); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + + expect(find.text('Error'), findsOneWidget); + }); + }); +} \ No newline at end of file diff --git a/test/features/edit_profile/mocks.dart b/test/features/edit_profile/mocks.dart new file mode 100644 index 0000000..1f60f98 --- /dev/null +++ b/test/features/edit_profile/mocks.dart @@ -0,0 +1,24 @@ +import 'dart:async'; + +import 'package:app_links/app_links.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; +import 'package:clean_stream_laundry_app/middleware/app_router.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme_manager.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; +import 'package:clean_stream_laundry_app/services/notification_service.dart'; +import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; +import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; +import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; + +class MockAuthService extends Mock implements AuthService {} + +class MockProfileService extends Mock implements ProfileService {} + +class MockEdgeFunctionService extends Mock implements EdgeFunctionService {} + diff --git a/test/features/edit_profile/widgets/danger_zone_test.dart b/test/features/edit_profile/widgets/danger_zone_test.dart new file mode 100644 index 0000000..1c9181b --- /dev/null +++ b/test/features/edit_profile/widgets/danger_zone_test.dart @@ -0,0 +1,114 @@ +import 'package:clean_stream_laundry_app/features/edit_profile/widgets/danger_zone.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget({ + bool isSaving = false, + VoidCallback? onDeletePressed, + }) { + return MaterialApp( + home: Scaffold( + body: DangerZoneSection( + isSaving: isSaving, + onDeletePressed: onDeletePressed ?? () {}, + ), + ), + ); + } + + group('DangerZoneSection', () { + group('Rendering', () { + testWidgets('displays Danger Zone header', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect(find.text('Danger Zone'), findsOneWidget); + }); + + testWidgets('displays warning icon', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + }); + + testWidgets('displays Delete Account button text', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect(find.text('Delete Account'), findsOneWidget); + }); + + testWidgets('displays delete_outline icon in button', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect(find.byIcon(Icons.delete_outline), findsOneWidget); + }); + + testWidgets('displays warning description text', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect( + find.textContaining('Once you delete your account'), + findsOneWidget, + ); + }); + }); + + group('Idle state', () { + testWidgets('delete button is enabled', (tester) async { + await tester.pumpWidget(buildWidget()); + + final button = tester.widget( + find.byType(OutlinedButton), + ); + expect(button.onPressed, isNotNull); + }); + + testWidgets('calls onDeletePressed when tapped', (tester) async { + var tapped = false; + await tester.pumpWidget( + buildWidget(onDeletePressed: () => tapped = true), + ); + + await tester.tap(find.byType(OutlinedButton)); + + expect(tapped, isTrue); + }); + }); + + group('Saving state', () { + testWidgets('delete button is disabled while saving', (tester) async { + await tester.pumpWidget(buildWidget(isSaving: true)); + + final button = tester.widget( + find.byType(OutlinedButton), + ); + expect(button.onPressed, isNull); + }); + + testWidgets('shows CircularProgressIndicator in button while saving', + (tester) async { + await tester.pumpWidget(buildWidget(isSaving: true)); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('hides Delete Account text while saving', (tester) async { + await tester.pumpWidget(buildWidget(isSaving: true)); + + expect(find.text('Delete Account'), findsNothing); + }); + + testWidgets('does not call onDeletePressed when tapped while saving', + (tester) async { + var tapped = false; + await tester.pumpWidget( + buildWidget(isSaving: true, onDeletePressed: () => tapped = true), + ); + + await tester.tap(find.byType(OutlinedButton), warnIfMissed: false); + + expect(tapped, isFalse); + }); + }); + }); +} \ No newline at end of file diff --git a/test/features/edit_profile/widgets/email_form_test.dart b/test/features/edit_profile/widgets/email_form_test.dart new file mode 100644 index 0000000..d2d83f3 --- /dev/null +++ b/test/features/edit_profile/widgets/email_form_test.dart @@ -0,0 +1,150 @@ +import 'package:clean_stream_laundry_app/features/edit_profile/widgets/email_form.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget({ + TextEditingController? controller, + bool enabled = true, + }) { + return MaterialApp( + home: Scaffold( + body: Form( + child: EmailFormField( + controller: controller ?? TextEditingController(), + enabled: enabled, + ), + ), + ), + ); + } + + group('EmailFormField', () { + group('Rendering', () { + testWidgets('displays label text', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect(find.text('New Email'), findsOneWidget); + }); + + testWidgets('displays hint text', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect(find.text('Enter your email address'), findsOneWidget); + }); + + testWidgets('displays email prefix icon', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect(find.byIcon(Icons.email_outlined), findsOneWidget); + }); + + testWidgets('is enabled by default', (tester) async { + final controller = TextEditingController(); + await tester.pumpWidget(buildWidget(controller: controller)); + + await tester.enterText(find.byType(TextFormField), 'test@example.com'); + + expect(controller.text, 'test@example.com'); + }); + + testWidgets('is disabled when enabled is false', (tester) async { + await tester.pumpWidget(buildWidget(enabled: false)); + + final field = tester.widget(find.byType(TextFormField)); + expect(field.enabled, isFalse); + }); + }); + + group('Validation', () { + testWidgets('shows error for empty value', (tester) async { + final formKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + key: formKey, + child: EmailFormField( + controller: TextEditingController(text: ''), + enabled: true, + ), + ), + ), + ), + ); + + formKey.currentState!.validate(); + await tester.pump(); + + expect(find.text('Email cannot be empty'), findsOneWidget); + }); + + testWidgets('shows error for whitespace-only value', (tester) async { + final formKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + key: formKey, + child: EmailFormField( + controller: TextEditingController(text: ' '), + enabled: true, + ), + ), + ), + ), + ); + + formKey.currentState!.validate(); + await tester.pump(); + + expect(find.text('Email cannot be empty'), findsOneWidget); + }); + + testWidgets('shows error for value without @', (tester) async { + final formKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + key: formKey, + child: EmailFormField( + controller: TextEditingController(text: 'invalidemail'), + enabled: true, + ), + ), + ), + ), + ); + + formKey.currentState!.validate(); + await tester.pump(); + + expect(find.text('Please enter a valid email'), findsOneWidget); + }); + + testWidgets('passes validation for valid email', (tester) async { + final formKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + key: formKey, + child: EmailFormField( + controller: TextEditingController(text: 'user@example.com'), + enabled: true, + ), + ), + ), + ), + ); + + final isValid = formKey.currentState!.validate(); + + expect(isValid, isTrue); + expect(find.text('Please enter a valid email'), findsNothing); + expect(find.text('Email cannot be empty'), findsNothing); + }); + }); + }); +} \ No newline at end of file diff --git a/test/features/edit_profile/widgets/info_card_test.dart b/test/features/edit_profile/widgets/info_card_test.dart new file mode 100644 index 0000000..599a72c --- /dev/null +++ b/test/features/edit_profile/widgets/info_card_test.dart @@ -0,0 +1,48 @@ +import 'package:clean_stream_laundry_app/features/edit_profile/widgets/info_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget({required String label, required String value}) { + return MaterialApp( + home: Scaffold( + body: InfoCard(label: label, value: value), + ), + ); + } + + group('InfoCard', () { + testWidgets('displays the label', (tester) async { + await tester.pumpWidget(buildWidget(label: 'Current', value: 'John Doe')); + + expect(find.text('Current'), findsOneWidget); + }); + + testWidgets('displays the value', (tester) async { + await tester.pumpWidget(buildWidget(label: 'Current', value: 'John Doe')); + + expect(find.text('John Doe'), findsOneWidget); + }); + + testWidgets('displays both label and value together', (tester) async { + await tester.pumpWidget( + buildWidget(label: 'Email Address', value: 'test@example.com'), + ); + + expect(find.text('Email Address'), findsOneWidget); + expect(find.text('test@example.com'), findsOneWidget); + }); + + testWidgets('renders inside a Row', (tester) async { + await tester.pumpWidget(buildWidget(label: 'Current', value: 'Jane')); + + expect(find.byType(Row), findsOneWidget); + }); + + testWidgets('handles empty value gracefully', (tester) async { + await tester.pumpWidget(buildWidget(label: 'Current', value: '')); + + expect(find.text('Current'), findsOneWidget); + }); + }); +} \ No newline at end of file diff --git a/test/features/edit_profile/widgets/name_form_test.dart b/test/features/edit_profile/widgets/name_form_test.dart new file mode 100644 index 0000000..be3c1fb --- /dev/null +++ b/test/features/edit_profile/widgets/name_form_test.dart @@ -0,0 +1,165 @@ +import 'package:clean_stream_laundry_app/features/edit_profile/widgets/name_form.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget({ + TextEditingController? controller, + bool enabled = true, + }) { + return MaterialApp( + home: Scaffold( + body: Form( + child: NameFormField( + controller: controller ?? TextEditingController(), + enabled: enabled, + ), + ), + ), + ); + } + + group('NameFormField', () { + group('Rendering', () { + testWidgets('displays label text', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect(find.text('New Full Name'), findsOneWidget); + }); + + testWidgets('displays hint text', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect(find.text('Enter your full name'), findsOneWidget); + }); + + testWidgets('displays person outline prefix icon', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect(find.byIcon(Icons.person_outline), findsOneWidget); + }); + + testWidgets('is enabled by default', (tester) async { + final controller = TextEditingController(); + await tester.pumpWidget(buildWidget(controller: controller)); + + await tester.enterText(find.byType(TextFormField), 'Test'); + + expect(controller.text, 'Test'); + }); + + testWidgets('is disabled when enabled is false', (tester) async { + final controller = TextEditingController(); + await tester.pumpWidget( + buildWidget(controller: controller, enabled: false), + ); + + final field = tester.widget(find.byType(TextFormField)); + expect(field.enabled, isFalse); + }); + }); + + group('Input formatters', () { + testWidgets('enforces 36 character limit', (tester) async { + final controller = TextEditingController(); + await tester.pumpWidget(buildWidget(controller: controller)); + + await tester.enterText( + find.byType(TextFormField), + 'A' * 50, + ); + await tester.pump(); + + expect(controller.text.length, lessThanOrEqualTo(36)); + }); + + testWidgets('strips special characters', (tester) async { + final controller = TextEditingController(); + await tester.pumpWidget(buildWidget(controller: controller)); + + await tester.enterText(find.byType(TextFormField), 'Test@#\$%'); + await tester.pump(); + + expect(controller.text, 'Test'); + }); + + testWidgets('allows letters, numbers, and spaces', (tester) async { + final controller = TextEditingController(); + await tester.pumpWidget(buildWidget(controller: controller)); + + await tester.enterText(find.byType(TextFormField), 'John Doe 123'); + await tester.pump(); + + expect(controller.text, 'John Doe 123'); + }); + }); + + group('Validation', () { + testWidgets('shows error for empty value', (tester) async { + final formKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + key: formKey, + child: NameFormField( + controller: TextEditingController(text: ''), + enabled: true, + ), + ), + ), + ), + ); + + formKey.currentState!.validate(); + await tester.pump(); + + expect(find.text('Name cannot be empty'), findsOneWidget); + }); + + testWidgets('shows error for whitespace-only value', (tester) async { + final formKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + key: formKey, + child: NameFormField( + controller: TextEditingController(text: ' '), + enabled: true, + ), + ), + ), + ), + ); + + formKey.currentState!.validate(); + await tester.pump(); + + expect(find.text('Name cannot be empty'), findsOneWidget); + }); + + testWidgets('passes validation for non-empty value', (tester) async { + final formKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + key: formKey, + child: NameFormField( + controller: TextEditingController(text: 'John Doe'), + enabled: true, + ), + ), + ), + ), + ); + + final isValid = formKey.currentState!.validate(); + + expect(isValid, isTrue); + expect(find.text('Name cannot be empty'), findsNothing); + }); + }); + }); +} \ No newline at end of file diff --git a/test/features/edit_profile/widgets/save_button_test.dart b/test/features/edit_profile/widgets/save_button_test.dart new file mode 100644 index 0000000..98a896e --- /dev/null +++ b/test/features/edit_profile/widgets/save_button_test.dart @@ -0,0 +1,88 @@ +import 'package:clean_stream_laundry_app/features/edit_profile/widgets/save_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget({ + bool isSaving = false, + VoidCallback? onPressed, + }) { + return MaterialApp( + home: Scaffold( + body: SaveButton( + isSaving: isSaving, + onPressed: onPressed ?? () {}, + ), + ), + ); + } + + group('SaveButton', () { + group('Idle state', () { + testWidgets('displays Save Changes text', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect(find.text('Save Changes'), findsOneWidget); + }); + + testWidgets('displays check_circle_outline icon', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect(find.byIcon(Icons.check_circle_outline), findsOneWidget); + }); + + testWidgets('button is enabled', (tester) async { + await tester.pumpWidget(buildWidget()); + + final button = tester.widget( + find.byType(ElevatedButton), + ); + expect(button.onPressed, isNotNull); + }); + + testWidgets('calls onPressed when tapped', (tester) async { + var tapped = false; + await tester.pumpWidget(buildWidget(onPressed: () => tapped = true)); + + await tester.tap(find.byType(ElevatedButton)); + + expect(tapped, isTrue); + }); + }); + + group('Saving state', () { + testWidgets('displays CircularProgressIndicator', (tester) async { + await tester.pumpWidget(buildWidget(isSaving: true)); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('hides Save Changes text', (tester) async { + await tester.pumpWidget(buildWidget(isSaving: true)); + + expect(find.text('Save Changes'), findsNothing); + }); + + testWidgets('button is disabled', (tester) async { + await tester.pumpWidget(buildWidget(isSaving: true)); + + final button = tester.widget( + find.byType(ElevatedButton), + ); + expect(button.onPressed, isNull); + }); + + testWidgets('does not call onPressed when tapped while saving', + (tester) async { + var tapped = false; + await tester.pumpWidget( + buildWidget(isSaving: true, onPressed: () => tapped = true), + ); + + await tester.tap(find.byType(ElevatedButton), warnIfMissed: false); + + expect(tapped, isFalse); + }); + }); + }); +} \ No newline at end of file diff --git a/test/features/edit_profile/widgets/section_header_test.dart b/test/features/edit_profile/widgets/section_header_test.dart new file mode 100644 index 0000000..0889e74 --- /dev/null +++ b/test/features/edit_profile/widgets/section_header_test.dart @@ -0,0 +1,33 @@ +import 'package:clean_stream_laundry_app/features/edit_profile/widgets/section_header.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget({required String title}) { + return MaterialApp( + home: Scaffold( + body: SectionHeader(title: title), + ), + ); + } + + group('SectionHeader', () { + testWidgets('displays the title', (tester) async { + await tester.pumpWidget(buildWidget(title: 'Full Name')); + + expect(find.text('Full Name'), findsOneWidget); + }); + + testWidgets('displays different title values correctly', (tester) async { + await tester.pumpWidget(buildWidget(title: 'Email Address')); + + expect(find.text('Email Address'), findsOneWidget); + }); + + testWidgets('renders as a Text widget', (tester) async { + await tester.pumpWidget(buildWidget(title: 'Full Name')); + + expect(find.byType(Text), findsOneWidget); + }); + }); +} \ No newline at end of file From c9e9f17e80e8f6cc5283f2e475818c280036fd9b Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Mar 2026 10:33:07 -0400 Subject: [PATCH 05/79] Cleans tests --- .../edit_profile/controller_test.dart | 17 ------------ .../edit_profile/edit_profile_test.dart | 27 ------------------- test/features/edit_profile/mocks.dart | 14 +--------- 3 files changed, 1 insertion(+), 57 deletions(-) diff --git a/test/features/edit_profile/controller_test.dart b/test/features/edit_profile/controller_test.dart index 4e5c170..fd23dec 100644 --- a/test/features/edit_profile/controller_test.dart +++ b/test/features/edit_profile/controller_test.dart @@ -44,10 +44,6 @@ void main() { GetIt.instance.reset(); }); - // --------------------------------------------------------------------------- - // loadUserData - // --------------------------------------------------------------------------- - group('loadUserData', () { test('populates currentName and currentEmail from services', () async { await controller.init(); @@ -93,10 +89,6 @@ void main() { }); }); - // --------------------------------------------------------------------------- - // hasChanges - // --------------------------------------------------------------------------- - group('hasChanges', () { setUp(() async { await controller.init(); @@ -119,7 +111,6 @@ void main() { }); test('returns false when text matches after trimming', () { - // Text controllers already hold the trimmed current values controller.nameController.text = controller.currentName; controller.emailController.text = controller.currentEmail; @@ -127,16 +118,12 @@ void main() { }); test('returns true when only whitespace differs', () { - // Whitespace-only changes still register as a change before trimming controller.nameController.text = ' ${controller.currentName} '; expect(controller.hasChanges, isFalse); }); }); - // --------------------------------------------------------------------------- - // saveChanges - // --------------------------------------------------------------------------- group('saveChanges', () { setUp(() async { @@ -282,10 +269,6 @@ void main() { }); }); - // --------------------------------------------------------------------------- - // deleteAccount - // --------------------------------------------------------------------------- - group('deleteAccount', () { setUp(() async { await controller.init(); diff --git a/test/features/edit_profile/edit_profile_test.dart b/test/features/edit_profile/edit_profile_test.dart index 609fd91..99e6cc3 100644 --- a/test/features/edit_profile/edit_profile_test.dart +++ b/test/features/edit_profile/edit_profile_test.dart @@ -68,10 +68,6 @@ void main() { ); } - // --------------------------------------------------------------------------- - // Static UI - // --------------------------------------------------------------------------- - group('Static UI', () { testWidgets('displays page title', (tester) async { await tester.pumpWidget(createWidget()); @@ -120,10 +116,6 @@ void main() { }); }); - // --------------------------------------------------------------------------- - // Loading state - // --------------------------------------------------------------------------- - group('Loading state', () { testWidgets('displays loading indicator while fetching data', (tester) async { @@ -168,10 +160,6 @@ void main() { }); }); - // --------------------------------------------------------------------------- - // Navigation - // --------------------------------------------------------------------------- - group('Navigation', () { testWidgets('back button navigates to settings', (tester) async { await tester.pumpWidget(createWidget()); @@ -207,10 +195,6 @@ void main() { }); }); - // --------------------------------------------------------------------------- - // Confirmation dialog - // --------------------------------------------------------------------------- - group('Confirmation dialog', () { testWidgets('shows confirmation dialog before saving changes', (tester) async { @@ -273,9 +257,6 @@ void main() { }); }); - // --------------------------------------------------------------------------- - // Form validation - // --------------------------------------------------------------------------- group('Form validation', () { testWidgets('validates empty name', (tester) async { @@ -362,10 +343,6 @@ void main() { }); }); - // --------------------------------------------------------------------------- - // Save behavior - // --------------------------------------------------------------------------- - group('Save behavior', () { testWidgets('updates name and shows success message', (tester) async { await tester.pumpWidget(createWidget()); @@ -471,12 +448,8 @@ void main() { }); }); - // --------------------------------------------------------------------------- - // Delete account - // --------------------------------------------------------------------------- group('Delete account', () { - // Scrolls to and taps the delete button, then taps Confirm Delete Future openDeleteDialog(WidgetTester tester) async { await tester.drag( find.byType(SingleChildScrollView), diff --git a/test/features/edit_profile/mocks.dart b/test/features/edit_profile/mocks.dart index 1f60f98..51fd1ed 100644 --- a/test/features/edit_profile/mocks.dart +++ b/test/features/edit_profile/mocks.dart @@ -1,20 +1,8 @@ -import 'dart:async'; - -import 'package:app_links/app_links.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; -import 'package:clean_stream_laundry_app/middleware/app_router.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme_manager.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; -import 'package:clean_stream_laundry_app/services/notification_service.dart'; -import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; -import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; -import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; + class MockAuthService extends Mock implements AuthService {} From 141e742f12fdde67670b8f375a5d04bae95b13a7 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Mar 2026 10:37:41 -0400 Subject: [PATCH 06/79] Separates email_verification.dart --- .../email_verification/controller.dart | 65 +++++++++++++++ .../email_verification.dart | 79 +++++++++++++++++++ .../widgets/resend_verification.dart | 75 ++++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 lib/features/email_verification/controller.dart create mode 100644 lib/features/email_verification/email_verification.dart create mode 100644 lib/features/email_verification/widgets/resend_verification.dart diff --git a/lib/features/email_verification/controller.dart b/lib/features/email_verification/controller.dart new file mode 100644 index 0000000..9a62c31 --- /dev/null +++ b/lib/features/email_verification/controller.dart @@ -0,0 +1,65 @@ +import 'dart:async'; +import 'package:app_links/app_links.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; + +class EmailVerificationController { + final AuthService _authService = GetIt.instance(); + final AppLinks appLinks; + final BuildContext context; + + StreamSubscription? _authSub; + StreamSubscription? _linkSub; + + bool resent = false; + bool isLoading = false; + AuthenticationResponses? lastResponse; + + EmailVerificationController({ + required this.appLinks, + required this.context, + }); + + void init() { + _authSub = _authService.onAuthChange.listen((isLoggedIn) { + if (isLoggedIn && _authService.isEmailVerified()) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) context.go('/homePage'); + }); + } + }); + + _linkSub = appLinks.uriLinkStream.listen(_handleUri); + } + + void dispose() { + _authSub?.cancel(); + _linkSub?.cancel(); + } + + Future _handleUri(Uri? uri) async { + if (uri != null && + uri.scheme == 'clean-stream' && + uri.host == 'email-verification') { + await _authService.getSessionFromURI(uri); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) context.go('/homePage'); + }); + } + } + + Future resendVerification() async { + if (resent) return; + + isLoading = true; + lastResponse = await _authService.resendVerification(); + isLoading = false; + + if (lastResponse == AuthenticationResponses.success) { + resent = true; + } + } +} \ No newline at end of file diff --git a/lib/features/email_verification/email_verification.dart b/lib/features/email_verification/email_verification.dart new file mode 100644 index 0000000..7595997 --- /dev/null +++ b/lib/features/email_verification/email_verification.dart @@ -0,0 +1,79 @@ +import 'package:app_links/app_links.dart'; +import 'package:clean_stream_laundry_app/features/email_verification/controller.dart'; +import 'package:clean_stream_laundry_app/features/email_verification/widgets/resend_verification.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; + +class EmailVerificationPage extends StatefulWidget { + final AppLinks appLinks; + + const EmailVerificationPage({super.key, required this.appLinks}); + + @override + State createState() => _EmailVerificationPageState(); +} + +class _EmailVerificationPageState extends State { + late final EmailVerificationController _controller; + + @override + void initState() { + super.initState(); + _controller = EmailVerificationController( + appLinks: widget.appLinks, + context: context, + ); + _controller.init(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _refresh() { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + body: Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.email, size: 80, color: Colors.blueAccent), + const SizedBox(height: 24), + Text( + 'Please verify your email address', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + color: Theme.of(context).colorScheme.fontInverted, + ), + ), + const SizedBox(height: 16), + Text( + 'Check your inbox and click the verification link.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + const SizedBox(height: 24), + ResendVerificationWidget( + controller: _controller, + onStateChange: _refresh, + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/email_verification/widgets/resend_verification.dart b/lib/features/email_verification/widgets/resend_verification.dart new file mode 100644 index 0000000..db5f0cf --- /dev/null +++ b/lib/features/email_verification/widgets/resend_verification.dart @@ -0,0 +1,75 @@ +import 'package:clean_stream_laundry_app/features/email_verification/controller.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; + +class ResendVerificationWidget extends StatefulWidget { + final EmailVerificationController controller; + final VoidCallback onStateChange; + + const ResendVerificationWidget({ + super.key, + required this.controller, + required this.onStateChange, + }); + + @override + State createState() => + _ResendVerificationWidgetState(); +} + +class _ResendVerificationWidgetState extends State { + @override + Widget build(BuildContext context) { + if (widget.controller.isLoading) { + return const CircularProgressIndicator(); + } + + if (widget.controller.resent) { + return const Icon(Icons.check_circle, size: 40, color: Colors.green); + } + + if (widget.controller.lastResponse == AuthenticationResponses.failure) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, + height: 80, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Center( + child: Icon(Icons.close, color: Colors.white, size: 40), + ), + ), + const SizedBox(height: 16), + Text( + 'Please resend verification again at another time.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.fontPrimary, + ), + ), + ], + ); + } + + return InkWell( + onTap: () async { + await widget.controller.resendVerification(); + widget.onStateChange(); + }, + child: const Text( + 'Resend Verification', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.blue, + decoration: TextDecoration.underline, + ), + ), + ); + } +} \ No newline at end of file From 6cdabb6391d26d4cbe7d6f66498da67f1b9935d7 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Mar 2026 10:55:14 -0400 Subject: [PATCH 07/79] Adds tests for email_verification feature --- .../email_verification/controller_test.dart | 212 ++++++++++++++++ .../email_verification_test.dart | 201 +++++++++++++++ test/features/email_verification/mocks.dart | 29 +++ .../widgets/resend_verification_test.dart | 238 ++++++++++++++++++ 4 files changed, 680 insertions(+) create mode 100644 test/features/email_verification/controller_test.dart create mode 100644 test/features/email_verification/email_verification_test.dart create mode 100644 test/features/email_verification/mocks.dart create mode 100644 test/features/email_verification/widgets/resend_verification_test.dart diff --git a/test/features/email_verification/controller_test.dart b/test/features/email_verification/controller_test.dart new file mode 100644 index 0000000..5550252 --- /dev/null +++ b/test/features/email_verification/controller_test.dart @@ -0,0 +1,212 @@ +import 'dart:async'; +import 'package:clean_stream_laundry_app/features/email_verification/controller.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late StreamController authChangeController; + late FakeAppLinks fakeAppLinks; + + setUpAll(() { + registerFallbackValue(FakeAuthService()); + registerFallbackValue(FakeUri()); + }); + + setUp(() { + mockAuthService = MockAuthService(); + authChangeController = StreamController.broadcast(); + fakeAppLinks = FakeAppLinks(); + + GetIt.instance.registerSingleton(mockAuthService); + + when(() => mockAuthService.onAuthChange) + .thenAnswer((_) => authChangeController.stream); + when(() => mockAuthService.isEmailVerified()).thenReturn(false); + }); + + tearDown(() { + authChangeController.close(); + fakeAppLinks.dispose(); + GetIt.instance.reset(); + }); + + /// minimal GoRouter - controller needs a context + Widget buildWithRouter({ + required void Function(EmailVerificationController) onControllerReady, + }) { + return MaterialApp.router( + routerConfig: GoRouter( + initialLocation: '/email-verification', + routes: [ + GoRoute( + path: '/email-verification', + builder: (context, state) { + final controller = EmailVerificationController( + appLinks: fakeAppLinks, + context: context, + ); + onControllerReady(controller); + controller.init(); + return const SizedBox(); + }, + ), + GoRoute( + path: '/homePage', + builder: (_, __) => const Scaffold(body: Text('Home')), + ), + ], + ), + ); + } + + group('resendVerification', () { + testWidgets('calls auth service resendVerification', (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.success); + + late EmailVerificationController controller; + await tester.pumpWidget( + buildWithRouter(onControllerReady: (c) => controller = c), + ); + + await controller.resendVerification(); + + verify(() => mockAuthService.resendVerification()).called(1); + }); + + testWidgets('sets resent to true on success', (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.success); + + late EmailVerificationController controller; + await tester.pumpWidget( + buildWithRouter(onControllerReady: (c) => controller = c), + ); + + await controller.resendVerification(); + + expect(controller.resent, isTrue); + expect(controller.lastResponse, AuthenticationResponses.success); + }); + + testWidgets('sets lastResponse on failure, resent stays false', + (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.failure); + + late EmailVerificationController controller; + await tester.pumpWidget( + buildWithRouter(onControllerReady: (c) => controller = c), + ); + + await controller.resendVerification(); + + expect(controller.resent, isFalse); + expect(controller.lastResponse, AuthenticationResponses.failure); + }); + + testWidgets('does not call service again when already resent', + (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.success); + + late EmailVerificationController controller; + await tester.pumpWidget( + buildWithRouter(onControllerReady: (c) => controller = c), + ); + + await controller.resendVerification(); + await controller.resendVerification(); + + verify(() => mockAuthService.resendVerification()).called(1); + }); + }); + + group('Auth change listener', () { + testWidgets('calls isEmailVerified when authState changes', + (tester) async { + when(() => mockAuthService.isEmailVerified()).thenReturn(true); + + await tester.pumpWidget(buildWithRouter(onControllerReady: (_) {})); + await tester.pumpAndSettle(); + + authChangeController.add(true); + await tester.pumpAndSettle(); + + + verify(() => mockAuthService.isEmailVerified()).called(1); + }); + + testWidgets('does not navigate when logged in but email not verified', + (tester) async { + when(() => mockAuthService.isEmailVerified()).thenReturn(false); + + await tester.pumpWidget(buildWithRouter(onControllerReady: (_) {})); + await tester.pumpAndSettle(); + + authChangeController.add(true); + await tester.pumpAndSettle(); + + expect(find.text('Home'), findsNothing); + }); + + testWidgets('does not navigate when auth emits false', (tester) async { + when(() => mockAuthService.isEmailVerified()).thenReturn(true); + + await tester.pumpWidget(buildWithRouter(onControllerReady: (_) {})); + await tester.pumpAndSettle(); + + authChangeController.add(false); + await tester.pumpAndSettle(); + + expect(find.text('Home'), findsNothing); + }); + }); + + group('Deep link handling', () { + testWidgets('calls getSessionFromURI on valid deep link', (tester) async { + when(() => mockAuthService.getSessionFromURI(any())) + .thenAnswer((_) async {}); + + await tester.pumpWidget(buildWithRouter(onControllerReady: (_) {})); + await tester.pumpAndSettle(); + + fakeAppLinks.emit(Uri.parse('clean-stream://email-verification')); + await tester.pumpAndSettle(); + await tester.pump(); + await tester.pumpAndSettle(); + + verify(() => mockAuthService.getSessionFromURI(any())).called(1); + }); + + testWidgets('ignores URIs with wrong host', (tester) async { + await tester.pumpWidget(buildWithRouter(onControllerReady: (_) {})); + await tester.pumpAndSettle(); + + fakeAppLinks.emit(Uri.parse('clean-stream://wrong-host')); + await tester.pumpAndSettle(); + + verifyNever(() => mockAuthService.getSessionFromURI(any())); + expect(find.text('Home'), findsNothing); + }); + }); + + + group('Lifecycle', () { + testWidgets('dispose cancels subscriptions without error', (tester) async { + late EmailVerificationController controller; + await tester.pumpWidget( + buildWithRouter(onControllerReady: (c) => controller = c), + ); + + expect(() => controller.dispose(), returnsNormally); + }); + }); +} \ No newline at end of file diff --git a/test/features/email_verification/email_verification_test.dart b/test/features/email_verification/email_verification_test.dart new file mode 100644 index 0000000..e452add --- /dev/null +++ b/test/features/email_verification/email_verification_test.dart @@ -0,0 +1,201 @@ +import 'dart:async'; +import 'package:clean_stream_laundry_app/features/email_verification/email_verification.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/pages/home_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late StreamController authChangeController; + late MockMachineService mockMachineService; + late MockLocationService mockLocationService; + late MockProfileService mockProfileService; + late FakeAppLinks fakeAppLinks; + + setUpAll(() { + registerFallbackValue(FakeAuthService()); + registerFallbackValue(FakeUri()); + }); + + setUp(() { + mockAuthService = MockAuthService(); + authChangeController = StreamController.broadcast(); + mockMachineService = MockMachineService(); + mockLocationService = MockLocationService(); + mockProfileService = MockProfileService(); + fakeAppLinks = FakeAppLinks(); + + GetIt.instance.registerSingleton(mockAuthService); + GetIt.instance.registerSingleton(mockMachineService); + GetIt.instance.registerSingleton(mockLocationService); + GetIt.instance.registerSingleton(mockProfileService); + + when(() => mockAuthService.onAuthChange) + .thenAnswer((_) => authChangeController.stream); + when(() => mockAuthService.isEmailVerified()).thenReturn(false); + when(() => mockLocationService.getLocations()) + .thenAnswer((_) async => >[]); + }); + + tearDown(() { + authChangeController.close(); + fakeAppLinks.dispose(); + GetIt.instance.reset(); + }); + + Widget createTestWidget() { + return MaterialApp.router( + routerConfig: GoRouter( + initialLocation: '/email-verification', + routes: [ + GoRoute( + path: '/email-verification', + builder: (context, state) => + EmailVerificationPage(appLinks: fakeAppLinks), + ), + GoRoute( + path: '/homePage', + builder: (context, state) => HomePage(), + ), + GoRoute( + path: '/scanner', + builder: (context, state) => + const Scaffold(body: Text('Scanner Page')), + ), + ], + ), + ); + } + + group('Static UI', () { + testWidgets('displays all required UI elements', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.email), findsOneWidget); + expect(find.text('Please verify your email address'), findsOneWidget); + expect( + find.text('Check your inbox and click the verification link.'), + findsOneWidget, + ); + expect(find.text('Resend Verification'), findsOneWidget); + }); + + testWidgets('email icon has correct styling', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final icon = tester.widget(find.byIcon(Icons.email)); + expect(icon.size, equals(80)); + expect(icon.color, equals(Colors.blueAccent)); + }); + + testWidgets('text uses center alignment', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final titleText = tester.widget( + find.text('Please verify your email address'), + ); + final descText = tester.widget( + find.text('Check your inbox and click the verification link.'), + ); + + expect(titleText.textAlign, equals(TextAlign.center)); + expect(descText.textAlign, equals(TextAlign.center)); + }); + + testWidgets('uses theme surface color as scaffold background', + (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final scaffold = tester.widget(find.byType(Scaffold)); + expect(scaffold.backgroundColor, isNotNull); + }); + }); + + group('Navigation', () { + testWidgets('verifies email verification check called', (tester) async { + when(() => mockAuthService.isEmailVerified()).thenReturn(true); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + authChangeController.add(true); + await tester.pumpAndSettle(); + + verify(() => mockAuthService.isEmailVerified()).called(1); + }); + + testWidgets('stays on page when email not verified', (tester) async { + when(() => mockAuthService.isEmailVerified()).thenReturn(false); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + authChangeController.add(true); + await tester.pumpAndSettle(); + + expect(find.text('Please verify your email address'), findsOneWidget); + }); + + testWidgets('stays on page when auth emits false (logout)', (tester) async { + when(() => mockAuthService.isEmailVerified()).thenReturn(true); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + authChangeController.add(false); + await tester.pumpAndSettle(); + + expect(find.text('Please verify your email address'), findsOneWidget); + }); + + testWidgets('verifies deeplink gets session', (tester) async { + when(() => mockAuthService.getSessionFromURI(any())) + .thenAnswer((_) async {}); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + fakeAppLinks.emit(Uri.parse('clean-stream://email-verification')); + await tester.pumpAndSettle(); + + verify(() => mockAuthService.getSessionFromURI(any())).called(1); + }); + + testWidgets('ignores deep link with wrong host', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + fakeAppLinks.emit(Uri.parse('clean-stream://other-host')); + await tester.pumpAndSettle(); + + expect(find.text('Please verify your email address'), findsOneWidget); + }); + }); + + group('Lifecycle', () { + testWidgets('properly disposes controller on navigation away', + (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final context = + tester.element(find.byType(EmailVerificationPage)); + GoRouter.of(context).go('/scanner'); + await tester.pumpAndSettle(); + + expect(find.byType(EmailVerificationPage), findsNothing); + }); + }); +} \ No newline at end of file diff --git a/test/features/email_verification/mocks.dart b/test/features/email_verification/mocks.dart new file mode 100644 index 0000000..63df300 --- /dev/null +++ b/test/features/email_verification/mocks.dart @@ -0,0 +1,29 @@ +import 'dart:async'; +import 'package:app_links/app_links.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthService extends Mock implements AuthService {} +class MockLocationService extends Mock implements LocationService {} +class MockMachineService extends Mock implements MachineService {} +class MockProfileService extends Mock implements ProfileService {} +class FakeAuthService extends Fake implements AuthService {} +class FakeUri extends Fake implements Uri {} + +class FakeAppLinks implements AppLinks { + final StreamController _controller = + StreamController.broadcast(); + + void emit(Uri uri) => _controller.add(uri); + + void dispose() => _controller.close(); + + @override + Stream get uriLinkStream => _controller.stream; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} \ No newline at end of file diff --git a/test/features/email_verification/widgets/resend_verification_test.dart b/test/features/email_verification/widgets/resend_verification_test.dart new file mode 100644 index 0000000..dccff93 --- /dev/null +++ b/test/features/email_verification/widgets/resend_verification_test.dart @@ -0,0 +1,238 @@ +import 'package:clean_stream_laundry_app/features/email_verification/controller.dart'; +import 'package:clean_stream_laundry_app/features/email_verification/widgets/resend_verification.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import '../mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late FakeAppLinks fakeAppLinks; + + setUpAll(() { + registerFallbackValue(FakeAuthService()); + registerFallbackValue(FakeUri()); + }); + + setUp(() { + mockAuthService = MockAuthService(); + fakeAppLinks = FakeAppLinks(); + GetIt.instance.registerSingleton(mockAuthService); + + when(() => mockAuthService.onAuthChange) + .thenAnswer((_) => const Stream.empty()); + }); + + tearDown(() { + fakeAppLinks.dispose(); + GetIt.instance.reset(); + }); + + Future buildController( + WidgetTester tester) async { + late EmailVerificationController controller; + + await tester.pumpWidget( + MaterialApp( + home: Builder(builder: (context) { + controller = EmailVerificationController( + appLinks: fakeAppLinks, + context: context, + ); + return const SizedBox(); + }), + ), + ); + + return controller; + } + + Widget buildWidget({ + required EmailVerificationController controller, + VoidCallback? onStateChange, + }) { + return MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => ResendVerificationWidget( + controller: controller, + onStateChange: onStateChange ?? () => setState(() {}), + ), + ), + ), + ); + } + + group('Initial state', () { + testWidgets('shows Resend Verification link text', (tester) async { + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + + expect(find.text('Resend Verification'), findsOneWidget); + }); + + testWidgets('link text has correct styling', (tester) async { + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + + final text = tester.widget(find.text('Resend Verification')); + expect(text.style?.color, equals(Colors.blue)); + expect(text.style?.decoration, equals(TextDecoration.underline)); + }); + + testWidgets('link is wrapped in an InkWell', (tester) async { + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + + expect( + find.ancestor( + of: find.text('Resend Verification'), + matching: find.byType(InkWell), + ), + findsOneWidget, + ); + }); + }); + + group('Success state', () { + testWidgets('shows check_circle icon after successful resend', + (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resend Verification')); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.check_circle), findsOneWidget); + expect(find.text('Resend Verification'), findsNothing); + }); + + testWidgets('check_circle icon has correct styling', (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resend Verification')); + await tester.pumpAndSettle(); + + final icon = tester.widget(find.byIcon(Icons.check_circle)); + expect(icon.size, equals(40)); + expect(icon.color, equals(Colors.green)); + }); + + testWidgets('tapping check_circle does not trigger another resend', + (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resend Verification')); + await tester.pumpAndSettle(); + + verify(() => mockAuthService.resendVerification()).called(1); + + await tester.tap(find.byIcon(Icons.check_circle)); + await tester.pumpAndSettle(); + + verifyNever(() => mockAuthService.resendVerification()); + }); + }); + + group('Failure state', () { + testWidgets('shows close icon and error text on failure', (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.failure); + + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resend Verification')); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.close), findsOneWidget); + expect( + find.text('Please resend verification again at another time.'), + findsOneWidget, + ); + }); + + testWidgets('error container has red circular decoration', (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.failure); + + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resend Verification')); + await tester.pumpAndSettle(); + + final container = tester.widget( + find + .ancestor( + of: find.byIcon(Icons.close), + matching: find.byType(Container), + ) + .first, + ); + + final decoration = container.decoration as BoxDecoration; + expect(decoration.color, equals(Colors.red)); + expect(decoration.shape, equals(BoxShape.circle)); + }); + + testWidgets('does not trigger another resend after failure', (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.failure); + + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resend Verification')); + await tester.pumpAndSettle(); + + verify(() => mockAuthService.resendVerification()).called(1); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + verifyNever(() => mockAuthService.resendVerification()); + }); + }); + + group('InkWell interaction', () { + testWidgets('tapping InkWell calls resendVerification', (tester) async { + when(() => mockAuthService.resendVerification()) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); + + await tester.tap( + find.ancestor( + of: find.text('Resend Verification'), + matching: find.byType(InkWell), + ), + ); + await tester.pumpAndSettle(); + + verify(() => mockAuthService.resendVerification()).called(1); + }); + }); +} \ No newline at end of file From a9a7b71362d19ce11f82ae9d02822ed4b0a460fb Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Mar 2026 12:08:04 -0400 Subject: [PATCH 08/79] Separates home page feature --- lib/features/home/controller.dart | 143 ++++++++++++++++++ lib/features/home/home.dart | 96 ++++++++++++ .../home/widgets/availability_card.dart | 130 ++++++++++++++++ lib/features/home/widgets/header.dart | 85 +++++++++++ .../home/widgets/location_selector.dart | 93 ++++++++++++ lib/features/home/widgets/map.dart | 47 ++++++ 6 files changed, 594 insertions(+) create mode 100644 lib/features/home/controller.dart create mode 100644 lib/features/home/home.dart create mode 100644 lib/features/home/widgets/availability_card.dart create mode 100644 lib/features/home/widgets/header.dart create mode 100644 lib/features/home/widgets/location_selector.dart create mode 100644 lib/features/home/widgets/map.dart diff --git a/lib/features/home/controller.dart b/lib/features/home/controller.dart new file mode 100644 index 0000000..fa959d3 --- /dev/null +++ b/lib/features/home/controller.dart @@ -0,0 +1,143 @@ +import 'package:clean_stream_laundry_app/logic/parsing/location_parser.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/middleware/storage_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class HomePageController extends ChangeNotifier { + final AuthService authService = GetIt.instance(); + final ProfileService profileService = GetIt.instance(); + final LocationService locationService = GetIt.instance(); + final MachineService machineService = GetIt.instance(); + final LocationParser locationParser; + + HomePageController({LocationParser? locationParser}) + : locationParser = locationParser ?? LocationParser(); + + String? selectedName; + String? username; + Map? balance; + final Map locationID = {}; + final Map locationCoordinates = {}; + List> locations = []; + bool locationSelected = false; + int? locationIDSelected; + bool isLoading = true; + StorageService storage = StorageService(); + + void Function(LatLng coords, double zoom)? onZoom; + + Future init() async { + await Future.wait([_initStorage(), _loadUserData()]); + await _loadLocations(); + isLoading = false; + notifyListeners(); + } + + Future _initStorage() async { + await storage.init(); + final lastVal = await storage.getValue('lastSelectedLocation'); + selectedName = lastVal; + } + + Future _loadUserData() async { + final userId = authService.getCurrentUserId; + if (userId == null) return; + username = await profileService.getUserNameById(userId); + balance = await profileService.getUserBalanceById(userId); + } + + Future _loadLocations() async { + locations = await locationService.getLocations(); + + for (final location in locations) { + final address = location['Address']; + final id = location['id']; + final lat = location['Latitude']; + final lng = location['Longitude']; + + if (address != null && id != null) locationID[address] = id; + if (address != null && lat != null && lng != null) { + locationCoordinates[address] = LatLng(lat as double, lng as double); + } + } + + if (selectedName != null && locationID.containsKey(selectedName)) { + locationSelected = true; + locationIDSelected = locationID[selectedName!]; + } + } + + void selectLocation(String address) { + final id = locationID[address]; + if (id == null) return; + + selectedName = address; + locationSelected = true; + locationIDSelected = id; + storage.setValue('lastSelectedLocation', address); + notifyListeners(); + + if (locationCoordinates.containsKey(address)) { + onZoom?.call(locationCoordinates[address]!, 15.0); + } + } + + Future selectNearestLocation() async { + final nearest = await locationParser.getNearestLocation(locations); + if (nearest != null) { + selectLocation(nearest['Address'] as String); + } + } + + Future> getMachineCounts() async { + if (locationIDSelected == null) return [0, 0, 0, 0]; + final id = locationIDSelected.toString(); + return await Future.wait([ + machineService.getWasherCountByLocation(id), + machineService.getIdleWasherCountByLocation(id), + machineService.getDryerCountByLocation(id), + machineService.getIdleDryerCountByLocation(id), + ]); + } + + Future openDirectionsFromAddress(String? address) async { + if (address == null) return; + + final encodedAddress = Uri.encodeComponent(address); + Uri uri; + + if (kIsWeb) { + uri = Uri.parse( + 'https://www.google.com/maps/dir/?api=1&destination=$encodedAddress', + ); + } else { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + uri = Uri.parse('google.navigation:q=$encodedAddress'); + break; + case TargetPlatform.iOS: + uri = Uri.parse( + 'http://maps.apple.com/?daddr=$encodedAddress&dirflg=d', + ); + break; + default: + uri = Uri.parse( + 'https://www.google.com/maps/dir/?api=1&destination=$encodedAddress', + ); + } + } + + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + void disposeController() {} +} \ No newline at end of file diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart new file mode 100644 index 0000000..e869ed5 --- /dev/null +++ b/lib/features/home/home.dart @@ -0,0 +1,96 @@ +import 'package:clean_stream_laundry_app/features/home/controller.dart'; +import 'package:clean_stream_laundry_app/features/home/widgets/availability_card.dart'; +import 'package:clean_stream_laundry_app/features/home/widgets/map.dart'; +import 'package:clean_stream_laundry_app/features/home/widgets/location_selector.dart'; +import 'package:clean_stream_laundry_app/features/home/widgets/header.dart'; +import 'package:clean_stream_laundry_app/widgets/base_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + static const pageKey = Key('home_page'); + + @override + HomePageState createState() => HomePageState(); +} + +class HomePageState extends State { + late final HomePageController _controller; + late final MapController _mapController; + + @override + void initState() { + super.initState(); + _mapController = MapController(); + _controller = HomePageController(); + _controller.onZoom = (coords, zoom) => _mapController.move(coords, zoom); + _controller.init().catchError((e) => debugPrint('HomePage init error: $e')); + _controller.addListener(() { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _controller.disposeController(); + _controller.dispose(); + _mapController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_controller.isLoading) { + return BasePage( + key: HomePage.pageKey, + body: const Center(child: CircularProgressIndicator()), + ); + } + + return BasePage( + key: HomePage.pageKey, + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 2.0), + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Header( + controller: _controller, + onNearestLocationTap: _controller.selectNearestLocation, + ), + const SizedBox(height: 10), + LocationMap( + locations: _controller.locations, + mapController: _mapController, + ), + LocationSelector( + controller: _controller, + onGetDirections: () async { + if (_controller.selectedName != null) { + await _controller.openDirectionsFromAddress( + _controller.selectedName, + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Please select a location to get directions!', + ), + ), + ); + } + }, + ), + const SizedBox(height: 14), + if (_controller.locationSelected) + AvailabilityCard(controller: _controller), + const SizedBox(height: 12), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/home/widgets/availability_card.dart b/lib/features/home/widgets/availability_card.dart new file mode 100644 index 0000000..14334ee --- /dev/null +++ b/lib/features/home/widgets/availability_card.dart @@ -0,0 +1,130 @@ +import 'package:clean_stream_laundry_app/features/home/controller.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; + +class AvailabilityCard extends StatelessWidget { + final HomePageController controller; + + const AvailabilityCard({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + key: ValueKey(controller.locationIDSelected), + future: controller.getMachineCounts(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } + + final totalWashers = snapshot.data![0]; + final idleWashers = snapshot.data![1]; + final totalDryers = snapshot.data![2]; + final idleDryers = snapshot.data![3]; + + return Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: Colors.blue, width: 3), + borderRadius: BorderRadius.circular(14), + color: Colors.transparent, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 45, + alignment: Alignment.center, + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Colors.blue, width: 2), + ), + ), + child: Text( + 'Availability', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ), + SizedBox( + height: 80, + child: Row( + children: [ + Expanded( + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + '$idleWashers/$totalWashers Washers', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .fontSecondary, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.local_laundry_service, + color: Colors.blue, + size: 36, + ), + ], + ), + ), + ), + Container(width: 2, color: Colors.blue), + Expanded( + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + '$idleDryers/$totalDryers Dryers', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .fontSecondary, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.local_laundry_service, + color: Colors.blue, + size: 36, + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/features/home/widgets/header.dart b/lib/features/home/widgets/header.dart new file mode 100644 index 0000000..7d294fb --- /dev/null +++ b/lib/features/home/widgets/header.dart @@ -0,0 +1,85 @@ +import 'package:clean_stream_laundry_app/features/home/controller.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class Header extends StatelessWidget { + final HomePageController controller; + final VoidCallback onNearestLocationTap; + + const Header({ + super.key, + required this.controller, + required this.onNearestLocationTap, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text( + controller.username == null + ? 'Welcome!' + : 'Welcome ${controller.username}!', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 28, + color: Theme.of(context).colorScheme.fontInverted, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + 'Current balance: \$' + '${controller.balance?["balance"] != null ? (controller.balance!["balance"] as num).toStringAsFixed(2) : 'Loading...'}', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: Theme.of(context).colorScheme.fontInverted, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 12), + InkWell( + onTap: onNearestLocationTap, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Nearest Location', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: + Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 6), + SvgPicture.asset( + 'assets/locationPin.svg', + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary, + BlendMode.srcIn, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/features/home/widgets/location_selector.dart b/lib/features/home/widgets/location_selector.dart new file mode 100644 index 0000000..50771ff --- /dev/null +++ b/lib/features/home/widgets/location_selector.dart @@ -0,0 +1,93 @@ +import 'package:clean_stream_laundry_app/features/home/controller.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; + +class LocationSelector extends StatelessWidget { + final HomePageController controller; + final VoidCallback onGetDirections; + + const LocationSelector({ + super.key, + required this.controller, + required this.onGetDirections, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(top: 12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.grey.shade400, width: 1), + color: Theme.of(context).colorScheme.cardSecondary, + ), + child: Row( + children: [ + const Icon(Icons.location_on, color: Colors.blue, size: 24), + const SizedBox(width: 8), + Expanded( + child: GestureDetector( + onTap: () => _showLocationPicker(context), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + controller.selectedName ?? 'Select Location', + style: TextStyle( + fontSize: 16, + color: controller.selectedName == null + ? Colors.grey + : Theme.of(context).colorScheme.fontInverted, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + IconButton( + onPressed: onGetDirections, + icon: Icon( + Icons.navigation, + color: Theme.of(context).primaryColor, + size: 24, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ); + } + + void _showLocationPicker(BuildContext context) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 12), + itemCount: controller.locations.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, index) { + final item = controller.locations[index]; + return ListTile( + title: Text( + item['Address'], + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.fontInverted, + ), + ), + onTap: () { + controller.selectLocation(item['Address']); + Navigator.pop(context); + }, + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/home/widgets/map.dart b/lib/features/home/widgets/map.dart new file mode 100644 index 0000000..72c57e0 --- /dev/null +++ b/lib/features/home/widgets/map.dart @@ -0,0 +1,47 @@ +import 'package:clean_stream_laundry_app/logic/parsing/location_parser.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +class LocationMap extends StatelessWidget { + final List> locations; + final MapController mapController; + + const LocationMap({ + super.key, + required this.locations, + required this.mapController, + }); + + @override + Widget build(BuildContext context) { + final markers = LocationParser.parseLocations(locations); + + return Container( + height: 300, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.grey.shade400, width: 1), + ), + clipBehavior: Clip.antiAlias, + child: FlutterMap( + mapController: mapController, + options: const MapOptions( + initialCenter: LatLng(40.273502, -86.126976), + initialZoom: 7.2, + keepAlive: true, + maxZoom: 15, + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'https://cleanstreamlaundry.com/', + tileProvider: NetworkTileProvider(), + ), + MarkerLayer(markers: markers), + ], + ), + ); + } +} \ No newline at end of file From 0a0a38e73b0cf123d09732a0360c561d208878a3 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Mar 2026 12:25:34 -0400 Subject: [PATCH 09/79] Adds tests for home feature --- test/features/home/controller_test.dart | 262 ++++++++++++++++ test/features/home/home_test.dart | 286 ++++++++++++++++++ test/features/home/mocks.dart | 12 + .../home/widgets/availability_card_test.dart | 162 ++++++++++ test/features/home/widgets/header_test.dart | 153 ++++++++++ .../home/widgets/location_selector_test.dart | 165 ++++++++++ 6 files changed, 1040 insertions(+) create mode 100644 test/features/home/controller_test.dart create mode 100644 test/features/home/home_test.dart create mode 100644 test/features/home/mocks.dart create mode 100644 test/features/home/widgets/availability_card_test.dart create mode 100644 test/features/home/widgets/header_test.dart create mode 100644 test/features/home/widgets/location_selector_test.dart diff --git a/test/features/home/controller_test.dart b/test/features/home/controller_test.dart new file mode 100644 index 0000000..4a47c70 --- /dev/null +++ b/test/features/home/controller_test.dart @@ -0,0 +1,262 @@ +import 'package:clean_stream_laundry_app/features/home/controller.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late MockLocationService mockLocationService; + late MockMachineService mockMachineService; + late MockProfileService mockProfileService; + late MockLocationParser mockLocationParser; + + final testLocations = [ + {'id': 1, 'Address': '123 Main St', 'Latitude': 40.0, 'Longitude': -86.0}, + {'id': 2, 'Address': '456 Oak Ave', 'Latitude': 40.5, 'Longitude': -86.5}, + ]; + + setUp(() { + mockAuthService = MockAuthService(); + mockLocationService = MockLocationService(); + mockMachineService = MockMachineService(); + mockProfileService = MockProfileService(); + mockLocationParser = MockLocationParser(); + + GetIt.instance.reset(); + GetIt.instance.registerSingleton(mockAuthService); + GetIt.instance.registerSingleton(mockLocationService); + GetIt.instance.registerSingleton(mockMachineService); + GetIt.instance.registerSingleton(mockProfileService); + + SharedPreferences.setMockInitialValues({}); + + when(() => mockAuthService.getCurrentUserId).thenReturn(null); + when(() => mockLocationService.getLocations()) + .thenAnswer((_) async => testLocations); + }); + + tearDown(() => GetIt.instance.reset()); + + HomePageController buildController() => + HomePageController(locationParser: mockLocationParser); + + group('init', () { + test('sets isLoading to false after init', () async { + final controller = buildController(); + + expect(controller.isLoading, isTrue); + + await controller.init(); + + expect(controller.isLoading, isFalse); + }); + + test('populates locations from service', () async { + final controller = buildController(); + + await controller.init(); + + expect(controller.locations, testLocations); + }); + + test('populates locationID map', () async { + final controller = buildController(); + + await controller.init(); + + expect(controller.locationID['123 Main St'], equals(1)); + expect(controller.locationID['456 Oak Ave'], equals(2)); + }); + + test('populates locationCoordinates map', () async { + final controller = buildController(); + + await controller.init(); + + expect(controller.locationCoordinates.containsKey('123 Main St'), isTrue); + expect(controller.locationCoordinates.containsKey('456 Oak Ave'), isTrue); + }); + + test('does not call profile service when userId is null', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn(null); + final controller = buildController(); + + await controller.init(); + + verifyNever(() => mockProfileService.getUserNameById(any())); + verifyNever(() => mockProfileService.getUserBalanceById(any())); + }); + + test('loads username and balance when userId is set', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user-1'); + when(() => mockProfileService.getUserNameById('user-1')) + .thenAnswer((_) async => 'John Doe'); + when(() => mockProfileService.getUserBalanceById('user-1')) + .thenAnswer((_) async => {'balance': 12.50}); + + final controller = buildController(); + await controller.init(); + + expect(controller.username, 'John Doe'); + expect(controller.balance?['balance'], 12.50); + }); + + test('restores last selected location from storage', () async { + SharedPreferences.setMockInitialValues({ + 'lastSelectedLocation': '123 Main St', + }); + + final controller = buildController(); + await controller.init(); + + expect(controller.selectedName, '123 Main St'); + expect(controller.locationSelected, isTrue); + expect(controller.locationIDSelected, 1); + }); + + test('does not restore location if stored address not in locations', + () async { + SharedPreferences.setMockInitialValues({ + 'lastSelectedLocation': 'Unknown Address', + }); + + final controller = buildController(); + await controller.init(); + + expect(controller.locationSelected, isFalse); + expect(controller.locationIDSelected, isNull); + }); + }); + + group('selectLocation', () { + test('sets selectedName, locationSelected, locationIDSelected', () async { + final controller = buildController(); + await controller.init(); + + controller.selectLocation('123 Main St'); + + expect(controller.selectedName, '123 Main St'); + expect(controller.locationSelected, isTrue); + expect(controller.locationIDSelected, 1); + }); + + test('does nothing for unknown address', () async { + final controller = buildController(); + await controller.init(); + + controller.selectLocation('Unknown Address'); + + expect(controller.locationSelected, isFalse); + expect(controller.locationIDSelected, isNull); + }); + + test('saves selection to storage', () async { + final controller = buildController(); + await controller.init(); + + controller.selectLocation('123 Main St'); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('lastSelectedLocation'), '123 Main St'); + }); + + test('calls onZoom when coordinates exist', () async { + final controller = buildController(); + await controller.init(); + + LatLng? zoomedTo; + double? zoomedZoom; + controller.onZoom = (coords, zoom) { + zoomedTo = coords; + zoomedZoom = zoom; + }; + + controller.selectLocation('123 Main St'); + + expect(zoomedTo, isNotNull); + expect(zoomedZoom, equals(15.0)); + }); + }); + + group('selectNearestLocation', () { + test('selects the nearest location when one is found', () async { + when(() => mockLocationParser.getNearestLocation(any())) + .thenAnswer((_) async => testLocations.first); + + final controller = buildController(); + await controller.init(); + + await controller.selectNearestLocation(); + + expect(controller.selectedName, '123 Main St'); + expect(controller.locationSelected, isTrue); + }); + + test('does nothing when no nearest location found', () async { + when(() => mockLocationParser.getNearestLocation(any())) + .thenAnswer((_) async => null); + + final controller = buildController(); + await controller.init(); + + await controller.selectNearestLocation(); + + expect(controller.locationSelected, isFalse); + }); + }); + + + group('getMachineCounts', () { + setUp(() { + when(() => mockMachineService.getWasherCountByLocation(any())) + .thenAnswer((_) async => 5); + when(() => mockMachineService.getIdleWasherCountByLocation(any())) + .thenAnswer((_) async => 3); + when(() => mockMachineService.getDryerCountByLocation(any())) + .thenAnswer((_) async => 4); + when(() => mockMachineService.getIdleDryerCountByLocation(any())) + .thenAnswer((_) async => 2); + }); + + test('returns [0,0,0,0] when no location selected', () async { + final controller = buildController(); + await controller.init(); + + final counts = await controller.getMachineCounts(); + + expect(counts, [0, 0, 0, 0]); + }); + + test('returns machine counts for selected location', () async { + final controller = buildController(); + await controller.init(); + controller.selectLocation('123 Main St'); + + final counts = await controller.getMachineCounts(); + + expect(counts, [5, 3, 4, 2]); + }); + + test('passes correct locationId string to each service call', () async { + final controller = buildController(); + await controller.init(); + controller.selectLocation('123 Main St'); // id = 1 + + await controller.getMachineCounts(); + + verify(() => mockMachineService.getWasherCountByLocation('1')).called(1); + verify(() => mockMachineService.getIdleWasherCountByLocation('1')) + .called(1); + verify(() => mockMachineService.getDryerCountByLocation('1')).called(1); + verify(() => mockMachineService.getIdleDryerCountByLocation('1')) + .called(1); + }); + }); +} \ No newline at end of file diff --git a/test/features/home/home_test.dart b/test/features/home/home_test.dart new file mode 100644 index 0000000..b92e8aa --- /dev/null +++ b/test/features/home/home_test.dart @@ -0,0 +1,286 @@ +import 'package:clean_stream_laundry_app/features/home/home.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late MockLocationService mockLocationService; + late MockMachineService mockMachineService; + late MockProfileService mockProfileService; + + final defaultLocations = [ + {'id': 1, 'Address': '123 Main St'}, + {'id': 2, 'Address': '456 Oak Ave'}, + ]; + + setUp(() { + mockAuthService = MockAuthService(); + mockLocationService = MockLocationService(); + mockMachineService = MockMachineService(); + mockProfileService = MockProfileService(); + + GetIt.instance.reset(); + GetIt.instance.registerSingleton(mockAuthService); + GetIt.instance.registerSingleton(mockLocationService); + GetIt.instance.registerSingleton(mockMachineService); + GetIt.instance.registerSingleton(mockProfileService); + + SharedPreferences.setMockInitialValues({}); + + when(() => mockAuthService.getCurrentUserId).thenReturn(null); + when(() => mockLocationService.getLocations()) + .thenAnswer((_) async => defaultLocations); + }); + + tearDown(() => GetIt.instance.reset()); + + Widget createWidget() { + return MaterialApp.router( + routerConfig: GoRouter( + routes: [ + GoRoute(path: '/', builder: (_, __) => const HomePage()), + ], + ), + ); + } + + void mockMachineCounts( + String locationId, { + int washers = 5, + int idleWashers = 3, + int dryers = 4, + int idleDryers = 2, + }) { + when(() => mockMachineService.getWasherCountByLocation(locationId)) + .thenAnswer((_) async => washers); + when(() => mockMachineService.getIdleWasherCountByLocation(locationId)) + .thenAnswer((_) async => idleWashers); + when(() => mockMachineService.getDryerCountByLocation(locationId)) + .thenAnswer((_) async => dryers); + when(() => mockMachineService.getIdleDryerCountByLocation(locationId)) + .thenAnswer((_) async => idleDryers); + } + + group('Loading state', () { + testWidgets('shows loading indicator while fetching data', (tester) async { + when(() => mockLocationService.getLocations()).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 100)); + return defaultLocations; + }); + + await tester.pumpWidget(createWidget()); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.pumpAndSettle(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + }); + + group('Static UI', () { + testWidgets('shows welcome message without username', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Welcome!'), findsOneWidget); + }); + + testWidgets('shows welcome message with username when user loaded', + (tester) async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user-1'); + when(() => mockProfileService.getUserNameById('user-1')) + .thenAnswer((_) async => 'Jane'); + when(() => mockProfileService.getUserBalanceById('user-1')) + .thenAnswer((_) async => {'balance': 10.00}); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Welcome Jane!'), findsOneWidget); + }); + + testWidgets('shows balance loading text when balance is null', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.textContaining('Loading...'), findsOneWidget); + }); + + testWidgets('has SingleChildScrollView', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.byType(SingleChildScrollView), findsOneWidget); + }); + + testWidgets('displays Nearest Location button', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Nearest Location'), findsOneWidget); + }); + + testWidgets('displays Select Location text when no location chosen', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Select Location'), findsOneWidget); + }); + + testWidgets('displays location_on icon', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.location_on), findsOneWidget); + }); + + testWidgets('displays navigation icon button', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.byType(IconButton), findsOneWidget); + }); + + testWidgets('does not show availability card before location selected', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Availability'), findsNothing); + }); + }); + + group('Location selector', () { + testWidgets('shows locations in bottom sheet when tapped', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Select Location')); + await tester.pumpAndSettle(); + + expect(find.text('123 Main St'), findsOneWidget); + expect(find.text('456 Oak Ave'), findsOneWidget); + }); + + testWidgets('updates selected location after tapping a location', + (tester) async { + mockMachineCounts('1'); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Select Location')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('123 Main St')); + await tester.pumpAndSettle(); + + expect(find.text('123 Main St'), findsOneWidget); + }); + + testWidgets('shows availability card after selecting a location', + (tester) async { + mockMachineCounts('1'); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Select Location')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('123 Main St')); + await tester.pumpAndSettle(); + + expect(find.text('Availability'), findsOneWidget); + }); + + testWidgets('restores last selected location from storage', (tester) async { + SharedPreferences.setMockInitialValues({ + 'lastSelectedLocation': '123 Main St', + }); + mockMachineCounts('1'); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('123 Main St'), findsOneWidget); + }); + + testWidgets('shows snackbar when directions tapped with no location', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.navigation)); + await tester.pumpAndSettle(); + + expect( + find.text('Please select a location to get directions!'), + findsOneWidget, + ); + }); + }); + + group('Availability card', () { + testWidgets('shows washer and dryer counts after selecting location', + (tester) async { + mockMachineCounts('1', washers: 5, idleWashers: 3, dryers: 4, idleDryers: 2); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Select Location')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('123 Main St')); + await tester.pumpAndSettle(); + + expect(find.text('3/5 Washers'), findsOneWidget); + expect(find.text('2/4 Dryers'), findsOneWidget); + }); + }); + + group('Nearest location button', () { + testWidgets('nearest location button is tappable', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final button = find.ancestor( + of: find.text('Nearest Location'), + matching: find.byType(InkWell), + ); + + expect(button, findsOneWidget); + final inkWell = tester.widget(button); + expect(inkWell.onTap, isNotNull); + }); + + testWidgets('tapping nearest location calls getLocations', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final button = find.ancestor( + of: find.text('Nearest Location'), + matching: find.byType(InkWell), + ); + + await tester.tap(button); + await tester.pumpAndSettle(); + + verify(() => mockLocationService.getLocations()).called(1); + }); + }); +} \ No newline at end of file diff --git a/test/features/home/mocks.dart b/test/features/home/mocks.dart new file mode 100644 index 0000000..020e193 --- /dev/null +++ b/test/features/home/mocks.dart @@ -0,0 +1,12 @@ +import 'package:clean_stream_laundry_app/logic/parsing/location_parser.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthService extends Mock implements AuthService {} +class MockLocationService extends Mock implements LocationService {} +class MockMachineService extends Mock implements MachineService {} +class MockProfileService extends Mock implements ProfileService {} +class MockLocationParser extends Mock implements LocationParser {} \ No newline at end of file diff --git a/test/features/home/widgets/availability_card_test.dart b/test/features/home/widgets/availability_card_test.dart new file mode 100644 index 0000000..a4d92c6 --- /dev/null +++ b/test/features/home/widgets/availability_card_test.dart @@ -0,0 +1,162 @@ +import 'package:clean_stream_laundry_app/features/home/controller.dart'; +import 'package:clean_stream_laundry_app/features/home/widgets/availability_card.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late MockLocationService mockLocationService; + late MockMachineService mockMachineService; + late MockProfileService mockProfileService; + + setUp(() { + mockAuthService = MockAuthService(); + mockLocationService = MockLocationService(); + mockMachineService = MockMachineService(); + mockProfileService = MockProfileService(); + + GetIt.instance.reset(); + GetIt.instance.registerSingleton(mockAuthService); + GetIt.instance.registerSingleton(mockLocationService); + GetIt.instance.registerSingleton(mockMachineService); + GetIt.instance.registerSingleton(mockProfileService); + + SharedPreferences.setMockInitialValues({}); + + when(() => mockAuthService.getCurrentUserId).thenReturn(null); + when(() => mockLocationService.getLocations()).thenAnswer((_) async => [ + {'id': 1, 'Address': '123 Main St'}, + ]); + }); + + tearDown(() => GetIt.instance.reset()); + + void mockMachineCounts({ + int washers = 5, + int idleWashers = 3, + int dryers = 4, + int idleDryers = 2, + }) { + when(() => mockMachineService.getWasherCountByLocation(any())) + .thenAnswer((_) async => washers); + when(() => mockMachineService.getIdleWasherCountByLocation(any())) + .thenAnswer((_) async => idleWashers); + when(() => mockMachineService.getDryerCountByLocation(any())) + .thenAnswer((_) async => dryers); + when(() => mockMachineService.getIdleDryerCountByLocation(any())) + .thenAnswer((_) async => idleDryers); + } + + Widget buildWidget(HomePageController controller) { + return MaterialApp( + home: Scaffold( + body: AvailabilityCard(controller: controller), + ), + ); + } + + group('AvailabilityCard', () { + group('Loading state', () { + testWidgets('shows CircularProgressIndicator while loading', + (tester) async { + mockMachineCounts(); + + final controller = + HomePageController(locationParser: MockLocationParser()); + await controller.init(); + controller.selectLocation('123 Main St'); + + when(() => mockMachineService.getWasherCountByLocation(any())) + .thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 100)); + return 5; + }); + + await tester.pumpWidget(buildWidget(controller)); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.pumpAndSettle(); + }); + }); + + group('Loaded state', () { + testWidgets('displays Availability header', (tester) async { + mockMachineCounts(); + + final controller = + HomePageController(locationParser: MockLocationParser()); + await controller.init(); + controller.selectLocation('123 Main St'); + + await tester.pumpWidget(buildWidget(controller)); + await tester.pumpAndSettle(); + + expect(find.text('Availability'), findsOneWidget); + }); + + testWidgets('displays washer count text', (tester) async { + mockMachineCounts(washers: 5, idleWashers: 3); + + final controller = + HomePageController(locationParser: MockLocationParser()); + await controller.init(); + controller.selectLocation('123 Main St'); + + await tester.pumpWidget(buildWidget(controller)); + await tester.pumpAndSettle(); + + expect(find.text('3/5 Washers'), findsOneWidget); + }); + + testWidgets('displays dryer count text', (tester) async { + mockMachineCounts(dryers: 4, idleDryers: 2); + + final controller = + HomePageController(locationParser: MockLocationParser()); + await controller.init(); + controller.selectLocation('123 Main St'); + + await tester.pumpWidget(buildWidget(controller)); + await tester.pumpAndSettle(); + + expect(find.text('2/4 Dryers'), findsOneWidget); + }); + + testWidgets('displays laundry service icons', (tester) async { + mockMachineCounts(); + + final controller = + HomePageController(locationParser: MockLocationParser()); + await controller.init(); + controller.selectLocation('123 Main St'); + + await tester.pumpWidget(buildWidget(controller)); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.local_laundry_service), findsNWidgets(2)); + }); + + testWidgets('shows all zeros when no location selected', (tester) async { + final controller = + HomePageController(locationParser: MockLocationParser()); + await controller.init(); + + await tester.pumpWidget(buildWidget(controller)); + await tester.pumpAndSettle(); + + expect(find.text('0/0 Washers'), findsOneWidget); + expect(find.text('0/0 Dryers'), findsOneWidget); + }); + }); + }); +} \ No newline at end of file diff --git a/test/features/home/widgets/header_test.dart b/test/features/home/widgets/header_test.dart new file mode 100644 index 0000000..de2dfde --- /dev/null +++ b/test/features/home/widgets/header_test.dart @@ -0,0 +1,153 @@ +import 'package:clean_stream_laundry_app/features/home/controller.dart'; +import 'package:clean_stream_laundry_app/features/home/widgets/header.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late MockLocationService mockLocationService; + late MockMachineService mockMachineService; + late MockProfileService mockProfileService; + + setUp(() { + mockAuthService = MockAuthService(); + mockLocationService = MockLocationService(); + mockMachineService = MockMachineService(); + mockProfileService = MockProfileService(); + + GetIt.instance.reset(); + GetIt.instance.registerSingleton(mockAuthService); + GetIt.instance.registerSingleton(mockLocationService); + GetIt.instance.registerSingleton(mockMachineService); + GetIt.instance.registerSingleton(mockProfileService); + + SharedPreferences.setMockInitialValues({}); + + when(() => mockAuthService.getCurrentUserId).thenReturn(null); + when(() => mockLocationService.getLocations()) + .thenAnswer((_) async => >[]); + }); + + tearDown(() => GetIt.instance.reset()); + + Widget buildWidget({ + required HomePageController controller, + VoidCallback? onNearestLocationTap, + }) { + return MaterialApp( + home: Scaffold( + body: Header( + controller: controller, + onNearestLocationTap: onNearestLocationTap ?? () {}, + ), + ), + ); + } + + Future buildController() async { + final controller = HomePageController(locationParser: MockLocationParser()); + await controller.init(); + return controller; + } + + group('WelcomeHeader', () { + group('Welcome text', () { + testWidgets('shows generic welcome when username is null', (tester) async { + final controller = await buildController(); + await tester.pumpWidget(buildWidget(controller: controller)); + + expect(find.text('Welcome!'), findsOneWidget); + }); + + testWidgets('shows username in welcome text when set', (tester) async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user-1'); + when(() => mockProfileService.getUserNameById('user-1')) + .thenAnswer((_) async => 'Jane'); + when(() => mockProfileService.getUserBalanceById('user-1')) + .thenAnswer((_) async => {'balance': 10.0}); + + final controller = HomePageController(locationParser: MockLocationParser()); + await controller.init(); + + await tester.pumpWidget(buildWidget(controller: controller)); + + expect(find.text('Welcome Jane!'), findsOneWidget); + }); + }); + + group('Balance text', () { + testWidgets('shows Loading... when balance is null', (tester) async { + final controller = await buildController(); + await tester.pumpWidget(buildWidget(controller: controller)); + + expect(find.textContaining('Loading...'), findsOneWidget); + }); + + testWidgets('shows formatted balance when available', (tester) async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user-1'); + when(() => mockProfileService.getUserNameById('user-1')) + .thenAnswer((_) async => 'Jane'); + when(() => mockProfileService.getUserBalanceById('user-1')) + .thenAnswer((_) async => {'balance': 12.5}); + + final controller = HomePageController(locationParser: MockLocationParser()); + await controller.init(); + + await tester.pumpWidget(buildWidget(controller: controller)); + + expect(find.textContaining('12.50'), findsOneWidget); + }); + }); + + group('Nearest Location button', () { + testWidgets('displays Nearest Location text', (tester) async { + final controller = await buildController(); + await tester.pumpWidget(buildWidget(controller: controller)); + + expect(find.text('Nearest Location'), findsOneWidget); + }); + + testWidgets('is wrapped in InkWell', (tester) async { + final controller = await buildController(); + await tester.pumpWidget(buildWidget(controller: controller)); + + expect( + find.ancestor( + of: find.text('Nearest Location'), + matching: find.byType(InkWell), + ), + findsOneWidget, + ); + }); + + testWidgets('calls onNearestLocationTap when tapped', (tester) async { + var tapped = false; + final controller = await buildController(); + + await tester.pumpWidget( + buildWidget( + controller: controller, + onNearestLocationTap: () => tapped = true, + ), + ); + + await tester.tap( + find.ancestor( + of: find.text('Nearest Location'), + matching: find.byType(InkWell), + ), + ); + + expect(tapped, isTrue); + }); + }); + }); +} \ No newline at end of file diff --git a/test/features/home/widgets/location_selector_test.dart b/test/features/home/widgets/location_selector_test.dart new file mode 100644 index 0000000..bbfedd0 --- /dev/null +++ b/test/features/home/widgets/location_selector_test.dart @@ -0,0 +1,165 @@ +import 'package:clean_stream_laundry_app/features/home/controller.dart'; +import 'package:clean_stream_laundry_app/features/home/widgets/location_selector.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late MockLocationService mockLocationService; + late MockMachineService mockMachineService; + late MockProfileService mockProfileService; + + final testLocations = [ + {'id': 1, 'Address': '123 Main St'}, + {'id': 2, 'Address': '456 Oak Ave'}, + ]; + + setUp(() { + mockAuthService = MockAuthService(); + mockLocationService = MockLocationService(); + mockMachineService = MockMachineService(); + mockProfileService = MockProfileService(); + + GetIt.instance.reset(); + GetIt.instance.registerSingleton(mockAuthService); + GetIt.instance.registerSingleton(mockLocationService); + GetIt.instance.registerSingleton(mockMachineService); + GetIt.instance.registerSingleton(mockProfileService); + + SharedPreferences.setMockInitialValues({}); + + when(() => mockAuthService.getCurrentUserId).thenReturn(null); + when(() => mockLocationService.getLocations()) + .thenAnswer((_) async => testLocations); + }); + + tearDown(() => GetIt.instance.reset()); + + Future buildController() async { + final controller = HomePageController(locationParser: MockLocationParser()); + await controller.init(); + return controller; + } + + Widget buildWidget({ + required HomePageController controller, + VoidCallback? onGetDirections, + }) { + return MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => LocationSelector( + controller: controller, + onGetDirections: onGetDirections ?? () {}, + ), + ), + ), + ); + } + + group('LocationSelector', () { + group('Initial state', () { + testWidgets('shows Select Location placeholder', (tester) async { + final controller = await buildController(); + await tester.pumpWidget(buildWidget(controller: controller)); + + expect(find.text('Select Location'), findsOneWidget); + }); + + testWidgets('shows selected name when already set', (tester) async { + SharedPreferences.setMockInitialValues({ + 'lastSelectedLocation': '123 Main St', + }); + final controller = HomePageController(locationParser: MockLocationParser()); + await controller.init(); + + await tester.pumpWidget(buildWidget(controller: controller)); + + expect(find.text('123 Main St'), findsOneWidget); + }); + + testWidgets('displays location_on icon', (tester) async { + final controller = await buildController(); + await tester.pumpWidget(buildWidget(controller: controller)); + + expect(find.byIcon(Icons.location_on), findsOneWidget); + }); + + testWidgets('displays navigation icon button', (tester) async { + final controller = await buildController(); + await tester.pumpWidget(buildWidget(controller: controller)); + + expect(find.byIcon(Icons.navigation), findsOneWidget); + }); + }); + + group('Bottom sheet', () { + testWidgets('shows location list on tap', (tester) async { + final controller = await buildController(); + await tester.pumpWidget(buildWidget(controller: controller)); + + await tester.tap(find.text('Select Location')); + await tester.pumpAndSettle(); + + expect(find.text('123 Main St'), findsOneWidget); + expect(find.text('456 Oak Ave'), findsOneWidget); + }); + + testWidgets('closes bottom sheet after selecting a location', + (tester) async { + final controller = await buildController(); + await tester.pumpWidget(buildWidget(controller: controller)); + + await tester.tap(find.text('Select Location')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('123 Main St')); + await tester.pumpAndSettle(); + + expect(find.text('456 Oak Ave'), findsNothing); + }); + + testWidgets('calls controller.selectLocation when location tapped', + (tester) async { + final controller = await buildController(); + await tester.pumpWidget(buildWidget(controller: controller)); + + await tester.tap(find.text('Select Location')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('123 Main St')); + await tester.pumpAndSettle(); + + expect(controller.selectedName, '123 Main St'); + expect(controller.locationSelected, isTrue); + }); + }); + + group('Directions button', () { + testWidgets('calls onGetDirections when navigation icon tapped', + (tester) async { + var tapped = false; + final controller = await buildController(); + + await tester.pumpWidget( + buildWidget( + controller: controller, + onGetDirections: () => tapped = true, + ), + ); + + await tester.tap(find.byIcon(Icons.navigation)); + + expect(tapped, isTrue); + }); + }); + }); +} \ No newline at end of file From 02c256001b9c11d193ffbe163e4d9940adc3777b Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Mar 2026 12:29:54 -0400 Subject: [PATCH 10/79] Separates loading feature --- lib/features/loading/controller.dart | 81 ++++++++++++++++++++ lib/features/loading/loading.dart | 41 ++++++++++ lib/features/loading/widgets/error_view.dart | 46 +++++++++++ lib/features/loading/widgets/logo.dart | 33 ++++++++ 4 files changed, 201 insertions(+) create mode 100644 lib/features/loading/controller.dart create mode 100644 lib/features/loading/loading.dart create mode 100644 lib/features/loading/widgets/error_view.dart create mode 100644 lib/features/loading/widgets/logo.dart diff --git a/lib/features/loading/controller.dart b/lib/features/loading/controller.dart new file mode 100644 index 0000000..ee2ddbb --- /dev/null +++ b/lib/features/loading/controller.dart @@ -0,0 +1,81 @@ +import 'package:app_links/app_links.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; + +class LoadingPageController extends ChangeNotifier { + final AuthService authService = GetIt.instance(); + + String? error; + + Future init(BuildContext context) async { + await Future.wait([ + _automaticLogIn(context), + _coldStartRedirect(context), + ]); + } + + Future _automaticLogIn(BuildContext context) async { + await Future.delayed(Duration.zero); + + try { + if (await authService.isLoggedIn() == AuthenticationResponses.success) { + if (!context.mounted) return; + context.go('/homePage'); + } else { + if (!context.mounted) return; + context.go('/login'); + } + } catch (e) { + if (!context.mounted) return; + error = e.toString(); + notifyListeners(); + } + } + + Future _coldStartRedirect(BuildContext context) async { + try { + final AppLinks appLinks = AppLinks(); + final Uri? initialUri = await appLinks.getInitialAppLink(); + + if (initialUri == null) return; + + if (initialUri.scheme == 'clean-stream' && + initialUri.host == 'reset-protected') { + if (!context.mounted) return; + context.go('/reset-protected', extra: initialUri); + return; + } + + if (initialUri.scheme == 'clean-stream' && + initialUri.host == 'email-verification') { + if (!context.mounted) return; + context.go('/homePage'); + return; + } + + if (initialUri.host == 'change-email') { + if (!context.mounted) return; + context.go('/email-verification'); + return; + } + + if (initialUri.scheme == 'clean-stream' && + initialUri.host == 'oauth') { + await authService.getSessionFromURI(initialUri); + if (!context.mounted) return; + if (await authService.isLoggedIn() == AuthenticationResponses.success) { + context.go('/homePage'); + } else { + context.go('/login'); + } + } + } catch (e) { + // cold start redirect failures are silent — _automaticLogIn covers nav + } + } + + void disposeController() {} +} \ No newline at end of file diff --git a/lib/features/loading/loading.dart b/lib/features/loading/loading.dart new file mode 100644 index 0000000..ddbda73 --- /dev/null +++ b/lib/features/loading/loading.dart @@ -0,0 +1,41 @@ +import 'package:clean_stream_laundry_app/features/loading/controller.dart'; +import 'package:clean_stream_laundry_app/features/loading/widgets/error_view.dart'; +import 'package:clean_stream_laundry_app/features/loading/widgets/logo.dart'; +import 'package:flutter/material.dart'; + +class LoadingPage extends StatefulWidget { + const LoadingPage({super.key}); + + @override + State createState() => _LoadingPageState(); +} + +class _LoadingPageState extends State { + late final LoadingPageController _controller; + + @override + void initState() { + super.initState(); + _controller = LoadingPageController(); + _controller.init(context); + _controller.addListener(() { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _controller.disposeController(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: _controller.error != null + ? ErrorView(error: _controller.error!) + : const Logo(), + ); + } +} \ No newline at end of file diff --git a/lib/features/loading/widgets/error_view.dart b/lib/features/loading/widgets/error_view.dart new file mode 100644 index 0000000..25cabf3 --- /dev/null +++ b/lib/features/loading/widgets/error_view.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class ErrorView extends StatelessWidget { + final String error; + + const ErrorView({super.key, required this.error}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Colors.redAccent, size: 80), + const SizedBox(height: 20), + const Text( + 'Authentication Failed', + style: TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + Text( + error, + style: TextStyle( + color: Colors.redAccent.withValues(alpha: 0.8), + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 26), + ElevatedButton.icon( + onPressed: () => context.go('/login'), + icon: const Icon(Icons.login), + label: const Text('Return to Login'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/features/loading/widgets/logo.dart b/lib/features/loading/widgets/logo.dart new file mode 100644 index 0000000..a6ce0f0 --- /dev/null +++ b/lib/features/loading/widgets/logo.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class Logo extends StatefulWidget { + const Logo({super.key}); + + @override + State createState() => _LogoState(); +} + +class _LogoState extends State { + double begin = 0.95; + double end = 1.05; + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: begin, end: end), + duration: const Duration(seconds: 1), + curve: Curves.easeInOut, + builder: (context, scale, child) { + return Transform.scale(scale: scale, child: child); + }, + child: Image.asset('assets/Logo.png', height: 250), + onEnd: () { + setState(() { + final temp = begin; + begin = end; + end = temp; + }); + }, + ); + } +} \ No newline at end of file From 9eaa0b47324e2fff83171890b5e22651be3bf25d Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Mar 2026 12:48:14 -0400 Subject: [PATCH 11/79] Fix controller parameters --- lib/features/loading/controller.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/features/loading/controller.dart b/lib/features/loading/controller.dart index ee2ddbb..b582db7 100644 --- a/lib/features/loading/controller.dart +++ b/lib/features/loading/controller.dart @@ -6,7 +6,14 @@ import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; class LoadingPageController extends ChangeNotifier { - final AuthService authService = GetIt.instance(); + final AuthService authService; + final AppLinks appLinks; + + LoadingPageController({ + AuthService? authService, + AppLinks? appLinks, + }) : authService = authService ?? GetIt.instance(), + appLinks = appLinks ?? AppLinks(); String? error; @@ -73,7 +80,7 @@ class LoadingPageController extends ChangeNotifier { } } } catch (e) { - // cold start redirect failures are silent — _automaticLogIn covers nav + } } From f36e95ea6027939accbf93420d9cb445b463f027 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Mar 2026 15:06:44 -0400 Subject: [PATCH 12/79] Refactor loading --- lib/features/loading/controller.dart | 61 ++++++++++++++++------------ lib/features/loading/loading.dart | 12 ++++-- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/lib/features/loading/controller.dart b/lib/features/loading/controller.dart index b582db7..2bde365 100644 --- a/lib/features/loading/controller.dart +++ b/lib/features/loading/controller.dart @@ -1,9 +1,8 @@ import 'package:app_links/app_links.dart'; import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; class LoadingPageController extends ChangeNotifier { final AuthService authService; @@ -17,70 +16,78 @@ class LoadingPageController extends ChangeNotifier { String? error; - Future init(BuildContext context) async { + Future init({ + required void Function(String route, {Object? extra}) navigate, + required ValueGetter isMounted, + }) async { await Future.wait([ - _automaticLogIn(context), - _coldStartRedirect(context), + _automaticLogIn(navigate: navigate, isMounted: isMounted), + _coldStartRedirect(navigate: navigate, isMounted: isMounted), ]); } - Future _automaticLogIn(BuildContext context) async { + Future _automaticLogIn({ + required void Function(String route, {Object? extra}) navigate, + required ValueGetter isMounted, + }) async { await Future.delayed(Duration.zero); try { - if (await authService.isLoggedIn() == AuthenticationResponses.success) { - if (!context.mounted) return; - context.go('/homePage'); + final response = await authService.isLoggedIn(); + if (!isMounted()) return; + + if (response == AuthenticationResponses.success) { + navigate('/homePage'); } else { - if (!context.mounted) return; - context.go('/login'); + navigate('/login'); } } catch (e) { - if (!context.mounted) return; + if (!isMounted()) return; error = e.toString(); notifyListeners(); } } - Future _coldStartRedirect(BuildContext context) async { + Future _coldStartRedirect({ + required void Function(String route, {Object? extra}) navigate, + required ValueGetter isMounted, + }) async { try { - final AppLinks appLinks = AppLinks(); final Uri? initialUri = await appLinks.getInitialAppLink(); if (initialUri == null) return; if (initialUri.scheme == 'clean-stream' && initialUri.host == 'reset-protected') { - if (!context.mounted) return; - context.go('/reset-protected', extra: initialUri); + if (!isMounted()) return; + navigate('/reset-protected', extra: initialUri); return; } if (initialUri.scheme == 'clean-stream' && initialUri.host == 'email-verification') { - if (!context.mounted) return; - context.go('/homePage'); + if (!isMounted()) return; + navigate('/homePage'); return; } if (initialUri.host == 'change-email') { - if (!context.mounted) return; - context.go('/email-verification'); + if (!isMounted()) return; + navigate('/email-verification'); return; } - if (initialUri.scheme == 'clean-stream' && - initialUri.host == 'oauth') { + if (initialUri.scheme == 'clean-stream' && initialUri.host == 'oauth') { await authService.getSessionFromURI(initialUri); - if (!context.mounted) return; + if (!isMounted()) return; if (await authService.isLoggedIn() == AuthenticationResponses.success) { - context.go('/homePage'); + navigate('/homePage'); } else { - context.go('/login'); + navigate('/login'); } } - } catch (e) { - + } catch (_) { + // cold-start failures are silent — same as original } } diff --git a/lib/features/loading/loading.dart b/lib/features/loading/loading.dart index ddbda73..5dda432 100644 --- a/lib/features/loading/loading.dart +++ b/lib/features/loading/loading.dart @@ -2,9 +2,12 @@ import 'package:clean_stream_laundry_app/features/loading/controller.dart'; import 'package:clean_stream_laundry_app/features/loading/widgets/error_view.dart'; import 'package:clean_stream_laundry_app/features/loading/widgets/logo.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; class LoadingPage extends StatefulWidget { - const LoadingPage({super.key}); + const LoadingPage({super.key, this.controller}); + + final LoadingPageController? controller; @override State createState() => _LoadingPageState(); @@ -16,8 +19,11 @@ class _LoadingPageState extends State { @override void initState() { super.initState(); - _controller = LoadingPageController(); - _controller.init(context); + _controller = widget.controller ?? LoadingPageController(); + _controller.init( + navigate: (route, {extra}) => context.go(route, extra: extra), + isMounted: () => mounted, + ); _controller.addListener(() { if (mounted) setState(() {}); }); From a39683d8515f174bfd0519afca06db489e81af48 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Mar 2026 15:08:40 -0400 Subject: [PATCH 13/79] Adds tests for loading --- test/features/loading/controller_test.dart | 311 ++++++++++++++++++ test/features/loading/loading_test.dart | 101 ++++++ test/features/loading/mocks.dart | 7 + .../loading/widgets/error_view_test.dart | 76 +++++ test/features/loading/widgets/logo_test.dart | 64 ++++ 5 files changed, 559 insertions(+) create mode 100644 test/features/loading/controller_test.dart create mode 100644 test/features/loading/loading_test.dart create mode 100644 test/features/loading/mocks.dart create mode 100644 test/features/loading/widgets/error_view_test.dart create mode 100644 test/features/loading/widgets/logo_test.dart diff --git a/test/features/loading/controller_test.dart b/test/features/loading/controller_test.dart new file mode 100644 index 0000000..4d5db59 --- /dev/null +++ b/test/features/loading/controller_test.dart @@ -0,0 +1,311 @@ +import 'package:clean_stream_laundry_app/features/loading/controller.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'mocks.dart'; + +/// Convenience: run init() with a controllable mounted flag and collect routes. +Future> _runInit( + LoadingPageController controller, { + bool mounted = true, + }) async { + final routes = []; + await controller.init( + navigate: (route, {extra}) => routes.add(route), + isMounted: () => mounted, + ); + return routes; +} + +void main() { + late MockAuthService mockAuth; + late MockAppLinks mockAppLinks; + + setUpAll(() { + // Mocktail needs fallback values for any non-nullable custom types used + // with any() matchers. Uri is a Dart core type so no fallback needed. + registerFallbackValue(Uri()); + }); + + setUp(() { + mockAuth = MockAuthService(); + mockAppLinks = MockAppLinks(); + + // Default: no deep link + when(() => mockAppLinks.getInitialAppLink()).thenAnswer((_) async => null); + }); + + // ───────────────────────────────────────────── + // _automaticLogIn + // ───────────────────────────────────────────── + + group('_automaticLogIn', () { + test('navigates to /homePage when auth returns success', () async { + when(() => mockAuth.isLoggedIn()) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = LoadingPageController( + authService: mockAuth, + appLinks: mockAppLinks, + ); + + final routes = await _runInit(controller); + + expect(routes, contains('/homePage')); + }); + + test('navigates to /login when auth returns non-success', () async { + when(() => mockAuth.isLoggedIn()) + .thenAnswer((_) async => AuthenticationResponses.failure); + + final controller = LoadingPageController( + authService: mockAuth, + appLinks: mockAppLinks, + ); + + final routes = await _runInit(controller); + + expect(routes, contains('/login')); + }); + + test('sets error and notifies listeners when auth throws', () async { + when(() => mockAuth.isLoggedIn()).thenThrow(Exception('network error')); + + final controller = LoadingPageController( + authService: mockAuth, + appLinks: mockAppLinks, + ); + + bool notified = false; + controller.addListener(() => notified = true); + + await _runInit(controller); + + expect(controller.error, isNotNull); + expect(controller.error, contains('network error')); + expect(notified, isTrue); + }); + + test('does not navigate when isMounted returns false', () async { + when(() => mockAuth.isLoggedIn()) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = LoadingPageController( + authService: mockAuth, + appLinks: mockAppLinks, + ); + + final routes = await _runInit(controller, mounted: false); + + expect(routes, isEmpty); + }); + + test('does not set error when unmounted and auth throws', () async { + when(() => mockAuth.isLoggedIn()).thenThrow(Exception('oops')); + + final controller = LoadingPageController( + authService: mockAuth, + appLinks: mockAppLinks, + ); + + await _runInit(controller, mounted: false); + + expect(controller.error, isNull); + }); + }); + + // ───────────────────────────────────────────── + // _coldStartRedirect – no deep link + // ───────────────────────────────────────────── + + group('_coldStartRedirect with no deep link', () { + test('does not produce a cold-start navigation when initialUri is null', + () async { + when(() => mockAuth.isLoggedIn()) + .thenAnswer((_) async => AuthenticationResponses.success); + when(() => mockAppLinks.getInitialAppLink()).thenAnswer((_) async => null); + + final controller = LoadingPageController( + authService: mockAuth, + appLinks: mockAppLinks, + ); + + final routes = await _runInit(controller); + + // Only the auth navigation should fire + expect(routes, equals(['/homePage'])); + }); + }); + + // ───────────────────────────────────────────── + // _coldStartRedirect – reset-protected + // ───────────────────────────────────────────── + + group('_coldStartRedirect – reset-protected URI', () { + test('navigates to /reset-protected and passes URI as extra', () async { + final uri = Uri.parse('clean-stream://reset-protected?token=abc'); + when(() => mockAppLinks.getInitialAppLink()).thenAnswer((_) async => uri); + when(() => mockAuth.isLoggedIn()) + .thenAnswer((_) async => AuthenticationResponses.failure); + + final capturedExtras = []; + final routes = []; + + final controller = LoadingPageController( + authService: mockAuth, + appLinks: mockAppLinks, + ); + + await controller.init( + navigate: (route, {extra}) { + routes.add(route); + capturedExtras.add(extra); + }, + isMounted: () => true, + ); + + expect(routes, contains('/reset-protected')); + expect(capturedExtras, contains(uri)); + }); + }); + + // ───────────────────────────────────────────── + // _coldStartRedirect – email-verification + // ───────────────────────────────────────────── + + group('_coldStartRedirect – email-verification URI', () { + test('navigates to /homePage for email-verification deep link', () async { + final uri = Uri.parse('clean-stream://email-verification'); + when(() => mockAppLinks.getInitialAppLink()).thenAnswer((_) async => uri); + when(() => mockAuth.isLoggedIn()) + .thenAnswer((_) async => AuthenticationResponses.failure); + + final controller = LoadingPageController( + authService: mockAuth, + appLinks: mockAppLinks, + ); + + final routes = await _runInit(controller); + + expect(routes, contains('/homePage')); + }); + }); + + // ───────────────────────────────────────────── + // _coldStartRedirect – change-email + // ───────────────────────────────────────────── + + group('_coldStartRedirect – change-email URI', () { + test('navigates to /email-verification for change-email deep link', + () async { + final uri = Uri.parse('https://change-email'); + when(() => mockAppLinks.getInitialAppLink()).thenAnswer((_) async => uri); + when(() => mockAuth.isLoggedIn()) + .thenAnswer((_) async => AuthenticationResponses.failure); + + final controller = LoadingPageController( + authService: mockAuth, + appLinks: mockAppLinks, + ); + + final routes = await _runInit(controller); + + expect(routes, contains('/email-verification')); + }); + }); + + // ───────────────────────────────────────────── + // _coldStartRedirect – oauth + // ───────────────────────────────────────────── + + group('_coldStartRedirect – oauth URI', () { + test('calls getSessionFromURI and navigates to /homePage when logged in', + () async { + final uri = Uri.parse('clean-stream://oauth?code=xyz'); + when(() => mockAppLinks.getInitialAppLink()).thenAnswer((_) async => uri); + when(() => mockAuth.getSessionFromURI(uri)).thenAnswer((_) async {}); + when(() => mockAuth.isLoggedIn()) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = LoadingPageController( + authService: mockAuth, + appLinks: mockAppLinks, + ); + + final routes = await _runInit(controller); + + verify(() => mockAuth.getSessionFromURI(uri)).called(1); + expect(routes, contains('/homePage')); + }); + + test('navigates to /login after oauth when session is not valid', () async { + final uri = Uri.parse('clean-stream://oauth?code=xyz'); + when(() => mockAppLinks.getInitialAppLink()).thenAnswer((_) async => uri); + when(() => mockAuth.getSessionFromURI(uri)).thenAnswer((_) async {}); + when(() => mockAuth.isLoggedIn()) + .thenAnswer((_) async => AuthenticationResponses.failure); + + final controller = LoadingPageController( + authService: mockAuth, + appLinks: mockAppLinks, + ); + + final routes = await _runInit(controller); + + expect(routes, contains('/login')); + }); + + test('does not navigate oauth redirect when unmounted', () async { + final uri = Uri.parse('clean-stream://oauth?code=xyz'); + when(() => mockAppLinks.getInitialAppLink()).thenAnswer((_) async => uri); + when(() => mockAuth.getSessionFromURI(uri)).thenAnswer((_) async {}); + when(() => mockAuth.isLoggedIn()) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = LoadingPageController( + authService: mockAuth, + appLinks: mockAppLinks, + ); + + final routes = await _runInit(controller, mounted: false); + + expect(routes, isEmpty); + }); + }); + + // ───────────────────────────────────────────── + // _coldStartRedirect – swallows exceptions silently + // ───────────────────────────────────────────── + + group('_coldStartRedirect error handling', () { + test('swallows exceptions silently and does not expose error', () async { + when(() => mockAppLinks.getInitialAppLink()) + .thenThrow(Exception('link error')); + when(() => mockAuth.isLoggedIn()) + .thenAnswer((_) async => AuthenticationResponses.failure); + + final controller = LoadingPageController( + authService: mockAuth, + appLinks: mockAppLinks, + ); + + await expectLater(_runInit(controller), completes); + expect(controller.error, isNull); + }); + }); + + // ───────────────────────────────────────────── + // disposeController + // ───────────────────────────────────────────── + + group('disposeController', () { + test('can be called without throwing', () { + final controller = LoadingPageController( + authService: mockAuth, + appLinks: mockAppLinks, + ); + expect(() => controller.disposeController(), returnsNormally); + }); + }); +} \ No newline at end of file diff --git a/test/features/loading/loading_test.dart b/test/features/loading/loading_test.dart new file mode 100644 index 0000000..5390154 --- /dev/null +++ b/test/features/loading/loading_test.dart @@ -0,0 +1,101 @@ +import 'package:clean_stream_laundry_app/features/loading/controller.dart'; +import 'package:clean_stream_laundry_app/features/loading/loading.dart'; +import 'package:clean_stream_laundry_app/features/loading/widgets/error_view.dart'; +import 'package:clean_stream_laundry_app/features/loading/widgets/logo.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'mocks.dart'; + +// ───────────────────────────────────────────────────────────────────────────── +// Fake controller — overrides init() so the widget never triggers real auth +// ───────────────────────────────────────────────────────────────────────────── + +class _FakeController extends LoadingPageController { + _FakeController({String? error}) + : super( + authService: MockAuthService(), + appLinks: MockAppLinks(), + ) { + this.error = error; + } + + @override + Future init({ + required void Function(String route, {Object? extra}) navigate, + required ValueGetter isMounted, + }) async { + // No-op: state was already set in the constructor for the test scenario. + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helper: wrap a widget in a GoRouter so context.go is available +// ───────────────────────────────────────────────────────────────────────────── + +Widget _withRouter(Widget child) { + final router = GoRouter( + initialLocation: '/test', + routes: [ + GoRoute(path: '/test', builder: (_, __) => child), + GoRoute(path: '/login', builder: (_, __) => const Scaffold()), + GoRoute(path: '/homePage', builder: (_, __) => const Scaffold()), + ], + ); + return MaterialApp.router(routerConfig: router); +} + +void main() { + group('LoadingPage', () { + testWidgets('shows Logo when controller has no error', (tester) async { + final controller = _FakeController(); + + await tester.pumpWidget(_withRouter(LoadingPage(controller: controller))); + await tester.pump(); + + expect(find.byType(Logo), findsOneWidget); + expect(find.byType(ErrorView), findsNothing); + }); + + testWidgets('shows ErrorView when controller has an error', (tester) async { + final controller = _FakeController(error: 'Something went wrong'); + + await tester.pumpWidget(_withRouter(LoadingPage(controller: controller))); + await tester.pump(); + + expect(find.byType(ErrorView), findsOneWidget); + expect(find.byType(Logo), findsNothing); + }); + + testWidgets('rebuilds and shows ErrorView when error is set after init', + (tester) async { + final controller = _FakeController(); + + await tester.pumpWidget(_withRouter(LoadingPage(controller: controller))); + await tester.pump(); + + expect(find.byType(Logo), findsOneWidget); + + // Simulate the controller receiving an error post-init + controller.error = 'Late error'; + controller.notifyListeners(); + await tester.pump(); + + expect(find.byType(ErrorView), findsOneWidget); + expect(find.byType(Logo), findsNothing); + }); + + testWidgets('dispose does not throw when widget is removed', (tester) async { + final controller = _FakeController(); + + await tester.pumpWidget(_withRouter(LoadingPage(controller: controller))); + await tester.pump(); + + // Replace with an empty widget to trigger dispose + await tester.pumpWidget(const MaterialApp(home: Scaffold())); + + expect(tester.takeException(), isNull); + }); + }); +} \ No newline at end of file diff --git a/test/features/loading/mocks.dart b/test/features/loading/mocks.dart new file mode 100644 index 0000000..bb1da63 --- /dev/null +++ b/test/features/loading/mocks.dart @@ -0,0 +1,7 @@ +import 'package:app_links/app_links.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthService extends Mock implements AuthService {} + +class MockAppLinks extends Mock implements AppLinks {} \ No newline at end of file diff --git a/test/features/loading/widgets/error_view_test.dart b/test/features/loading/widgets/error_view_test.dart new file mode 100644 index 0000000..c8e5c7f --- /dev/null +++ b/test/features/loading/widgets/error_view_test.dart @@ -0,0 +1,76 @@ +import 'package:clean_stream_laundry_app/features/loading/widgets/error_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +Widget _buildErrorView(String error, {List? extraRoutes}) { + final router = GoRouter( + initialLocation: '/test', + routes: [ + GoRoute( + path: '/test', + builder: (_, __) => Scaffold(body: ErrorView(error: error)), + ), + GoRoute(path: '/login', builder: (_, __) => const Scaffold(key: Key('login'))), + ...?extraRoutes, + ], + ); + return MaterialApp.router(routerConfig: router); +} + +void main() { + group('ErrorView', () { + testWidgets('renders the error icon', (tester) async { + await tester.pumpWidget(_buildErrorView('Some error')); + + expect(find.byIcon(Icons.error_outline), findsOneWidget); + }); + + testWidgets('renders the "Authentication Failed" heading', (tester) async { + await tester.pumpWidget(_buildErrorView('Some error')); + + expect(find.text('Authentication Failed'), findsOneWidget); + }); + + testWidgets('renders the supplied error message', (tester) async { + const message = 'Network timeout occurred'; + await tester.pumpWidget(_buildErrorView(message)); + + expect(find.text(message), findsOneWidget); + }); + + testWidgets('renders the "Return to Login" button', (tester) async { + await tester.pumpWidget(_buildErrorView('err')); + + expect(find.text('Return to Login'), findsOneWidget); + expect(find.byIcon(Icons.login), findsOneWidget); + }); + + testWidgets('tapping the button navigates to /login', (tester) async { + await tester.pumpWidget(_buildErrorView('err')); + + await tester.tap(find.text('Return to Login')); + await tester.pumpAndSettle(); + + // After navigation, the login scaffold (with its Key) should be present + expect(find.byKey(const Key('login')), findsOneWidget); + }); + + testWidgets('renders different error messages correctly', (tester) async { + const message = 'Exception: Invalid credentials'; + await tester.pumpWidget(_buildErrorView(message)); + + expect(find.text(message), findsOneWidget); + }); + + testWidgets('long error messages do not overflow', (tester) async { + final longError = 'E' * 300; + await tester.pumpWidget(_buildErrorView(longError)); + + // pumpAndSettle ensures no overflow exceptions are thrown in frame render + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); + }); +} \ No newline at end of file diff --git a/test/features/loading/widgets/logo_test.dart b/test/features/loading/widgets/logo_test.dart new file mode 100644 index 0000000..0326cd3 --- /dev/null +++ b/test/features/loading/widgets/logo_test.dart @@ -0,0 +1,64 @@ +import 'package:clean_stream_laundry_app/features/loading/widgets/logo.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _buildLogo() { + return const MaterialApp( + home: Scaffold( + backgroundColor: Colors.black, + body: Center(child: Logo()), + ), + ); +} + +void main() { + group('Logo', () { + testWidgets('renders without throwing', (tester) async { + await tester.pumpWidget(_buildLogo()); + expect(tester.takeException(), isNull); + }); + + testWidgets('contains a TweenAnimationBuilder wrapping a Transform.scale', + (tester) async { + await tester.pumpWidget(_buildLogo()); + + expect(find.byType(TweenAnimationBuilder), findsOneWidget); + // Transform.scale is a Transform widget under the hood + expect(find.byType(Transform), findsAtLeastNWidgets(1)); + }); + + testWidgets('renders an Image widget for the logo asset', (tester) async { + await tester.pumpWidget(_buildLogo()); + + expect(find.byType(Image), findsOneWidget); + }); + + testWidgets('scale swaps begin/end after animation completes', (tester) async { + await tester.pumpWidget(_buildLogo()); + + // Advance past the 1-second animation + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(milliseconds: 50)); // trigger onEnd + + // A second TweenAnimationBuilder cycle starts — no exception means the + // begin/end swap in setState() worked correctly. + expect(tester.takeException(), isNull); + + // Advance through the reversed animation too + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(milliseconds: 50)); + + expect(tester.takeException(), isNull); + }); + + testWidgets('does not throw after multiple pump cycles', (tester) async { + await tester.pumpWidget(_buildLogo()); + + for (int i = 0; i < 4; i++) { + await tester.pump(const Duration(milliseconds: 500)); + } + + expect(tester.takeException(), isNull); + }); + }); +} \ No newline at end of file From 3ebcd9e76350f9374122611eccd31fbd290f40c9 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Mar 2026 15:10:27 -0400 Subject: [PATCH 14/79] Cleans tests --- test/features/loading/controller_test.dart | 37 ------------------- test/features/loading/loading_test.dart | 11 ------ .../loading/widgets/error_view_test.dart | 2 - test/features/loading/widgets/logo_test.dart | 7 +--- 4 files changed, 1 insertion(+), 56 deletions(-) diff --git a/test/features/loading/controller_test.dart b/test/features/loading/controller_test.dart index 4d5db59..df58d05 100644 --- a/test/features/loading/controller_test.dart +++ b/test/features/loading/controller_test.dart @@ -5,7 +5,6 @@ import 'package:mocktail/mocktail.dart'; import 'mocks.dart'; -/// Convenience: run init() with a controllable mounted flag and collect routes. Future> _runInit( LoadingPageController controller, { bool mounted = true, @@ -23,8 +22,6 @@ void main() { late MockAppLinks mockAppLinks; setUpAll(() { - // Mocktail needs fallback values for any non-nullable custom types used - // with any() matchers. Uri is a Dart core type so no fallback needed. registerFallbackValue(Uri()); }); @@ -32,14 +29,9 @@ void main() { mockAuth = MockAuthService(); mockAppLinks = MockAppLinks(); - // Default: no deep link when(() => mockAppLinks.getInitialAppLink()).thenAnswer((_) async => null); }); - // ───────────────────────────────────────────── - // _automaticLogIn - // ───────────────────────────────────────────── - group('_automaticLogIn', () { test('navigates to /homePage when auth returns success', () async { when(() => mockAuth.isLoggedIn()) @@ -115,10 +107,6 @@ void main() { }); }); - // ───────────────────────────────────────────── - // _coldStartRedirect – no deep link - // ───────────────────────────────────────────── - group('_coldStartRedirect with no deep link', () { test('does not produce a cold-start navigation when initialUri is null', () async { @@ -133,15 +121,10 @@ void main() { final routes = await _runInit(controller); - // Only the auth navigation should fire expect(routes, equals(['/homePage'])); }); }); - // ───────────────────────────────────────────── - // _coldStartRedirect – reset-protected - // ───────────────────────────────────────────── - group('_coldStartRedirect – reset-protected URI', () { test('navigates to /reset-protected and passes URI as extra', () async { final uri = Uri.parse('clean-stream://reset-protected?token=abc'); @@ -170,10 +153,6 @@ void main() { }); }); - // ───────────────────────────────────────────── - // _coldStartRedirect – email-verification - // ───────────────────────────────────────────── - group('_coldStartRedirect – email-verification URI', () { test('navigates to /homePage for email-verification deep link', () async { final uri = Uri.parse('clean-stream://email-verification'); @@ -192,10 +171,6 @@ void main() { }); }); - // ───────────────────────────────────────────── - // _coldStartRedirect – change-email - // ───────────────────────────────────────────── - group('_coldStartRedirect – change-email URI', () { test('navigates to /email-verification for change-email deep link', () async { @@ -215,10 +190,6 @@ void main() { }); }); - // ───────────────────────────────────────────── - // _coldStartRedirect – oauth - // ───────────────────────────────────────────── - group('_coldStartRedirect – oauth URI', () { test('calls getSessionFromURI and navigates to /homePage when logged in', () async { @@ -274,10 +245,6 @@ void main() { }); }); - // ───────────────────────────────────────────── - // _coldStartRedirect – swallows exceptions silently - // ───────────────────────────────────────────── - group('_coldStartRedirect error handling', () { test('swallows exceptions silently and does not expose error', () async { when(() => mockAppLinks.getInitialAppLink()) @@ -295,10 +262,6 @@ void main() { }); }); - // ───────────────────────────────────────────── - // disposeController - // ───────────────────────────────────────────── - group('disposeController', () { test('can be called without throwing', () { final controller = LoadingPageController( diff --git a/test/features/loading/loading_test.dart b/test/features/loading/loading_test.dart index 5390154..c438d45 100644 --- a/test/features/loading/loading_test.dart +++ b/test/features/loading/loading_test.dart @@ -5,13 +5,8 @@ import 'package:clean_stream_laundry_app/features/loading/widgets/logo.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; - import 'mocks.dart'; -// ───────────────────────────────────────────────────────────────────────────── -// Fake controller — overrides init() so the widget never triggers real auth -// ───────────────────────────────────────────────────────────────────────────── - class _FakeController extends LoadingPageController { _FakeController({String? error}) : super( @@ -26,13 +21,9 @@ class _FakeController extends LoadingPageController { required void Function(String route, {Object? extra}) navigate, required ValueGetter isMounted, }) async { - // No-op: state was already set in the constructor for the test scenario. } } -// ───────────────────────────────────────────────────────────────────────────── -// Helper: wrap a widget in a GoRouter so context.go is available -// ───────────────────────────────────────────────────────────────────────────── Widget _withRouter(Widget child) { final router = GoRouter( @@ -77,7 +68,6 @@ void main() { expect(find.byType(Logo), findsOneWidget); - // Simulate the controller receiving an error post-init controller.error = 'Late error'; controller.notifyListeners(); await tester.pump(); @@ -92,7 +82,6 @@ void main() { await tester.pumpWidget(_withRouter(LoadingPage(controller: controller))); await tester.pump(); - // Replace with an empty widget to trigger dispose await tester.pumpWidget(const MaterialApp(home: Scaffold())); expect(tester.takeException(), isNull); diff --git a/test/features/loading/widgets/error_view_test.dart b/test/features/loading/widgets/error_view_test.dart index c8e5c7f..949d212 100644 --- a/test/features/loading/widgets/error_view_test.dart +++ b/test/features/loading/widgets/error_view_test.dart @@ -52,7 +52,6 @@ void main() { await tester.tap(find.text('Return to Login')); await tester.pumpAndSettle(); - // After navigation, the login scaffold (with its Key) should be present expect(find.byKey(const Key('login')), findsOneWidget); }); @@ -67,7 +66,6 @@ void main() { final longError = 'E' * 300; await tester.pumpWidget(_buildErrorView(longError)); - // pumpAndSettle ensures no overflow exceptions are thrown in frame render await tester.pumpAndSettle(); expect(tester.takeException(), isNull); diff --git a/test/features/loading/widgets/logo_test.dart b/test/features/loading/widgets/logo_test.dart index 0326cd3..47589b1 100644 --- a/test/features/loading/widgets/logo_test.dart +++ b/test/features/loading/widgets/logo_test.dart @@ -23,7 +23,6 @@ void main() { await tester.pumpWidget(_buildLogo()); expect(find.byType(TweenAnimationBuilder), findsOneWidget); - // Transform.scale is a Transform widget under the hood expect(find.byType(Transform), findsAtLeastNWidgets(1)); }); @@ -36,15 +35,11 @@ void main() { testWidgets('scale swaps begin/end after animation completes', (tester) async { await tester.pumpWidget(_buildLogo()); - // Advance past the 1-second animation await tester.pump(const Duration(seconds: 1)); - await tester.pump(const Duration(milliseconds: 50)); // trigger onEnd + await tester.pump(const Duration(milliseconds: 50)); - // A second TweenAnimationBuilder cycle starts — no exception means the - // begin/end swap in setState() worked correctly. expect(tester.takeException(), isNull); - // Advance through the reversed animation too await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(milliseconds: 50)); From 47ce4d81def113b2b40f23c952c4cc7be5489554 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Mar 2026 15:25:29 -0400 Subject: [PATCH 15/79] Separates login into features/login --- lib/features/login/controller.dart | 107 ++++++++++++++++++ lib/features/login/login.dart | 105 +++++++++++++++++ lib/features/login/widgets/form_fields.dart | 78 +++++++++++++ lib/features/login/widgets/links.dart | 40 +++++++ .../login/widgets/social_sign_in_buttons.dart | 76 +++++++++++++ 5 files changed, 406 insertions(+) create mode 100644 lib/features/login/controller.dart create mode 100644 lib/features/login/login.dart create mode 100644 lib/features/login/widgets/form_fields.dart create mode 100644 lib/features/login/widgets/links.dart create mode 100644 lib/features/login/widgets/social_sign_in_buttons.dart diff --git a/lib/features/login/controller.dart b/lib/features/login/controller.dart new file mode 100644 index 0000000..5aa405e --- /dev/null +++ b/lib/features/login/controller.dart @@ -0,0 +1,107 @@ +import 'dart:async'; +import 'package:app_links/app_links.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; + +class LoginController extends ChangeNotifier { + final AuthService authService = GetIt.instance(); + final ProfileService profileService = GetIt.instance(); + + final TextEditingController emailController = TextEditingController(); + final TextEditingController passwordController = TextEditingController(); + final ScrollController scrollController = ScrollController(); + + bool obscurePassword = true; + + String passwordLabel = 'Password'; + String emailLabel = 'Email'; + Color iconColor = Colors.blue; + Color enabledBorderColor = Colors.grey; + Color focusedBorderColor = Colors.blue; + Color labelColor = Colors.blue; + + late final StreamSubscription _linkSub; + + void init(BuildContext context, AppLinks appLinks) { + _linkSub = appLinks.uriLinkStream.listen((Uri? uri) async { + if (uri == null) return; + + if (uri.scheme == 'clean-stream' && uri.host == 'email-verification') { + if (!context.mounted) return; + context.go('/homePage'); + return; + } + + if (uri.scheme == 'clean-stream' && uri.host == 'oauth') { + await authService.getSessionFromURI(uri); + + if (await authService.isLoggedIn() == AuthenticationResponses.success) { + if (!context.mounted) return; + final currentUser = authService.getCurrentUser(); + if (currentUser != null) { + final name = currentUser.userMetadata?['full_name'] ?? + currentUser.userMetadata?['name'] ?? + currentUser.userMetadata?['given_name']; + await profileService.createAccount( + id: currentUser.id, name: name); + } + if (!context.mounted) return; + context.go('/homePage'); + } else { + if (!context.mounted) return; + context.go('/login'); + } + } + }); + } + + void disposeController() { + _linkSub.cancel(); + emailController.dispose(); + passwordController.dispose(); + scrollController.dispose(); + } + + Future handleLogin(BuildContext context, + void Function(String) showMessage) async { + final email = emailController.text.trim(); + final password = passwordController.text; + + if (email.isEmpty || password.isEmpty) { + showMessage('Please fill in both fields.'); + return; + } + + showMessage('Logging in as $email...'); + final response = await authService.login(email, password); + if (!context.mounted) return; + + if (response == AuthenticationResponses.success) { + showMessage('Logged in as $email'); + context.go('/homePage'); + } else if (response == AuthenticationResponses.emailNotVerified) { + context.go('/email-Verification'); + } else { + setErrorColors(); + } + } + + void setErrorColors() { + passwordLabel = 'Invalid Password or Email'; + emailLabel = 'Invalid Password or Email'; + iconColor = Colors.red; + enabledBorderColor = Colors.red; + focusedBorderColor = Colors.red; + labelColor = Colors.red; + notifyListeners(); + } + + void togglePasswordVisibility() { + obscurePassword = !obscurePassword; + notifyListeners(); + } +} \ No newline at end of file diff --git a/lib/features/login/login.dart b/lib/features/login/login.dart new file mode 100644 index 0000000..f299453 --- /dev/null +++ b/lib/features/login/login.dart @@ -0,0 +1,105 @@ +import 'package:app_links/app_links.dart'; +import 'package:clean_stream_laundry_app/features/login/controller.dart'; +import 'package:clean_stream_laundry_app/features/login/widgets/links.dart'; +import 'package:clean_stream_laundry_app/features/login/widgets/form_fields.dart'; +import 'package:clean_stream_laundry_app/features/login/widgets/social_sign_in_buttons.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class Login extends StatefulWidget { + final AppLinks appLinks; + + const Login({super.key, required this.appLinks}); + + @override + State createState() => _LoginState(); +} + +class _LoginState extends State { + late final LoginController _controller; + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _controller = LoginController(); + _controller.init(context, widget.appLinks); + _controller.addListener(() { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _controller.disposeController(); + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _showMessage(String text) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(text)), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + body: KeyboardListener( + focusNode: _focusNode, + autofocus: kIsWeb, + onKeyEvent: (keyEvent) { + if (keyEvent is KeyDownEvent && + keyEvent.logicalKey == LogicalKeyboardKey.enter) { + _controller.handleLogin(context, _showMessage); + } + }, + child: ScrollbarTheme( + data: ScrollbarThemeData( + thumbColor: WidgetStateProperty.all(Colors.blue), + ), + child: Scrollbar( + controller: _controller.scrollController, + interactive: true, + thickness: 6, + radius: const Radius.circular(8), + child: SingleChildScrollView( + controller: _controller.scrollController, + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/Logo.png', + height: 250, + width: 250, + key: const Key('app_logo'), + ), + FormFields(controller: _controller), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => + _controller.handleLogin(context, _showMessage), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + child: const Text('Log In'), + ), + ), + SocialSignInButtons(controller: _controller), + const LoginLinks(), + ], + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/login/widgets/form_fields.dart b/lib/features/login/widgets/form_fields.dart new file mode 100644 index 0000000..c368844 --- /dev/null +++ b/lib/features/login/widgets/form_fields.dart @@ -0,0 +1,78 @@ +import 'package:clean_stream_laundry_app/features/login/controller.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; + +class FormFields extends StatelessWidget { + final LoginController controller; + + const FormFields({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + TextField( + controller: controller.emailController, + style: TextStyle( + color: Theme.of(context).colorScheme.fontInverted, + ), + decoration: InputDecoration( + labelText: controller.emailLabel, + labelStyle: TextStyle(color: controller.labelColor), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: controller.focusedBorderColor, + width: 2.0, + ), + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: controller.enabledBorderColor), + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: Icon(Icons.email, color: controller.iconColor), + ), + ), + const SizedBox(height: 16), + TextField( + controller: controller.passwordController, + style: TextStyle( + color: Theme.of(context).colorScheme.fontInverted, + ), + decoration: InputDecoration( + labelText: controller.passwordLabel, + labelStyle: TextStyle(color: controller.labelColor), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: controller.focusedBorderColor, + width: 2.0, + ), + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: controller.enabledBorderColor), + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: Icon(Icons.lock, color: controller.iconColor), + suffixIcon: IconButton( + icon: Icon( + controller.obscurePassword + ? Icons.visibility_off + : Icons.visibility, + color: Colors.blue, + ), + onPressed: controller.togglePasswordVisibility, + ), + ), + obscureText: controller.obscurePassword, + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/features/login/widgets/links.dart b/lib/features/login/widgets/links.dart new file mode 100644 index 0000000..77bb744 --- /dev/null +++ b/lib/features/login/widgets/links.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class LoginLinks extends StatelessWidget { + const LoginLinks({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: InkWell( + onTap: () => context.go('/signup'), + child: const Text( + 'Create Account', + style: TextStyle( + color: Colors.blue, + decoration: TextDecoration.underline, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: InkWell( + onTap: () => context.go('/password-reset'), + child: const Text( + 'Reset Password', + style: TextStyle( + color: Colors.blue, + decoration: TextDecoration.underline, + ), + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/features/login/widgets/social_sign_in_buttons.dart b/lib/features/login/widgets/social_sign_in_buttons.dart new file mode 100644 index 0000000..b86f715 --- /dev/null +++ b/lib/features/login/widgets/social_sign_in_buttons.dart @@ -0,0 +1,76 @@ +import 'package:clean_stream_laundry_app/features/login/controller.dart'; +import 'package:flutter/material.dart'; + +class SocialSignInButtons extends StatelessWidget { + final LoginController controller; + + const SocialSignInButtons({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: SizedBox( + width: double.infinity, + height: 36, + child: ElevatedButton( + onPressed: () => controller.authService.googleSignIn(), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.grey, + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/Google.png', + width: 16, + height: 16, + key: const Key('google_logo'), + ), + const SizedBox(width: 8), + const Text( + 'Sign in with Google', + style: TextStyle(fontSize: 14), + ), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: SizedBox( + width: double.infinity, + height: 36, + child: ElevatedButton( + onPressed: () => controller.authService.appleSignIn(), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.apple, size: 16), + SizedBox(width: 8), + Text('Sign in with Apple', style: TextStyle(fontSize: 14)), + ], + ), + ), + ), + ), + ], + ); + } +} \ No newline at end of file From 9ba130fca28e9e8677151d09973993814bb6f39c Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Mar 2026 15:35:29 -0400 Subject: [PATCH 16/79] Refactors controller to use DI parameters --- lib/features/login/controller.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/features/login/controller.dart b/lib/features/login/controller.dart index 5aa405e..461c0b8 100644 --- a/lib/features/login/controller.dart +++ b/lib/features/login/controller.dart @@ -8,8 +8,14 @@ import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; class LoginController extends ChangeNotifier { - final AuthService authService = GetIt.instance(); - final ProfileService profileService = GetIt.instance(); + final AuthService authService; + final ProfileService profileService; + + LoginController({ + AuthService? authService, + ProfileService? profileService, + }) : authService = authService ?? GetIt.instance(), + profileService = profileService ?? GetIt.instance(); final TextEditingController emailController = TextEditingController(); final TextEditingController passwordController = TextEditingController(); From 682f0011f10f048fdf2766435dae4fe4264946f4 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Fri, 20 Mar 2026 15:48:51 -0400 Subject: [PATCH 17/79] Adds tests for login --- test/features/login/controller_test.dart | 248 +++++++++ test/features/login/login_test.dart | 507 ++++++++++++++++++ test/features/login/mocks.dart | 23 + .../login/widgets/form_fields_test.dart | 157 ++++++ test/features/login/widgets/links_test.dart | 92 ++++ .../widgets/social_sign_in_buttons_test.dart | 69 +++ 6 files changed, 1096 insertions(+) create mode 100644 test/features/login/controller_test.dart create mode 100644 test/features/login/login_test.dart create mode 100644 test/features/login/mocks.dart create mode 100644 test/features/login/widgets/form_fields_test.dart create mode 100644 test/features/login/widgets/links_test.dart create mode 100644 test/features/login/widgets/social_sign_in_buttons_test.dart diff --git a/test/features/login/controller_test.dart b/test/features/login/controller_test.dart new file mode 100644 index 0000000..8ca1810 --- /dev/null +++ b/test/features/login/controller_test.dart @@ -0,0 +1,248 @@ +import 'package:clean_stream_laundry_app/features/login/controller.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late MockProfileService mockProfileService; + late FakeAppLinks fakeAppLinks; + + setUpAll(() { + registerFallbackValue(FakeUri()); + }); + + setUp(() { + mockAuthService = MockAuthService(); + mockProfileService = MockProfileService(); + fakeAppLinks = FakeAppLinks(); + }); + + tearDown(() { + fakeAppLinks.dispose(); + }); + + LoginController buildController() => LoginController( + authService: mockAuthService, + profileService: mockProfileService, + ); + + Widget buildWithRouter({ + required void Function(LoginController) onControllerReady, + }) { + return MaterialApp.router( + routerConfig: GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (context, state) { + final controller = LoginController( + authService: mockAuthService, + profileService: mockProfileService, + ); + onControllerReady(controller); + controller.init(context, fakeAppLinks); + return const SizedBox(); + }, + ), + GoRoute( + path: '/homePage', + builder: (_, __) => const Scaffold(body: Text('Home')), + ), + GoRoute( + path: '/login', + builder: (_, __) => const Scaffold(body: Text('Login')), + ), + GoRoute( + path: '/email-Verification', + builder: (_, __) => + const Scaffold(body: Text('Email Verification')), + ), + ], + ), + ); + } + + group('handleLogin', () { + testWidgets('calls auth service with trimmed email and password', + (tester) async { + when(() => mockAuthService.login(any(), any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + late LoginController controller; + await tester.pumpWidget( + buildWithRouter(onControllerReady: (c) => controller = c)); + + controller.emailController.text = ' test@example.com '; + controller.passwordController.text = 'password123'; + + await controller.handleLogin( + tester.element(find.byType(SizedBox)), (_) {}); + + verify(() => mockAuthService.login('test@example.com', 'password123')) + .called(1); + }); + + testWidgets('does not call login when email is empty', (tester) async { + late LoginController controller; + await tester.pumpWidget( + buildWithRouter(onControllerReady: (c) => controller = c)); + + controller.emailController.text = ''; + controller.passwordController.text = 'password123'; + + final messages = []; + await controller.handleLogin( + tester.element(find.byType(SizedBox)), messages.add); + + expect(messages, contains('Please fill in both fields.')); + verifyNever(() => mockAuthService.login(any(), any())); + }); + + testWidgets('does not call login when password is empty', (tester) async { + late LoginController controller; + await tester.pumpWidget( + buildWithRouter(onControllerReady: (c) => controller = c)); + + controller.emailController.text = 'test@example.com'; + controller.passwordController.text = ''; + + final messages = []; + await controller.handleLogin( + tester.element(find.byType(SizedBox)), messages.add); + + expect(messages, contains('Please fill in both fields.')); + verifyNever(() => mockAuthService.login(any(), any())); + }); + + testWidgets('calls setErrorColors on failure response', (tester) async { + when(() => mockAuthService.login(any(), any())) + .thenAnswer((_) async => AuthenticationResponses.failure); + + late LoginController controller; + await tester.pumpWidget( + buildWithRouter(onControllerReady: (c) => controller = c)); + + controller.emailController.text = 'test@example.com'; + controller.passwordController.text = 'wrongpass'; + + await controller.handleLogin( + tester.element(find.byType(SizedBox)), (_) {}); + + expect(controller.iconColor, Colors.red); + expect(controller.labelColor, Colors.red); + expect(controller.emailLabel, 'Invalid Password or Email'); + expect(controller.passwordLabel, 'Invalid Password or Email'); + }); + }); + + group('setErrorColors', () { + test('sets all color fields to red and updates labels', () { + final controller = buildController(); + + controller.setErrorColors(); + + expect(controller.iconColor, Colors.red); + expect(controller.enabledBorderColor, Colors.red); + expect(controller.focusedBorderColor, Colors.red); + expect(controller.labelColor, Colors.red); + expect(controller.emailLabel, 'Invalid Password or Email'); + expect(controller.passwordLabel, 'Invalid Password or Email'); + }); + }); + + group('togglePasswordVisibility', () { + test('flips obscurePassword from true to false', () { + final controller = buildController(); + + expect(controller.obscurePassword, isTrue); + controller.togglePasswordVisibility(); + expect(controller.obscurePassword, isFalse); + }); + + test('flips obscurePassword from false to true', () { + final controller = buildController(); + + controller.togglePasswordVisibility(); + controller.togglePasswordVisibility(); + + expect(controller.obscurePassword, isTrue); + }); + }); + + group('Deep link handling', () { + testWidgets('calls getSessionFromURI on oauth deep link', (tester) async { + when(() => mockAuthService.getSessionFromURI(any())) + .thenAnswer((_) async {}); + when(() => mockAuthService.isLoggedIn()) + .thenAnswer((_) async => AuthenticationResponses.success); + when(() => mockAuthService.getCurrentUser()).thenReturn(null); + + await tester.pumpWidget(buildWithRouter(onControllerReady: (_) {})); + await tester.pumpAndSettle(); + + fakeAppLinks.emit(Uri.parse('clean-stream://oauth')); + await tester.pumpAndSettle(); + + verify(() => mockAuthService.getSessionFromURI(any())).called(1); + }); + + testWidgets('calls createAccount when oauth user exists', (tester) async { + when(() => mockAuthService.getSessionFromURI(any())) + .thenAnswer((_) async {}); + when(() => mockAuthService.isLoggedIn()) + .thenAnswer((_) async => AuthenticationResponses.success); + when(() => mockAuthService.getCurrentUser()).thenReturn( + User( + id: 'uid', + appMetadata: {}, + userMetadata: {'full_name': 'Jane Doe'}, + aud: '', + createdAt: '', + ), + ); + when(() => mockProfileService.createAccount( + id: any(named: 'id'), + name: any(named: 'name'), + )).thenAnswer((_) async {}); + + await tester.pumpWidget(buildWithRouter(onControllerReady: (_) {})); + await tester.pumpAndSettle(); + + fakeAppLinks.emit(Uri.parse('clean-stream://oauth')); + await tester.pumpAndSettle(); + + verify(() => mockProfileService.createAccount( + id: 'uid', + name: 'Jane Doe', + )).called(1); + }); + + testWidgets('ignores uri with unrecognised host', (tester) async { + await tester.pumpWidget(buildWithRouter(onControllerReady: (_) {})); + await tester.pumpAndSettle(); + + fakeAppLinks.emit(Uri.parse('clean-stream://unknown-host')); + await tester.pumpAndSettle(); + + verifyNever(() => mockAuthService.getSessionFromURI(any())); + }); + }); + + + group('Lifecycle', () { + testWidgets('disposeController cancels subscription without error', + (tester) async { + late LoginController controller; + await tester.pumpWidget( + buildWithRouter(onControllerReady: (c) => controller = c)); + + expect(() => controller.disposeController(), returnsNormally); + }); + }); +} \ No newline at end of file diff --git a/test/features/login/login_test.dart b/test/features/login/login_test.dart new file mode 100644 index 0000000..1a5cc5b --- /dev/null +++ b/test/features/login/login_test.dart @@ -0,0 +1,507 @@ +import 'package:clean_stream_laundry_app/features/login/login.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late MockProfileService mockProfileService; + late FakeAppLinks fakeAppLinks; + + setUpAll(() { + registerFallbackValue(FakeUri()); + }); + + setUp(() async { + mockAuthService = MockAuthService(); + mockProfileService = MockProfileService(); + fakeAppLinks = FakeAppLinks(); + + await GetIt.instance.reset(); + GetIt.instance.registerSingleton(mockAuthService); + GetIt.instance.registerSingleton(mockProfileService); + }); + + tearDown(() async { + fakeAppLinks.dispose(); + await GetIt.instance.reset(); + }); + + Widget createWidget() { + return MaterialApp.router( + routerConfig: GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, __) => Login(appLinks: fakeAppLinks), + ), + GoRoute( + path: '/homePage', + builder: (_, __) => const Scaffold(body: Text('Home Page')), + ), + GoRoute( + path: '/email-Verification', + builder: (_, __) => + const Scaffold(body: Text('Email Verification')), + ), + GoRoute( + path: '/signup', + builder: (_, __) => const Scaffold(body: Text('Sign Up Page')), + ), + GoRoute( + path: '/password-reset', + builder: (_, __) => + const Scaffold(body: Text('Password Reset Page')), + ), + GoRoute( + path: '/login', + builder: (_, __) => const Scaffold(body: Text('Login Page')), + ), + ], + ), + ); + } + + void mockLoginResponse(AuthenticationResponses response) { + when(() => mockAuthService.login(any(), any())) + .thenAnswer((_) async => response); + } + + Future enterCredentials( + WidgetTester tester, { + String email = 'test@example.com', + String password = 'password123', + }) async { + await tester.enterText(find.widgetWithText(TextField, 'Email'), email); + await tester.enterText( + find.widgetWithText(TextField, 'Password'), password); + } + + group('Static UI', () { + testWidgets('displays logo image with correct dimensions', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final image = tester.widget(find.byKey(const Key('app_logo'))); + expect(image.height, 250); + expect(image.width, 250); + }); + + testWidgets('displays email and password text fields', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(TextField, 'Email'), findsOneWidget); + expect(find.widgetWithText(TextField, 'Password'), findsOneWidget); + }); + + testWidgets('displays email and lock icons', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.email), findsOneWidget); + expect(find.byIcon(Icons.lock), findsOneWidget); + }); + + testWidgets('displays Log In button', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(ElevatedButton, 'Log In'), findsOneWidget); + }); + + testWidgets('displays Sign in with Google button', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect( + find.widgetWithText(ElevatedButton, 'Sign in with Google'), + findsOneWidget, + ); + }); + + testWidgets('displays Sign in with Apple button', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect( + find.widgetWithText(ElevatedButton, 'Sign in with Apple'), + findsOneWidget, + ); + }); + + testWidgets('displays Create Account link', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Create Account'), findsOneWidget); + }); + + testWidgets('displays Reset Password link', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Reset Password'), findsOneWidget); + }); + + testWidgets('password field is obscured by default', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final field = tester.widget( + find.widgetWithText(TextField, 'Password'), + ); + expect(field.obscureText, isTrue); + }); + + testWidgets('page is scrollable', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.byType(SingleChildScrollView), findsOneWidget); + }); + }); + + group('Login functionality', () { + testWidgets('shows error snackbar when both fields are empty', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); + await tester.pump(); + + expect(find.text('Please fill in both fields.'), findsOneWidget); + }); + + testWidgets('shows error snackbar when email is empty', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Password'), 'password123'); + await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); + await tester.pump(); + + expect(find.text('Please fill in both fields.'), findsOneWidget); + }); + + testWidgets('shows error snackbar when password is empty', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Email'), 'test@example.com'); + await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); + await tester.pump(); + + expect(find.text('Please fill in both fields.'), findsOneWidget); + }); + + testWidgets('shows logging in snackbar when credentials entered', + (tester) async { + mockLoginResponse(AuthenticationResponses.success); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await enterCredentials(tester); + await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); + await tester.pump(); + + expect(find.text('Logging in as test@example.com...'), findsOneWidget); + }); + + testWidgets('navigates to home page on successful login', (tester) async { + mockLoginResponse(AuthenticationResponses.success); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await enterCredentials(tester); + await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); + await tester.pumpAndSettle(); + + expect(find.text('Home Page'), findsOneWidget); + verify(() => mockAuthService.login('test@example.com', 'password123')) + .called(1); + }); + + testWidgets('navigates to email verification on unverified email', + (tester) async { + mockLoginResponse(AuthenticationResponses.emailNotVerified); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await enterCredentials(tester); + await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); + await tester.pumpAndSettle(); + + expect(find.text('Email Verification'), findsOneWidget); + }); + + testWidgets('shows error colors on failed login', (tester) async { + mockLoginResponse(AuthenticationResponses.failure); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await enterCredentials(tester); + await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); + await tester.pumpAndSettle(); + + expect(find.text('Invalid Password or Email'), findsNWidgets(2)); + + final emailIcon = tester.widget(find.byIcon(Icons.email)); + expect(emailIcon.color, Colors.red); + + final lockIcon = tester.widget(find.byIcon(Icons.lock)); + expect(lockIcon.color, Colors.red); + }); + + testWidgets('trims email whitespace before calling login', (tester) async { + mockLoginResponse(AuthenticationResponses.success); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await enterCredentials(tester, email: ' test@example.com '); + await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); + await tester.pumpAndSettle(); + + verify(() => mockAuthService.login('test@example.com', any())).called(1); + }); + + testWidgets('error colors persist after subsequent input', (tester) async { + mockLoginResponse(AuthenticationResponses.failure); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await enterCredentials(tester); + await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Invalid Password or Email').first, + 'new@example.com', + ); + await tester.pump(); + + final emailIcon = tester.widget(find.byIcon(Icons.email)); + expect(emailIcon.color, Colors.red); + }); + }); + + group('Password visibility', () { + testWidgets('toggles password visibility when suffix icon tapped', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect( + tester + .widget(find.widgetWithText(TextField, 'Password')) + .obscureText, + isTrue, + ); + + await tester.tap(find.byIcon(Icons.visibility_off)); + await tester.pump(); + + expect( + tester + .widget(find.widgetWithText(TextField, 'Password')) + .obscureText, + isFalse, + ); + }); + + testWidgets('shows visibility icon when password is hidden', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.visibility_off), findsOneWidget); + expect(find.byIcon(Icons.visibility), findsNothing); + }); + + testWidgets('shows visibility_off icon when password is shown', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.visibility_off)); + await tester.pump(); + + expect(find.byIcon(Icons.visibility), findsOneWidget); + expect(find.byIcon(Icons.visibility_off), findsNothing); + }); + }); + + + group('Navigation', () { + testWidgets('navigates to signup on Create Account tap', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.text('Create Account')); + await tester.tap(find.text('Create Account')); + await tester.pumpAndSettle(); + + expect(find.text('Sign Up Page'), findsOneWidget); + }); + + testWidgets('navigates to password reset on Reset Password tap', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.text('Reset Password')); + await tester.tap(find.text('Reset Password')); + await tester.pumpAndSettle(); + + expect(find.text('Password Reset Page'), findsOneWidget); + }); + }); + + group('Keyboard enter', () { + testWidgets('pressing Enter triggers login', (tester) async { + when(() => mockAuthService.login(any(), any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byType(TextField).first, 'test@example.com'); + await tester.enterText(find.byType(TextField).last, 'password123'); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect( + find.textContaining('Logging in as test@example.com'), + findsOneWidget, + ); + }); + }); + + group('Deep links', () { + testWidgets('navigates to home on email-verification deep link', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + fakeAppLinks.emit(Uri.parse('clean-stream://email-verification')); + await tester.pumpAndSettle(); + + expect(find.text('Home Page'), findsOneWidget); + }); + + testWidgets('handles oauth deep link with successful session', + (tester) async { + when(() => mockAuthService.getSessionFromURI(any())) + .thenAnswer((_) async {}); + when(() => mockAuthService.isLoggedIn()) + .thenAnswer((_) async => AuthenticationResponses.success); + when(() => mockAuthService.getCurrentUser()).thenReturn( + User( + id: 'testId', + appMetadata: {}, + userMetadata: {'full_name': 'Test User'}, + aud: '', + createdAt: '', + ), + ); + when(() => mockProfileService.createAccount( + id: any(named: 'id'), + name: any(named: 'name'), + )).thenAnswer((_) async {}); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + fakeAppLinks.emit(Uri.parse('clean-stream://oauth')); + await tester.pumpAndSettle(); + + expect(find.text('Home Page'), findsOneWidget); + verify(() => mockAuthService.getSessionFromURI(any())).called(1); + verify(() => mockAuthService.isLoggedIn()).called(1); + verify(() => mockAuthService.getCurrentUser()).called(1); + verify(() => mockProfileService.createAccount( + id: 'testId', + name: 'Test User', + )).called(1); + }); + + testWidgets('navigates to login on oauth deep link with failed session', + (tester) async { + when(() => mockAuthService.getSessionFromURI(any())) + .thenAnswer((_) async {}); + when(() => mockAuthService.isLoggedIn()) + .thenAnswer((_) async => AuthenticationResponses.failure); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + fakeAppLinks.emit(Uri.parse('clean-stream://oauth')); + await tester.pumpAndSettle(); + + expect(find.text('Login Page'), findsOneWidget); + }); + + testWidgets('ignores deep link with null uri', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(TextField, 'Email'), findsOneWidget); + }); + }); + + + group('Styling', () { + testWidgets('Log In button has blue background and white text', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final button = tester.widget( + find.widgetWithText(ElevatedButton, 'Log In'), + ); + expect(button.style?.backgroundColor?.resolve({}), Colors.blue); + expect(button.style?.foregroundColor?.resolve({}), Colors.white); + }); + + testWidgets('Create Account text is blue and underlined', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final text = tester.widget(find.text('Create Account')); + expect(text.style?.color, Colors.blue); + expect(text.style?.decoration, TextDecoration.underline); + }); + + testWidgets('email field has rounded border', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final field = tester.widget( + find.widgetWithText(TextField, 'Email'), + ); + final border = + (field.decoration as InputDecoration).border as OutlineInputBorder; + expect(border.borderRadius, BorderRadius.circular(12)); + }); + }); +} \ No newline at end of file diff --git a/test/features/login/mocks.dart b/test/features/login/mocks.dart new file mode 100644 index 0000000..5dbdc5b --- /dev/null +++ b/test/features/login/mocks.dart @@ -0,0 +1,23 @@ +import 'dart:async'; +import 'package:app_links/app_links.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthService extends Mock implements AuthService {} +class MockProfileService extends Mock implements ProfileService {} +class FakeUri extends Fake implements Uri {} + +class FakeAppLinks implements AppLinks { + final StreamController _controller = + StreamController.broadcast(); + + void emit(Uri uri) => _controller.add(uri); + void dispose() => _controller.close(); + + @override + Stream get uriLinkStream => _controller.stream; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} \ No newline at end of file diff --git a/test/features/login/widgets/form_fields_test.dart b/test/features/login/widgets/form_fields_test.dart new file mode 100644 index 0000000..46b783d --- /dev/null +++ b/test/features/login/widgets/form_fields_test.dart @@ -0,0 +1,157 @@ +import 'package:clean_stream_laundry_app/features/login/controller.dart'; +import 'package:clean_stream_laundry_app/features/login/widgets/form_fields.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late MockProfileService mockProfileService; + + setUp(() { + mockAuthService = MockAuthService(); + mockProfileService = MockProfileService(); + }); + + Widget buildWidget(LoginController controller) { + return MaterialApp( + home: Scaffold(body: _ControllerWrapper(controller: controller)), + ); + } + + LoginController buildController() => LoginController( + authService: mockAuthService, + profileService: mockProfileService, + ); + + group('LoginFormFields', () { + group('Initial rendering', () { + testWidgets('displays Email label', (tester) async { + final controller = buildController(); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.widgetWithText(TextField, 'Email'), findsOneWidget); + }); + + testWidgets('displays Password label', (tester) async { + final controller = buildController(); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.widgetWithText(TextField, 'Password'), findsOneWidget); + }); + + testWidgets('displays email and lock icons in blue', (tester) async { + final controller = buildController(); + await tester.pumpWidget(buildWidget(controller)); + + final emailIcon = tester.widget(find.byIcon(Icons.email)); + final lockIcon = tester.widget(find.byIcon(Icons.lock)); + + expect(emailIcon.color, Colors.blue); + expect(lockIcon.color, Colors.blue); + }); + + testWidgets('password is obscured by default', (tester) async { + final controller = buildController(); + await tester.pumpWidget(buildWidget(controller)); + + final field = tester.widget( + find.widgetWithText(TextField, 'Password'), + ); + expect(field.obscureText, isTrue); + }); + + testWidgets('shows visibility_off icon initially', (tester) async { + final controller = buildController(); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.byIcon(Icons.visibility_off), findsOneWidget); + }); + }); + + group('Error state', () { + testWidgets('shows error label text when setErrorColors called', + (tester) async { + final controller = buildController(); + await tester.pumpWidget(buildWidget(controller)); + + controller.setErrorColors(); + await tester.pump(); + + expect( + find.widgetWithText(TextField, 'Invalid Password or Email'), + findsNWidgets(2), + ); + }); + + testWidgets('shows red icons after setErrorColors', (tester) async { + final controller = buildController(); + await tester.pumpWidget(buildWidget(controller)); + + controller.setErrorColors(); + await tester.pump(); + + final emailIcon = tester.widget(find.byIcon(Icons.email)); + final lockIcon = tester.widget(find.byIcon(Icons.lock)); + + expect(emailIcon.color, Colors.red); + expect(lockIcon.color, Colors.red); + }); + }); + + group('Password visibility toggle', () { + testWidgets('tapping visibility icon toggles obscureText', + (tester) async { + final controller = buildController(); + await tester.pumpWidget(buildWidget(controller)); + + // onPressed inside the IconButton calls togglePasswordVisibility + await tester.tap(find.byIcon(Icons.visibility_off)); + await tester.pump(); + + final field = tester.widget( + find.widgetWithText(TextField, 'Password'), + ); + expect(field.obscureText, isFalse); + }); + + testWidgets('shows visibility icon after toggling', (tester) async { + final controller = buildController(); + controller.togglePasswordVisibility(); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.byIcon(Icons.visibility), findsOneWidget); + expect(find.byIcon(Icons.visibility_off), findsNothing); + }); + }); + }); +} + +class _ControllerWrapper extends StatefulWidget { + final LoginController controller; + const _ControllerWrapper({required this.controller}); + + @override + State<_ControllerWrapper> createState() => _ControllerWrapperState(); +} + +class _ControllerWrapperState extends State<_ControllerWrapper> { + @override + void initState() { + super.initState(); + widget.controller.addListener(_rebuild); + } + + void _rebuild() => setState(() {}); + + @override + void dispose() { + widget.controller.removeListener(_rebuild); + super.dispose(); + } + + @override + Widget build(BuildContext context) => + FormFields(controller: widget.controller); +} \ No newline at end of file diff --git a/test/features/login/widgets/links_test.dart b/test/features/login/widgets/links_test.dart new file mode 100644 index 0000000..4b0e4b7 --- /dev/null +++ b/test/features/login/widgets/links_test.dart @@ -0,0 +1,92 @@ +import 'package:clean_stream_laundry_app/features/login/widgets/links.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +void main() { + Widget buildWidget() { + return MaterialApp.router( + routerConfig: GoRouter( + routes: [ + GoRoute(path: '/', builder: (_, __) => const Scaffold(body: LoginLinks())), + GoRoute( + path: '/signup', + builder: (_, __) => const Scaffold(body: Text('Sign Up Page')), + ), + GoRoute( + path: '/password-reset', + builder: (_, __) => + const Scaffold(body: Text('Password Reset Page')), + ), + ], + ), + ); + } + + group('LoginLinks', () { + testWidgets('displays Create Account text', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect(find.text('Create Account'), findsOneWidget); + }); + + testWidgets('displays Reset Password text', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect(find.text('Reset Password'), findsOneWidget); + }); + + testWidgets('Create Account is blue and underlined', (tester) async { + await tester.pumpWidget(buildWidget()); + + final text = tester.widget(find.text('Create Account')); + expect(text.style?.color, Colors.blue); + expect(text.style?.decoration, TextDecoration.underline); + }); + + testWidgets('Reset Password is blue and underlined', (tester) async { + await tester.pumpWidget(buildWidget()); + + final text = tester.widget(find.text('Reset Password')); + expect(text.style?.color, Colors.blue); + expect(text.style?.decoration, TextDecoration.underline); + }); + + testWidgets('Create Account navigates to /signup', (tester) async { + await tester.pumpWidget(buildWidget()); + + await tester.tap(find.text('Create Account')); + await tester.pumpAndSettle(); + + expect(find.text('Sign Up Page'), findsOneWidget); + }); + + testWidgets('Reset Password navigates to /password-reset', (tester) async { + await tester.pumpWidget(buildWidget()); + + await tester.tap(find.text('Reset Password')); + await tester.pumpAndSettle(); + + expect(find.text('Password Reset Page'), findsOneWidget); + }); + + testWidgets('both links are wrapped in InkWell', (tester) async { + await tester.pumpWidget(buildWidget()); + + expect( + find.ancestor( + of: find.text('Create Account'), + matching: find.byType(InkWell), + ), + findsOneWidget, + ); + expect( + find.ancestor( + of: find.text('Reset Password'), + matching: find.byType(InkWell), + ), + findsOneWidget, + ); + }); + }); +} \ No newline at end of file diff --git a/test/features/login/widgets/social_sign_in_buttons_test.dart b/test/features/login/widgets/social_sign_in_buttons_test.dart new file mode 100644 index 0000000..554708b --- /dev/null +++ b/test/features/login/widgets/social_sign_in_buttons_test.dart @@ -0,0 +1,69 @@ +import 'package:clean_stream_laundry_app/features/login/controller.dart'; +import 'package:clean_stream_laundry_app/features/login/widgets/social_sign_in_buttons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import '../mocks.dart'; + +class MockLoginController extends Mock implements LoginController {} + +void main() { + late MockAuthService mockAuthService; + late MockLoginController mockController; + + setUp(() { + mockAuthService = MockAuthService(); + mockController = MockLoginController(); + + when(() => mockController.authService).thenReturn(mockAuthService); + + // Stub async methods + when(() => mockAuthService.googleSignIn()) + .thenAnswer((_) async {}); + when(() => mockAuthService.appleSignIn()) + .thenAnswer((_) async {}); + }); + + Widget createWidgetUnderTest() { + return MaterialApp( + home: Scaffold( + body: SocialSignInButtons(controller: mockController), + ), + ); + } + + testWidgets('renders both social sign-in buttons', + (WidgetTester tester) async { + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.text('Sign in with Google'), findsOneWidget); + expect(find.text('Sign in with Apple'), findsOneWidget); + }); + + testWidgets('tapping Google button calls googleSignIn', + (WidgetTester tester) async { + await tester.pumpWidget(createWidgetUnderTest()); + + await tester.tap(find.text('Sign in with Google')); + await tester.pump(); + + verify(() => mockAuthService.googleSignIn()).called(1); + }); + + testWidgets('tapping Apple button calls appleSignIn', + (WidgetTester tester) async { + await tester.pumpWidget(createWidgetUnderTest()); + + await tester.tap(find.text('Sign in with Apple')); + await tester.pump(); + + verify(() => mockAuthService.appleSignIn()).called(1); + }); + + testWidgets('google logo is displayed', + (WidgetTester tester) async { + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.byKey(const Key('google_logo')), findsOneWidget); + }); +} \ No newline at end of file From 656572d66ac69a0a72eab497354d5c6927737723 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 21 Mar 2026 10:07:16 -0400 Subject: [PATCH 18/79] Separates loyalty_card_page.dart into loyalty feature --- .../loyalty}/widgets/credit_card.dart | 0 .../loyalty/widgets/transaction_list.dart | 81 +++++++++++++++++++ .../loyalty}/widgets/credit_card_test.dart | 2 +- test/pages/loyalty_card_page_test.dart | 2 +- 4 files changed, 83 insertions(+), 2 deletions(-) rename lib/{ => features/loyalty}/widgets/credit_card.dart (100%) create mode 100644 lib/features/loyalty/widgets/transaction_list.dart rename test/{ => features/loyalty}/widgets/credit_card_test.dart (96%) diff --git a/lib/widgets/credit_card.dart b/lib/features/loyalty/widgets/credit_card.dart similarity index 100% rename from lib/widgets/credit_card.dart rename to lib/features/loyalty/widgets/credit_card.dart diff --git a/lib/features/loyalty/widgets/transaction_list.dart b/lib/features/loyalty/widgets/transaction_list.dart new file mode 100644 index 0000000..e3ec95b --- /dev/null +++ b/lib/features/loyalty/widgets/transaction_list.dart @@ -0,0 +1,81 @@ +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import '../controller.dart'; +import 'package:flutter/material.dart'; + +class TransactionList extends StatelessWidget { + final LoyaltyController controller; + + const TransactionList({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + if (controller.recentTransactions.isEmpty) { + return Text( + 'No transactions found.', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: ListView( + cacheExtent: 1000, + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Transactions', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + TextButton.icon( + onPressed: controller.toggleTransactionView, + icon: Icon( + controller.showPastTransactions + ? Icons.expand_less + : Icons.expand_more, + color: Colors.blue, + ), + label: Text( + controller.showPastTransactions ? 'Show Less' : 'Show More', + style: const TextStyle(color: Colors.blue), + ), + ), + ], + ), + const SizedBox(height: 9), + ...controller.recentTransactions.map((transaction) { + return Card( + margin: const EdgeInsets.symmetric( + horizontal: 4.0, + vertical: 6.0, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 4, + color: Theme.of(context).colorScheme.cardPrimary, + child: ListTile( + leading: const Icon( + Icons.receipt_long, + color: Color(0xFF2073A9), + ), + title: Text( + transaction.toString(), + style: const TextStyle(fontSize: 14, color: Colors.black87), + ), + ), + ); + }), + ], + ), + ); + } +} \ No newline at end of file diff --git a/test/widgets/credit_card_test.dart b/test/features/loyalty/widgets/credit_card_test.dart similarity index 96% rename from test/widgets/credit_card_test.dart rename to test/features/loyalty/widgets/credit_card_test.dart index 1bd7840..2813a71 100644 --- a/test/widgets/credit_card_test.dart +++ b/test/features/loyalty/widgets/credit_card_test.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/widgets/credit_card.dart'; +import 'package:clean_stream_laundry_app/features/loyalty/widgets/credit_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; diff --git a/test/pages/loyalty_card_page_test.dart b/test/pages/loyalty_card_page_test.dart index 255f39a..7aac360 100644 --- a/test/pages/loyalty_card_page_test.dart +++ b/test/pages/loyalty_card_page_test.dart @@ -2,7 +2,7 @@ import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; import 'package:clean_stream_laundry_app/pages/loyalty_card_page.dart'; import 'package:clean_stream_laundry_app/widgets/base_page.dart'; -import 'package:clean_stream_laundry_app/widgets/credit_card.dart'; +import 'package:clean_stream_laundry_app/features/loyalty/widgets/credit_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; From ff9d6f5c03f7a2677ced65e5a7abd1e72594320c Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 21 Mar 2026 10:08:01 -0400 Subject: [PATCH 19/79] Separates loyalty_card_page.dart into loyalty feature --- lib/features/loyalty/controller.dart | 116 +++++++++++ lib/features/loyalty/loyalty.dart | 163 +++++++++++++++ lib/features/loyalty/widgets/header.dart | 58 ++++++ .../loyalty/widgets/load_card_dialog.dart | 195 ++++++++++++++++++ 4 files changed, 532 insertions(+) create mode 100644 lib/features/loyalty/controller.dart create mode 100644 lib/features/loyalty/loyalty.dart create mode 100644 lib/features/loyalty/widgets/header.dart create mode 100644 lib/features/loyalty/widgets/load_card_dialog.dart diff --git a/lib/features/loyalty/controller.dart b/lib/features/loyalty/controller.dart new file mode 100644 index 0000000..22b70de --- /dev/null +++ b/lib/features/loyalty/controller.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; +import 'package:clean_stream_laundry_app/logic/parsing/transaction_parser.dart'; +import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; +import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; + + +class LoyaltyController extends ChangeNotifier { + final _authService = GetIt.instance(); + final _profileService = GetIt.instance(); + final _transactionService = GetIt.instance(); + final _paymentProcessor = GetIt.instance(); + + double? userBalance; + double? userReward; + String? userName; + String? errorMessage; + bool isLoading = true; + bool showPastTransactions = false; + + List recentTransactions = []; + + Future initialize() async { + await Future.wait([_fetchBalance(), _fetchTransactions()]); + } + + Future _fetchBalance() async { + final userId = _authService.getCurrentUserId; + + if (userId == null) { + errorMessage = 'User not known'; + isLoading = false; + notifyListeners(); + return; + } + + try { + final data = await _profileService.getUserBalanceById(userId); + + userBalance = (data?['balance'] as num?)?.toDouble() ?? 0.0; + userName = data?['full_name'] ?? 'John Doe'; + userReward = (data?["reward_tracker"] as num?)?.toDouble() ?? 0.0; + } catch (_) { + errorMessage = 'Failed to fetch balance'; + } + + isLoading = false; + notifyListeners(); + } + + Future _fetchTransactions() async { + final transactions = await _transactionService.getTransactionsForUser(); + final limit = showPastTransactions ? 100 : 3; + + final thirtyDaysAgo = DateTime.now().subtract(const Duration(days: 30)); + + final filtered = transactions.where((transaction) { + final createdAt = DateTime.parse(transaction['created_at'] as String); + final type = transaction['type'] as String?; + return createdAt.isAfter(thirtyDaysAgo) && type != "Rewards"; + }); + + recentTransactions = TransactionParser.formatTransactionsList( + filtered.take(limit), + "transactionHistory", + )..removeWhere((e) => e.isEmpty); + + notifyListeners(); + } + + Future toggleTransactionView() async { + showPastTransactions = !showPastTransactions; + notifyListeners(); + await _fetchTransactions(); + } + + Future loadCard(double amount) async { + final userId = _authService.getCurrentUserId; + final result = await _paymentProcessor.processPayment( + amount, + "Loyalty Card", + ); + + if (result == PaymentResult.success) { + amount = checkRewards(amount); + final newBalance = (userBalance ?? 0) + amount; + await _profileService.updateBalanceById(userId!, newBalance); + userBalance = newBalance; + await _fetchTransactions(); + } + + notifyListeners(); + return result; + } + + Future fetchTransactions() async { + await _transactionService.getTransactionsForUser(); + } + + double checkRewards(double amount) { + double combined = (userReward ?? 0) + amount; + double remainder = combined % 20; + int rewardsEarned = combined ~/ 20; + + if (remainder != (userReward ?? 0)) { + _profileService.updateRewardsById(_authService.getCurrentUserId!, remainder); + } + + userReward = remainder; + + return amount + (rewardsEarned * 5); + } +} \ No newline at end of file diff --git a/lib/features/loyalty/loyalty.dart b/lib/features/loyalty/loyalty.dart new file mode 100644 index 0000000..3d2c819 --- /dev/null +++ b/lib/features/loyalty/loyalty.dart @@ -0,0 +1,163 @@ +import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; +import 'controller.dart'; +import 'widgets/load_card_dialog.dart'; +import 'widgets/header.dart'; +import 'widgets/transaction_list.dart'; +import 'package:clean_stream_laundry_app/widgets/base_page.dart'; +import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; + +class LoyaltyPage extends StatefulWidget { + const LoyaltyPage({super.key}); + + @override + State createState() => _LoyaltyPageState(); +} + +class _LoyaltyPageState extends State { + final controller = GetIt.instance(); + + @override + void initState() { + super.initState(); + controller.addListener(_rebuild); + controller.initialize(); + } + + @override + void dispose() { + controller.removeListener(_rebuild); + super.dispose(); + } + + void _rebuild() => setState(() {}); + + void _showRewardInfoDialog() { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Rewards program'), + content: const Text( + 'For every \$20 you spend, you get an extra \$5 automatically added to your loyalty balance.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + + void _showErrorDialog(String? message) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Error'), + content: Text(message ?? ''), + icon: const Icon(Icons.error), + actions: [ + TextButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + if (message == 'Failed to fetch balance') { + context.go('/scanner'); + } else { + context.go('/login'); + } + }, + child: const Text('OK'), + ), + ], + ), + ); + } + + void _showLoadCardDialog() { + showDialog( + context: context, + builder: (_) => LoadCardDialog(onPay: _handlePayment), + ); + } + + Future _handlePayment(double amount) async { + final result = await controller.loadCard(amount); + if (!mounted) return; + + if (result == PaymentResult.success) { + controller.fetchTransactions(); + statusDialog( + context, + title: 'Payment Successful!', + message: + 'Thank you! Your payment of \$${amount.toStringAsFixed(2)} was processed successfully.', + isSuccess: true, + ); + } else if (result == PaymentResult.canceled) { + statusDialog( + context, + title: 'Payment Canceled', + message: 'Payment of \$${amount.toStringAsFixed(2)} was canceled.', + isSuccess: false, + ); + } else { + statusDialog( + context, + title: 'Payment Failed', + message: + 'An error occurred while processing your payment. Please try again.', + isSuccess: false, + ); + } + } + + @override + Widget build(BuildContext context) { + if (controller.errorMessage != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _showErrorDialog(controller.errorMessage); + }); + } + + return BasePage( + body: Column( + children: [ + const SizedBox(height: 10), + Header( + controller: controller, + onInfoTap: _showRewardInfoDialog, + ), + const SizedBox(height: 7), + ElevatedButton( + onPressed: _showLoadCardDialog, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + disabledBackgroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 2, + ), + child: const Text( + 'Load card', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + const SizedBox(height: 15), + Expanded( + child: TransactionList(controller: controller), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/loyalty/widgets/header.dart b/lib/features/loyalty/widgets/header.dart new file mode 100644 index 0000000..50e6f19 --- /dev/null +++ b/lib/features/loyalty/widgets/header.dart @@ -0,0 +1,58 @@ +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import '../controller.dart'; +import 'credit_card.dart'; +import 'package:flutter/material.dart'; + +class Header extends StatelessWidget { + final LoyaltyController controller; + final VoidCallback onInfoTap; + + const Header({ + super.key, + required this.controller, + required this.onInfoTap, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + CreditCard(username: controller.userName ?? 'John Doe'), + const SizedBox(height: 17), + Text( + 'Loyalty Balance: \$${controller.userBalance?.toStringAsFixed(2) ?? '0.00'}', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '\$${(20 - (controller.userReward ?? 0)).toStringAsFixed(2)} until next reward', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + const SizedBox(width: 4), + IconButton( + onPressed: onInfoTap, + icon: const Icon(Icons.info_outline), + iconSize: 18, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + color: Colors.blue, + ), + ], + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/features/loyalty/widgets/load_card_dialog.dart b/lib/features/loyalty/widgets/load_card_dialog.dart new file mode 100644 index 0000000..87707e5 --- /dev/null +++ b/lib/features/loyalty/widgets/load_card_dialog.dart @@ -0,0 +1,195 @@ +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; + +class LoadCardDialog extends StatefulWidget { + final Future Function(double amount) onPay; + + const LoadCardDialog({super.key, required this.onPay}); + + @override + State createState() => _LoadCardDialogState(); +} + +class _LoadCardDialogState extends State { + double selectedAmount = 1.0; + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: Theme.of(context).colorScheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Center( + child: Text( + 'Load Loyalty Card', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton( + onPressed: selectedAmount > 1.0 + ? () => setState(() { + selectedAmount = + (selectedAmount - 0.25).clamp(1.0, 500.0); + }) + : null, + style: OutlinedButton.styleFrom( + side: BorderSide( + color: selectedAmount > 1.0 ? Colors.blue : Colors.grey, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + '-25¢', + style: TextStyle( + color: selectedAmount > 1.0 ? Colors.blue : Colors.grey, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + const SizedBox(width: 12), + Text( + '\$${selectedAmount.toStringAsFixed(2)}', + style: TextStyle( + fontSize: 36, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.fontInverted, + ), + ), + const SizedBox(width: 12), + OutlinedButton( + onPressed: selectedAmount < 500.0 + ? () => setState(() { + selectedAmount = + (selectedAmount + 0.25).clamp(1.0, 500.0); + }) + : null, + style: OutlinedButton.styleFrom( + side: BorderSide( + color: selectedAmount < 500.0 ? Colors.blue : Colors.grey, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + '+25¢', + style: TextStyle( + color: selectedAmount < 500.0 ? Colors.blue : Colors.grey, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [10, 15, 25].map((amount) { + return ChoiceChip( + label: Text('\$$amount'), + labelStyle: TextStyle( + color: Theme.of(context).colorScheme.fontInverted, + ), + shape: StadiumBorder( + side: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 1.5, + ), + ), + selected: selectedAmount == amount.toDouble(), + onSelected: (_) => + setState(() => selectedAmount = amount.toDouble()), + ); + }).toList(), + ), + const SizedBox(height: 16), + SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 6, + activeTrackColor: Colors.blue, + inactiveTrackColor: Colors.blue.withAlpha(3), + thumbShape: + const RoundSliderThumbShape(enabledThumbRadius: 12), + overlayShape: + const RoundSliderOverlayShape(overlayRadius: 24), + tickMarkShape: + const RoundSliderTickMarkShape(tickMarkRadius: 0), + ), + child: SizedBox( + width: 650, + child: Row( + children: [ + Expanded( + child: Slider( + value: selectedAmount, + min: 1, + max: 500, + onChanged: (value) => setState( + () => selectedAmount = value.roundToDouble()), + ), + ), + ], + ), + ), + ), + Text( + 'Select an amount to add to your card.', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .fontInverted + .withValues(alpha: 0.7), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('Cancel', style: TextStyle(color: Colors.blue[700])), + ), + ElevatedButton( + onPressed: () async { + final amount = selectedAmount; + Navigator.of(context).pop(); + await widget.onPay(amount); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Pay', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ], + ); + } +} \ No newline at end of file From 9e00dca2259efe08da22881623f06770a6b6fd8e Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 21 Mar 2026 10:17:08 -0400 Subject: [PATCH 20/79] Removes unneeded DI --- lib/features/loyalty/loyalty.dart | 4 +- lib/pages/loyalty_card_page.dart | 2 +- test/features/loyalty/controller_test.dart | 279 +++++++++++++++++++++ 3 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 test/features/loyalty/controller_test.dart diff --git a/lib/features/loyalty/loyalty.dart b/lib/features/loyalty/loyalty.dart index 3d2c819..4677059 100644 --- a/lib/features/loyalty/loyalty.dart +++ b/lib/features/loyalty/loyalty.dart @@ -6,7 +6,6 @@ import 'widgets/transaction_list.dart'; import 'package:clean_stream_laundry_app/widgets/base_page.dart'; import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; class LoyaltyPage extends StatefulWidget { @@ -17,11 +16,12 @@ class LoyaltyPage extends StatefulWidget { } class _LoyaltyPageState extends State { - final controller = GetIt.instance(); + late final controller; @override void initState() { super.initState(); + controller = LoyaltyController(); controller.addListener(_rebuild); controller.initialize(); } diff --git a/lib/pages/loyalty_card_page.dart b/lib/pages/loyalty_card_page.dart index 77a8227..535c5bf 100644 --- a/lib/pages/loyalty_card_page.dart +++ b/lib/pages/loyalty_card_page.dart @@ -4,7 +4,7 @@ import 'package:clean_stream_laundry_app/widgets/base_page.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import '../Logic/Theme/theme.dart'; -import 'package:clean_stream_laundry_app/widgets/credit_card.dart'; +import 'package:clean_stream_laundry_app/features/loyalty/widgets/credit_card.dart'; import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; diff --git a/test/features/loyalty/controller_test.dart b/test/features/loyalty/controller_test.dart new file mode 100644 index 0000000..d5ea484 --- /dev/null +++ b/test/features/loyalty/controller_test.dart @@ -0,0 +1,279 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:get_it/get_it.dart'; +import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; +import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; +import 'mocks.dart'; +import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; + +void main() { + late LoyaltyViewModel viewModel; + late MockAuthService mockAuthService; + late MockProfileService mockProfileService; + late MockTransactionService mockTransactionService; + late MockPaymentProcessor mockPaymentProcessor; + + setUp(() { + // Clear GetIt before each test + GetIt.instance.reset(); + + // Create mocks + mockAuthService = MockAuthService(); + mockProfileService = MockProfileService(); + mockTransactionService = MockTransactionService(); + mockPaymentProcessor = MockPaymentProcessor(); + + // Register mocks with GetIt + GetIt.instance.registerSingleton(mockAuthService); + GetIt.instance.registerSingleton(mockProfileService); + GetIt.instance.registerSingleton( + mockTransactionService, + ); + GetIt.instance.registerSingleton(mockPaymentProcessor); + + // Create viewModel + viewModel = LoyaltyViewModel(); + }); + + tearDown(() { + GetIt.instance.reset(); + }); + + group('initialize', () { + test('should fetch balance and transactions successfully', () async { + // Arrange + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when( + () => mockProfileService.getUserBalanceById('user123'), + ).thenAnswer((_) async => {'balance': 100.0, 'full_name': 'Jane Doe'}); + when( + () => mockTransactionService.getTransactionsForUser(), + ).thenAnswer((_) async => []); + + // Act + await viewModel.initialize(); + + // Assert + expect(viewModel.userBalance, 100.0); + expect(viewModel.userName, 'Jane Doe'); + expect(viewModel.isLoading, false); + expect(viewModel.errorMessage, null); + + // Verify interactions + verify(() => mockAuthService.getCurrentUserId).called(1); + verify(() => mockProfileService.getUserBalanceById('user123')).called(1); + verify(() => mockTransactionService.getTransactionsForUser()).called(1); + }); + + test('should handle profile service error gracefully', () async { + // Arrange + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when( + () => mockProfileService.getUserBalanceById('user123'), + ).thenThrow(Exception('Network error')); + when( + () => mockTransactionService.getTransactionsForUser(), + ).thenAnswer((_) async => []); + + // Act + await viewModel.initialize(); + + // Assert + expect(viewModel.errorMessage, 'Failed to fetch balance'); + expect(viewModel.isLoading, false); + }); + + test('initialize should handle null userId', () async { + // Arrange + when(() => mockAuthService.getCurrentUserId).thenReturn(null); + when(() => mockTransactionService.getTransactionsForUser()) + .thenAnswer((_) async => []); + + // Act + await viewModel.initialize(); + + // Assert + expect(viewModel.errorMessage, 'User not known'); + expect(viewModel.isLoading, false); + + verifyNever(() => mockProfileService.getUserBalanceById(any())); + }); + + test('should default to 0.0 balance when null', () async { + // Arrange + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when( + () => mockProfileService.getUserBalanceById('user123'), + ).thenAnswer((_) async => {'balance': null, 'full_name': 'Jane Doe'}); + when( + () => mockTransactionService.getTransactionsForUser(), + ).thenAnswer((_) async => []); + + // Act + await viewModel.initialize(); + + // Assert + expect(viewModel.userBalance, 0.0); + expect(viewModel.userName, 'Jane Doe'); + }); + + test('should default to "John Doe" when name is null', () async { + // Arrange + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when( + () => mockProfileService.getUserBalanceById('user123'), + ).thenAnswer((_) async => {'balance': 100.0, 'full_name': null}); + when( + () => mockTransactionService.getTransactionsForUser(), + ).thenAnswer((_) async => []); + + // Act + await viewModel.initialize(); + + // Assert + expect(viewModel.userName, 'John Doe'); + }); + }); + + group('toggleTransactionView', () { + test('should toggle showPastTransactions from false to true', () async { + // Arrange + when( + () => mockTransactionService.getTransactionsForUser(), + ).thenAnswer((_) async => []); + + expect(viewModel.showPastTransactions, false); + + // Act + await viewModel.toggleTransactionView(); + + // Assert + expect(viewModel.showPastTransactions, true); + verify(() => mockTransactionService.getTransactionsForUser()).called(1); + }); + + test('should toggle showPastTransactions from true to false', () async { + // Arrange + when( + () => mockTransactionService.getTransactionsForUser(), + ).thenAnswer((_) async => []); + + viewModel.showPastTransactions = true; + + // Act + await viewModel.toggleTransactionView(); + + // Assert + expect(viewModel.showPastTransactions, false); + }); + }); + + group('fetchTransactions', () { + test('should call transaction service', () async { + // Arrange + when( + () => mockTransactionService.getTransactionsForUser(), + ).thenAnswer((_) async => []); + + // Act + await viewModel.fetchTransactions(); + + // Assert + verify(() => mockTransactionService.getTransactionsForUser()).called(1); + }); + + test('fetchTransactions filters out Rewards and old transactions', () async { + // Arrange + final now = DateTime.now(); + when(() => mockTransactionService.getTransactionsForUser()).thenAnswer( + (_) async => [ + { + 'created_at': now.toIso8601String(), + 'type': 'Laundry', + 'amount': 10, + 'description': 'Wash', + }, + { + 'created_at': now.toIso8601String(), + 'type': 'Rewards', + 'amount': 1, + 'description': 'Reward', + }, + { + 'created_at': + now.subtract(const Duration(days: 40)).toIso8601String(), + 'type': 'Laundry', + 'amount': 5, + 'description': 'Old wash', + }, + ], + ); + + // Act + await viewModel.toggleTransactionView(); + + // Assert + expect(viewModel.recentTransactions.length, 1); + }); + }); + + group('loadCard', () { + test('loadCard should update balance and fetch transactions on success', + () async { + // Arrange + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + + viewModel.userBalance = 20.0; + + when(() => mockPaymentProcessor.processPayment( + 10.0, + 'Loyalty Card', + )).thenAnswer((_) async => PaymentResult.success); + + when(() => mockProfileService.updateBalanceById('user123', 30)) + .thenAnswer((_) async => Future.value()); + + // Stub for checkRewards -> updateRewardsById called internally + when(() => mockProfileService.updateRewardsById(any(), any())) + .thenAnswer((_) async => Future.value()); + + when(() => mockTransactionService.getTransactionsForUser()) + .thenAnswer((_) async => []); + + // Act + final result = await viewModel.loadCard(10.0); + + // Assert + expect(result, PaymentResult.success); + expect(viewModel.userBalance, 30); + + verify(() => mockProfileService.updateBalanceById('user123', 30)) + .called(1); + verify(() => mockTransactionService.getTransactionsForUser()).called(1); + }); + + test('loadCard should not update balance on failed payment', () async { + // Arrange + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + + viewModel.userBalance = 20.0; + + when(() => mockPaymentProcessor.processPayment( + 10.0, + 'Loyalty Card', + )).thenAnswer((_) async => PaymentResult.failed); + + // Act + final result = await viewModel.loadCard(10.0); + + // Assert + expect(result, PaymentResult.failed); + expect(viewModel.userBalance, 20.0); + + verifyNever(() => mockProfileService.updateBalanceById(any(), any())); + }); + }); +} \ No newline at end of file From 1c40599cdce8c9889b35cb54649adf228750fc05 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 21 Mar 2026 10:29:06 -0400 Subject: [PATCH 21/79] Adds tests for loyalty feature --- lib/features/loyalty/loyalty.dart | 7 +- lib/main.dart | 2 +- pubspec.lock | 12 +- test/features/loyalty/controller_test.dart | 107 ++-- test/features/loyalty/loyalty_test.dart | 475 ++++++++++++++++++ test/features/loyalty/mocks.dart | 16 + .../loyalty/widgets/credit_card_test.dart | 2 - .../features/loyalty/widgets/header_test.dart | 90 ++++ .../widgets/load_card_dialog_test.dart | 193 +++++++ .../widgets/transaction_list_test.dart | 122 +++++ 10 files changed, 941 insertions(+), 85 deletions(-) create mode 100644 test/features/loyalty/loyalty_test.dart create mode 100644 test/features/loyalty/mocks.dart create mode 100644 test/features/loyalty/widgets/header_test.dart create mode 100644 test/features/loyalty/widgets/load_card_dialog_test.dart create mode 100644 test/features/loyalty/widgets/transaction_list_test.dart diff --git a/lib/features/loyalty/loyalty.dart b/lib/features/loyalty/loyalty.dart index 4677059..6fcc497 100644 --- a/lib/features/loyalty/loyalty.dart +++ b/lib/features/loyalty/loyalty.dart @@ -9,19 +9,20 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class LoyaltyPage extends StatefulWidget { - const LoyaltyPage({super.key}); + final LoyaltyController? controller; + const LoyaltyPage({super.key, this.controller}); @override State createState() => _LoyaltyPageState(); } class _LoyaltyPageState extends State { - late final controller; + late LoyaltyController controller; @override void initState() { super.initState(); - controller = LoyaltyController(); + controller = widget.controller ??LoyaltyController(); controller.addListener(_rebuild); controller.initialize(); } diff --git a/lib/main.dart b/lib/main.dart index 76d8777..cb4db57 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -99,7 +99,7 @@ Future setupDependencies() async { FlutterLocalNotificationsPlugin(), ); - getIt.registerLazySingleton(() => LoyaltyViewModel()); + //getIt.registerLazySingleton(() => LoyaltyViewModel()); getIt.registerLazySingleton(() => PaymentProcessor()); } diff --git a/pubspec.lock b/pubspec.lock index 18dea14..bc6b3aa 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1113,26 +1113,26 @@ packages: dependency: "direct main" description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.29.0" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.15" test_cov_console: dependency: "direct dev" description: diff --git a/test/features/loyalty/controller_test.dart b/test/features/loyalty/controller_test.dart index d5ea484..3b886f5 100644 --- a/test/features/loyalty/controller_test.dart +++ b/test/features/loyalty/controller_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:get_it/get_it.dart'; -import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; +import 'package:clean_stream_laundry_app/features/loyalty/controller.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; @@ -10,23 +10,20 @@ import 'mocks.dart'; import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; void main() { - late LoyaltyViewModel viewModel; + late LoyaltyController controller; late MockAuthService mockAuthService; late MockProfileService mockProfileService; late MockTransactionService mockTransactionService; late MockPaymentProcessor mockPaymentProcessor; setUp(() { - // Clear GetIt before each test GetIt.instance.reset(); - // Create mocks mockAuthService = MockAuthService(); mockProfileService = MockProfileService(); mockTransactionService = MockTransactionService(); mockPaymentProcessor = MockPaymentProcessor(); - // Register mocks with GetIt GetIt.instance.registerSingleton(mockAuthService); GetIt.instance.registerSingleton(mockProfileService); GetIt.instance.registerSingleton( @@ -34,8 +31,7 @@ void main() { ); GetIt.instance.registerSingleton(mockPaymentProcessor); - // Create viewModel - viewModel = LoyaltyViewModel(); + controller = LoyaltyController(); }); tearDown(() { @@ -44,7 +40,6 @@ void main() { group('initialize', () { test('should fetch balance and transactions successfully', () async { - // Arrange when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( () => mockProfileService.getUserBalanceById('user123'), @@ -53,23 +48,19 @@ void main() { () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); - // Act - await viewModel.initialize(); + await controller.initialize(); - // Assert - expect(viewModel.userBalance, 100.0); - expect(viewModel.userName, 'Jane Doe'); - expect(viewModel.isLoading, false); - expect(viewModel.errorMessage, null); + expect(controller.userBalance, 100.0); + expect(controller.userName, 'Jane Doe'); + expect(controller.isLoading, false); + expect(controller.errorMessage, null); - // Verify interactions verify(() => mockAuthService.getCurrentUserId).called(1); verify(() => mockProfileService.getUserBalanceById('user123')).called(1); verify(() => mockTransactionService.getTransactionsForUser()).called(1); }); test('should handle profile service error gracefully', () async { - // Arrange when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( () => mockProfileService.getUserBalanceById('user123'), @@ -78,32 +69,26 @@ void main() { () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); - // Act - await viewModel.initialize(); + await controller.initialize(); - // Assert - expect(viewModel.errorMessage, 'Failed to fetch balance'); - expect(viewModel.isLoading, false); + expect(controller.errorMessage, 'Failed to fetch balance'); + expect(controller.isLoading, false); }); test('initialize should handle null userId', () async { - // Arrange when(() => mockAuthService.getCurrentUserId).thenReturn(null); when(() => mockTransactionService.getTransactionsForUser()) .thenAnswer((_) async => []); - // Act - await viewModel.initialize(); + await controller.initialize(); - // Assert - expect(viewModel.errorMessage, 'User not known'); - expect(viewModel.isLoading, false); + expect(controller.errorMessage, 'User not known'); + expect(controller.isLoading, false); verifyNever(() => mockProfileService.getUserBalanceById(any())); }); test('should default to 0.0 balance when null', () async { - // Arrange when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( () => mockProfileService.getUserBalanceById('user123'), @@ -112,16 +97,13 @@ void main() { () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); - // Act - await viewModel.initialize(); + await controller.initialize(); - // Assert - expect(viewModel.userBalance, 0.0); - expect(viewModel.userName, 'Jane Doe'); + expect(controller.userBalance, 0.0); + expect(controller.userName, 'Jane Doe'); }); test('should default to "John Doe" when name is null', () async { - // Arrange when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( () => mockProfileService.getUserBalanceById('user123'), @@ -130,63 +112,51 @@ void main() { () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); - // Act - await viewModel.initialize(); + await controller.initialize(); - // Assert - expect(viewModel.userName, 'John Doe'); + expect(controller.userName, 'John Doe'); }); }); group('toggleTransactionView', () { test('should toggle showPastTransactions from false to true', () async { - // Arrange when( () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); - expect(viewModel.showPastTransactions, false); + expect(controller.showPastTransactions, false); - // Act - await viewModel.toggleTransactionView(); + await controller.toggleTransactionView(); - // Assert - expect(viewModel.showPastTransactions, true); + expect(controller.showPastTransactions, true); verify(() => mockTransactionService.getTransactionsForUser()).called(1); }); test('should toggle showPastTransactions from true to false', () async { - // Arrange when( () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); - viewModel.showPastTransactions = true; + controller.showPastTransactions = true; - // Act - await viewModel.toggleTransactionView(); + await controller.toggleTransactionView(); - // Assert - expect(viewModel.showPastTransactions, false); + expect(controller.showPastTransactions, false); }); }); group('fetchTransactions', () { test('should call transaction service', () async { - // Arrange when( () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); - // Act - await viewModel.fetchTransactions(); + await controller.fetchTransactions(); - // Assert verify(() => mockTransactionService.getTransactionsForUser()).called(1); }); test('fetchTransactions filters out Rewards and old transactions', () async { - // Arrange final now = DateTime.now(); when(() => mockTransactionService.getTransactionsForUser()).thenAnswer( (_) async => [ @@ -212,21 +182,18 @@ void main() { ], ); - // Act - await viewModel.toggleTransactionView(); + await controller.toggleTransactionView(); - // Assert - expect(viewModel.recentTransactions.length, 1); + expect(controller.recentTransactions.length, 1); }); }); group('loadCard', () { test('loadCard should update balance and fetch transactions on success', () async { - // Arrange when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - viewModel.userBalance = 20.0; + controller.userBalance = 20.0; when(() => mockPaymentProcessor.processPayment( 10.0, @@ -236,19 +203,16 @@ void main() { when(() => mockProfileService.updateBalanceById('user123', 30)) .thenAnswer((_) async => Future.value()); - // Stub for checkRewards -> updateRewardsById called internally when(() => mockProfileService.updateRewardsById(any(), any())) .thenAnswer((_) async => Future.value()); when(() => mockTransactionService.getTransactionsForUser()) .thenAnswer((_) async => []); - // Act - final result = await viewModel.loadCard(10.0); + final result = await controller.loadCard(10.0); - // Assert expect(result, PaymentResult.success); - expect(viewModel.userBalance, 30); + expect(controller.userBalance, 30); verify(() => mockProfileService.updateBalanceById('user123', 30)) .called(1); @@ -256,22 +220,19 @@ void main() { }); test('loadCard should not update balance on failed payment', () async { - // Arrange when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - viewModel.userBalance = 20.0; + controller.userBalance = 20.0; when(() => mockPaymentProcessor.processPayment( 10.0, 'Loyalty Card', )).thenAnswer((_) async => PaymentResult.failed); - // Act - final result = await viewModel.loadCard(10.0); + final result = await controller.loadCard(10.0); - // Assert expect(result, PaymentResult.failed); - expect(viewModel.userBalance, 20.0); + expect(controller.userBalance, 20.0); verifyNever(() => mockProfileService.updateBalanceById(any(), any())); }); diff --git a/test/features/loyalty/loyalty_test.dart b/test/features/loyalty/loyalty_test.dart new file mode 100644 index 0000000..a5ac794 --- /dev/null +++ b/test/features/loyalty/loyalty_test.dart @@ -0,0 +1,475 @@ +import 'package:clean_stream_laundry_app/features/loyalty/loyalty.dart'; +import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; +import 'package:clean_stream_laundry_app/widgets/base_page.dart'; +import 'package:clean_stream_laundry_app/features/loyalty/widgets/credit_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockLoyaltyController mockController; + + setUpAll(() { + registerFallbackValue((_) {}); + }); + + setUp(() async { + mockController = MockLoyaltyController(); + + await GetIt.instance.reset(); + when(() => mockController.isLoading).thenReturn(false); + when(() => mockController.errorMessage).thenReturn(null); + when(() => mockController.userName).thenReturn('Test User'); + when(() => mockController.userBalance).thenReturn(25.50); + when(() => mockController.userReward).thenReturn(0.0); + when(() => mockController.recentTransactions).thenReturn([]); + when(() => mockController.showPastTransactions).thenReturn(false); + when(() => mockController.initialize()).thenAnswer((_) async {}); + when(() => mockController.fetchTransactions()).thenAnswer((_) async {}); + when(() => mockController.toggleTransactionView()).thenAnswer((_) async {}); + when(() => mockController.addListener(any())).thenReturn(null); + when(() => mockController.removeListener(any())).thenReturn(null); + }); + + tearDown(() async { + await GetIt.instance.reset(); + }); + + Widget createWidget() { + return MaterialApp.router( + routerConfig: GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, __) => LoyaltyPage(controller: mockController), + ), + GoRoute( + path: '/scanner', + builder: (_, __) => const Scaffold(body: Text('Scanner')), + ), + GoRoute( + path: '/login', + builder: (_, __) => const Scaffold(body: Text('Login')), + ), + ], + ), + ); + } + + group('Initialization', () { + testWidgets('calls initialize on viewModel during initState', + (tester) async { + await tester.pumpWidget(createWidget()); + verify(() => mockController.initialize()).called(1); + }); + + testWidgets('adds listener to viewModel', (tester) async { + await tester.pumpWidget(createWidget()); + verify(() => mockController.addListener(any())).called(1); + }); + + testWidgets('removes listener on dispose', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpWidget(const MaterialApp(home: Scaffold())); + verify(() => mockController.removeListener(any())).called(1); + }); + + testWidgets('displays BasePage when not loading', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pump(); + expect(find.byType(BasePage), findsOneWidget); + }); + }); + + group('Content display', () { + testWidgets('displays CreditCard with correct username', (tester) async { + when(() => mockController.userName).thenReturn('Jane Doe'); + await tester.pumpWidget(createWidget()); + await tester.pump(); + + final card = tester.widget(find.byType(CreditCard)); + expect(card.username, 'Jane Doe'); + }); + + testWidgets('displays default username when userName is null', + (tester) async { + when(() => mockController.userName).thenReturn(null); + await tester.pumpWidget(createWidget()); + await tester.pump(); + + final card = tester.widget(find.byType(CreditCard)); + expect(card.username, 'John Doe'); + }); + + testWidgets('displays correct balance with two decimal places', + (tester) async { + when(() => mockController.userBalance).thenReturn(42.75); + await tester.pumpWidget(createWidget()); + await tester.pump(); + + expect(find.text('Loyalty Balance: \$42.75'), findsOneWidget); + }); + + testWidgets('displays default balance when userBalance is null', + (tester) async { + when(() => mockController.userBalance).thenReturn(null); + await tester.pumpWidget(createWidget()); + await tester.pump(); + + expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget); + }); + + testWidgets('displays Load card button', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pump(); + expect(find.text('Load card'), findsOneWidget); + }); + + testWidgets('displays info button', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pump(); + expect(find.byIcon(Icons.info_outline), findsOneWidget); + }); + + testWidgets('displays zero balance correctly', (tester) async { + when(() => mockController.userBalance).thenReturn(0.0); + await tester.pumpWidget(createWidget()); + await tester.pump(); + expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget); + }); + + testWidgets('displays large balance correctly', (tester) async { + when(() => mockController.userBalance).thenReturn(9999.99); + await tester.pumpWidget(createWidget()); + await tester.pump(); + expect(find.text('Loyalty Balance: \$9999.99'), findsOneWidget); + }); + }); + + group('Transactions', () { + testWidgets('shows No transactions found when list is empty', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pump(); + expect(find.text('No transactions found.'), findsOneWidget); + expect(find.text('Transactions'), findsNothing); + }); + + testWidgets('shows transaction header when transactions exist', + (tester) async { + when(() => mockController.recentTransactions) + .thenReturn(['Test transaction']); + await tester.pumpWidget(createWidget()); + await tester.pump(); + expect(find.text('Transactions'), findsOneWidget); + }); + + testWidgets('shows Show More when showPastTransactions is false', + (tester) async { + when(() => mockController.recentTransactions) + .thenReturn(['Test transaction']); + when(() => mockController.showPastTransactions).thenReturn(false); + await tester.pumpWidget(createWidget()); + await tester.pump(); + expect(find.text('Show More'), findsOneWidget); + expect(find.byIcon(Icons.expand_more), findsOneWidget); + }); + + testWidgets('shows Show Less when showPastTransactions is true', + (tester) async { + when(() => mockController.recentTransactions) + .thenReturn(['Test transaction']); + when(() => mockController.showPastTransactions).thenReturn(true); + await tester.pumpWidget(createWidget()); + await tester.pump(); + expect(find.text('Show Less'), findsOneWidget); + expect(find.byIcon(Icons.expand_less), findsOneWidget); + }); + + testWidgets('calls toggleTransactionView when Show More tapped', + (tester) async { + when(() => mockController.recentTransactions) + .thenReturn(['Test transaction']); + await tester.pumpWidget(createWidget()); + await tester.pump(); + await tester.tap(find.text('Show More')); + await tester.pump(); + verify(() => mockController.toggleTransactionView()).called(1); + }); + + testWidgets('calls toggleTransactionView when Show Less tapped', + (tester) async { + when(() => mockController.recentTransactions) + .thenReturn(['Test transaction']); + when(() => mockController.showPastTransactions).thenReturn(true); + await tester.pumpWidget(createWidget()); + await tester.pump(); + await tester.tap(find.text('Show Less')); + await tester.pump(); + verify(() => mockController.toggleTransactionView()).called(1); + }); + + testWidgets('displays transaction cards with receipt icon', (tester) async { + when(() => mockController.recentTransactions) + .thenReturn(['Test transaction']); + await tester.pumpWidget(createWidget()); + await tester.pump(); + expect(find.byType(Card, skipOffstage: false), findsWidgets); + expect(find.byIcon(Icons.receipt_long, skipOffstage: false), + findsOneWidget); + }); + }); + + group('Error handling', () { + testWidgets('shows error dialog when errorMessage is set', (tester) async { + when(() => mockController.errorMessage).thenReturn('Test error message'); + await tester.pumpWidget(createWidget()); + await tester.pump(); + await tester.pump(); + + expect(find.text('Error'), findsOneWidget); + expect(find.text('Test error message'), findsOneWidget); + expect(find.byIcon(Icons.error), findsOneWidget); + }); + + testWidgets('navigates to /scanner when error is "Failed to fetch balance"', + (tester) async { + when(() => mockController.errorMessage) + .thenReturn('Failed to fetch balance'); + await tester.pumpWidget(createWidget()); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(find.text('Scanner'), findsOneWidget); + }); + + testWidgets('navigates to /login for other errors', (tester) async { + when(() => mockController.errorMessage).thenReturn('User not known'); + await tester.pumpWidget(createWidget()); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(find.text('Login'), findsOneWidget); + }); + + testWidgets('displays only one error dialog', (tester) async { + when(() => mockController.errorMessage) + .thenReturn('Something went wrong'); + await tester.pumpWidget(createWidget()); + await tester.pump(); + await tester.pump(); + + expect(find.byType(AlertDialog), findsOneWidget); + }); + }); + + group('Reward info dialog', () { + testWidgets('opens reward info dialog when info button tapped', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.info_outline)); + await tester.pumpAndSettle(); + + expect(find.text('Rewards program'), findsOneWidget); + expect( + find.text( + 'For every \$20 you spend, you get an extra \$5 automatically added to your loyalty balance.', + ), + findsOneWidget, + ); + }); + + testWidgets('closes reward info dialog when Got it tapped', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.info_outline)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Got it')); + await tester.pumpAndSettle(); + + expect(find.text('Rewards program'), findsNothing); + }); + }); + + group('Payment handling', () { + testWidgets('calls loadCard with correct amount on Pay tap', (tester) async { + when(() => mockController.loadCard(any())) + .thenAnswer((_) async => PaymentResult.success); + + await tester.pumpWidget(createWidget()); + await tester.pump(); + + await tester.tap(find.text('Load card')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Pay')); + await tester.pumpAndSettle(); + + verify(() => mockController.loadCard(1.0)).called(1); + }); + + testWidgets('shows success dialog on successful payment', (tester) async { + when(() => mockController.loadCard(any())) + .thenAnswer((_) async => PaymentResult.success); + + await tester.pumpWidget(createWidget()); + await tester.pump(); + + await tester.tap(find.text('Load card')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Pay')); + await tester.pumpAndSettle(); + + expect(find.text('Payment Successful!'), findsOneWidget); + expect( + find.text( + 'Thank you! Your payment of \$1.00 was processed successfully.', + ), + findsOneWidget, + ); + }); + + testWidgets('calls fetchTransactions after successful payment', + (tester) async { + when(() => mockController.loadCard(any())) + .thenAnswer((_) async => PaymentResult.success); + + await tester.pumpWidget(createWidget()); + await tester.pump(); + + await tester.tap(find.text('Load card')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Pay')); + await tester.pumpAndSettle(); + + verify(() => mockController.fetchTransactions()).called(1); + }); + + testWidgets('shows canceled dialog when payment is canceled', + (tester) async { + when(() => mockController.loadCard(any())) + .thenAnswer((_) async => PaymentResult.canceled); + + await tester.pumpWidget(createWidget()); + await tester.pump(); + + await tester.tap(find.text('Load card')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Pay')); + await tester.pumpAndSettle(); + + expect(find.text('Payment Canceled'), findsOneWidget); + expect(find.text('Payment of \$1.00 was canceled.'), findsOneWidget); + }); + + testWidgets('shows failed dialog when payment fails', (tester) async { + when(() => mockController.loadCard(any())) + .thenAnswer((_) async => PaymentResult.failed); + + await tester.pumpWidget(createWidget()); + await tester.pump(); + + await tester.tap(find.text('Load card')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Pay')); + await tester.pumpAndSettle(); + + expect(find.text('Payment Failed'), findsOneWidget); + expect( + find.text( + 'An error occurred while processing your payment. Please try again.', + ), + findsOneWidget, + ); + }); + + testWidgets('does not call fetchTransactions on failed payment', + (tester) async { + when(() => mockController.loadCard(any())) + .thenAnswer((_) async => PaymentResult.failed); + + await tester.pumpWidget(createWidget()); + await tester.pump(); + + await tester.tap(find.text('Load card')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Pay')); + await tester.pumpAndSettle(); + + verifyNever(() => mockController.fetchTransactions()); + }); + + testWidgets('does not call fetchTransactions on canceled payment', + (tester) async { + when(() => mockController.loadCard(any())) + .thenAnswer((_) async => PaymentResult.canceled); + + await tester.pumpWidget(createWidget()); + await tester.pump(); + + await tester.tap(find.text('Load card')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Pay')); + await tester.pumpAndSettle(); + + verifyNever(() => mockController.fetchTransactions()); + }); + + testWidgets('handles custom amount payment', (tester) async { + when(() => mockController.loadCard(any())) + .thenAnswer((_) async => PaymentResult.success); + + await tester.pumpWidget(createWidget()); + await tester.pump(); + + await tester.tap(find.text('Load card')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('\$25')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Pay')); + await tester.pumpAndSettle(); + + verify(() => mockController.loadCard(25.0)).called(1); + }); + + testWidgets('closes success dialog when Done tapped', (tester) async { + when(() => mockController.loadCard(any())) + .thenAnswer((_) async => PaymentResult.success); + + await tester.pumpWidget(createWidget()); + await tester.pump(); + + await tester.tap(find.text('Load card')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Pay')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + + expect(find.text('Payment Successful!'), findsNothing); + }); + }); +} \ No newline at end of file diff --git a/test/features/loyalty/mocks.dart b/test/features/loyalty/mocks.dart new file mode 100644 index 0000000..5f7fc9e --- /dev/null +++ b/test/features/loyalty/mocks.dart @@ -0,0 +1,16 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; +import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; +import 'package:clean_stream_laundry_app/features/loyalty/controller.dart'; + +class MockAuthService extends Mock implements AuthService {} + +class MockProfileService extends Mock implements ProfileService {} + +class MockTransactionService extends Mock implements TransactionService {} + +class MockPaymentProcessor extends Mock implements PaymentProcessor {} + +class MockLoyaltyController extends Mock implements LoyaltyController {} \ No newline at end of file diff --git a/test/features/loyalty/widgets/credit_card_test.dart b/test/features/loyalty/widgets/credit_card_test.dart index 2813a71..eb8adb7 100644 --- a/test/features/loyalty/widgets/credit_card_test.dart +++ b/test/features/loyalty/widgets/credit_card_test.dart @@ -95,7 +95,5 @@ void main() { expect(find.text("John Doe"), findsOneWidget); }); - - }); } diff --git a/test/features/loyalty/widgets/header_test.dart b/test/features/loyalty/widgets/header_test.dart new file mode 100644 index 0000000..e15a12e --- /dev/null +++ b/test/features/loyalty/widgets/header_test.dart @@ -0,0 +1,90 @@ +import 'package:clean_stream_laundry_app/features/loyalty/widgets/header.dart'; +import 'package:clean_stream_laundry_app/features/loyalty/widgets/credit_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import '../mocks.dart'; + +void main() { + late MockLoyaltyController mockController; + + setUp(() { + mockController = MockLoyaltyController(); + when(() => mockController.userName).thenReturn('Jane Doe'); + when(() => mockController.userBalance).thenReturn(50.0); + when(() => mockController.userReward).thenReturn(5.0); + }); + + Widget buildWidget({VoidCallback? onInfoTap}) { + return MaterialApp( + home: Scaffold( + body: Header( + controller: mockController, + onInfoTap: onInfoTap ?? () {}, + ), + ), + ); + } + + group('LoyaltyHeader', () { + group('Rendering', () { + testWidgets('shows CreditCard widget', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byType(CreditCard), findsOneWidget); + }); + + testWidgets('passes username to CreditCard', (tester) async { + await tester.pumpWidget(buildWidget()); + final card = tester.widget(find.byType(CreditCard)); + expect(card.username, 'Jane Doe'); + }); + + testWidgets('shows default username when userName is null', (tester) async { + when(() => mockController.userName).thenReturn(null); + await tester.pumpWidget(buildWidget()); + final card = tester.widget(find.byType(CreditCard)); + expect(card.username, 'John Doe'); + }); + + testWidgets('displays formatted balance', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.text('Loyalty Balance: \$50.00'), findsOneWidget); + }); + + testWidgets('displays default balance when userBalance is null', + (tester) async { + when(() => mockController.userBalance).thenReturn(null); + await tester.pumpWidget(buildWidget()); + expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget); + }); + + testWidgets('displays reward countdown text', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.text('\$15.00 until next reward'), findsOneWidget); + }); + + testWidgets('displays reward countdown as 20.00 when userReward is null', + (tester) async { + when(() => mockController.userReward).thenReturn(null); + await tester.pumpWidget(buildWidget()); + expect(find.text('\$20.00 until next reward'), findsOneWidget); + }); + + testWidgets('shows info_outline icon button', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byIcon(Icons.info_outline), findsOneWidget); + }); + }); + + group('Info button', () { + testWidgets('calls onInfoTap when info button tapped', (tester) async { + var tapped = false; + await tester.pumpWidget(buildWidget(onInfoTap: () => tapped = true)); + + await tester.tap(find.byIcon(Icons.info_outline)); + + expect(tapped, isTrue); + }); + }); + }); +} \ No newline at end of file diff --git a/test/features/loyalty/widgets/load_card_dialog_test.dart b/test/features/loyalty/widgets/load_card_dialog_test.dart new file mode 100644 index 0000000..622840d --- /dev/null +++ b/test/features/loyalty/widgets/load_card_dialog_test.dart @@ -0,0 +1,193 @@ +import 'package:clean_stream_laundry_app/features/loyalty/widgets/load_card_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget({Future Function(double)? onPay}) { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showDialog( + context: context, + builder: (_) => LoadCardDialog(onPay: onPay ?? (_) async {}), + ), + child: const Text('Open'), + ), + ), + ), + ); + } + + Future openDialog(WidgetTester tester, + {Future Function(double)? onPay}) async { + await tester.pumpWidget(buildWidget(onPay: onPay)); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + } + + group('LoadCardDialog', () { + group('Initial state', () { + testWidgets('shows Load Loyalty Card title', (tester) async { + await openDialog(tester); + expect(find.text('Load Loyalty Card'), findsOneWidget); + }); + + testWidgets('initialises amount at \$1.00', (tester) async { + await openDialog(tester); + expect(find.text('\$1.00'), findsOneWidget); + }); + + testWidgets('shows instruction text', (tester) async { + await openDialog(tester); + expect( + find.text('Select an amount to add to your card.'), + findsOneWidget, + ); + }); + + testWidgets('shows Cancel and Pay buttons', (tester) async { + await openDialog(tester); + expect(find.text('Cancel'), findsOneWidget); + expect(find.widgetWithText(ElevatedButton, 'Pay'), findsOneWidget); + }); + + testWidgets('shows preset chip amounts', (tester) async { + await openDialog(tester); + expect(find.text('\$10'), findsOneWidget); + expect(find.text('\$15'), findsOneWidget); + expect(find.text('\$25'), findsOneWidget); + }); + + testWidgets('shows slider at initial value', (tester) async { + await openDialog(tester); + final slider = tester.widget(find.byType(Slider)); + expect(slider.value, 1.0); + }); + + testWidgets('decrement button is disabled at minimum', (tester) async { + await openDialog(tester); + final button = tester.widget( + find.widgetWithText(OutlinedButton, '-25¢'), + ); + expect(button.onPressed, isNull); + }); + }); + + group('Amount controls', () { + testWidgets('increments by 25¢ when +25¢ tapped', (tester) async { + await openDialog(tester); + await tester.tap(find.text('+25¢')); + await tester.pumpAndSettle(); + expect(find.text('\$1.25'), findsOneWidget); + }); + + testWidgets('decrements by 25¢ when -25¢ tapped after increment', + (tester) async { + await openDialog(tester); + await tester.tap(find.text('+25¢')); + await tester.pumpAndSettle(); + await tester.tap(find.text('-25¢')); + await tester.pumpAndSettle(); + expect(find.text('\$1.00'), findsOneWidget); + }); + + testWidgets('selects \$10 chip', (tester) async { + await openDialog(tester); + await tester.tap(find.text('\$10')); + await tester.pumpAndSettle(); + expect(find.text('\$10.00'), findsOneWidget); + }); + + testWidgets('selects \$15 chip', (tester) async { + await openDialog(tester); + await tester.tap(find.text('\$15')); + await tester.pumpAndSettle(); + expect(find.text('\$15.00'), findsOneWidget); + }); + + testWidgets('selects \$25 chip', (tester) async { + await openDialog(tester); + await tester.tap(find.text('\$25')); + await tester.pumpAndSettle(); + expect(find.text('\$25.00'), findsOneWidget); + }); + + testWidgets('does not go below \$1.00', (tester) async { + await openDialog(tester); + final decrementButton = + find.widgetWithText(OutlinedButton, '-25¢'); + await tester.tap(decrementButton); + await tester.pumpAndSettle(); + expect(find.text('\$1.00'), findsOneWidget); + }); + + testWidgets('caps at \$500.00 and disables increment', (tester) async { + await openDialog(tester); + + final incrementButton = find.text('+25¢'); + for (int i = 0; i < 2000; i++) { + await tester.tap(incrementButton); + await tester.pump(); + } + await tester.pumpAndSettle(); + + expect(find.text('\$500.00'), findsOneWidget); + final button = tester.widget( + find.widgetWithText(OutlinedButton, '+25¢'), + ); + expect(button.onPressed, isNull); + }); + }); + + group('Dialog actions', () { + testWidgets('closes when Cancel tapped', (tester) async { + await openDialog(tester); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.text('Load Loyalty Card'), findsNothing); + }); + + testWidgets('calls onPay with correct amount', (tester) async { + double? capturedAmount; + await openDialog( + tester, + onPay: (amount) async => capturedAmount = amount, + ); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Pay')); + await tester.pumpAndSettle(); + + expect(capturedAmount, 1.0); + }); + + testWidgets('calls onPay with chip-selected amount', (tester) async { + double? capturedAmount; + await openDialog( + tester, + onPay: (amount) async => capturedAmount = amount, + ); + + await tester.tap(find.text('\$25')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Pay')); + await tester.pumpAndSettle(); + + expect(capturedAmount, 25.0); + }); + + testWidgets('closes dialog before calling onPay', (tester) async { + await openDialog( + tester, + onPay: (_) async {}, + ); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Pay')); + await tester.pumpAndSettle(); + + expect(find.text('Load Loyalty Card'), findsNothing); + }); + }); + }); +} \ No newline at end of file diff --git a/test/features/loyalty/widgets/transaction_list_test.dart b/test/features/loyalty/widgets/transaction_list_test.dart new file mode 100644 index 0000000..b251486 --- /dev/null +++ b/test/features/loyalty/widgets/transaction_list_test.dart @@ -0,0 +1,122 @@ +import 'package:clean_stream_laundry_app/features/loyalty/widgets/transaction_list.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import '../mocks.dart'; + +void main() { + late MockLoyaltyController mockController; + + setUpAll(() { + registerFallbackValue((_) {}); + }); + + setUp(() { + mockController = MockLoyaltyController(); + when(() => mockController.recentTransactions).thenReturn([]); + when(() => mockController.showPastTransactions).thenReturn(false); + when(() => mockController.toggleTransactionView()).thenAnswer((_) async {}); + }); + + Widget buildWidget() { + return MaterialApp( + home: Scaffold( + body: TransactionList(controller: mockController), + ), + ); + } + + group('TransactionList', () { + group('Empty state', () { + testWidgets('shows No transactions found text', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.text('No transactions found.'), findsOneWidget); + }); + + testWidgets('does not show Transactions header', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.text('Transactions'), findsNothing); + }); + + testWidgets('does not show ListView', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byType(ListView), findsNothing); + }); + }); + + group('Populated state', () { + setUp(() { + when(() => mockController.recentTransactions) + .thenReturn(['Transaction A', 'Transaction B']); + }); + + testWidgets('shows Transactions header', (tester) async { + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.text('Transactions'), findsOneWidget); + }); + + testWidgets('shows transaction items', (tester) async { + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.text('Transaction A'), findsOneWidget); + expect(find.text('Transaction B'), findsOneWidget); + }); + + testWidgets('shows receipt_long icon for each transaction', (tester) async { + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.byIcon(Icons.receipt_long), findsNWidgets(2)); + }); + + testWidgets('wraps each transaction in a Card', (tester) async { + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.byType(Card), findsWidgets); + }); + }); + + group('Toggle button', () { + setUp(() { + when(() => mockController.recentTransactions) + .thenReturn(['Transaction A']); + }); + + testWidgets('shows Show More when showPastTransactions is false', + (tester) async { + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.text('Show More'), findsOneWidget); + expect(find.byIcon(Icons.expand_more), findsOneWidget); + }); + + testWidgets('shows Show Less when showPastTransactions is true', + (tester) async { + when(() => mockController.showPastTransactions).thenReturn(true); + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.text('Show Less'), findsOneWidget); + expect(find.byIcon(Icons.expand_less), findsOneWidget); + }); + + testWidgets('calls toggleTransactionView when Show More tapped', + (tester) async { + await tester.pumpWidget(buildWidget()); + await tester.pump(); + await tester.tap(find.text('Show More')); + await tester.pump(); + verify(() => mockController.toggleTransactionView()).called(1); + }); + + testWidgets('calls toggleTransactionView when Show Less tapped', + (tester) async { + when(() => mockController.showPastTransactions).thenReturn(true); + await tester.pumpWidget(buildWidget()); + await tester.pump(); + await tester.tap(find.text('Show Less')); + await tester.pump(); + verify(() => mockController.toggleTransactionView()).called(1); + }); + }); + }); +} \ No newline at end of file From 4aabc22dda39770e10114af92d8cbfc88401edca Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 21 Mar 2026 10:52:40 -0400 Subject: [PATCH 22/79] Separates monthly report --- lib/features/monthly_report/controller.dart | 47 ++++ .../monthly_report/monthly_report.dart | 204 ++++++++++++++++++ .../monthly_report/widgets/month_card.dart | 91 ++++++++ .../widgets/transaction_row.dart | 37 ++++ 4 files changed, 379 insertions(+) create mode 100644 lib/features/monthly_report/controller.dart create mode 100644 lib/features/monthly_report/monthly_report.dart create mode 100644 lib/features/monthly_report/widgets/month_card.dart create mode 100644 lib/features/monthly_report/widgets/transaction_row.dart diff --git a/lib/features/monthly_report/controller.dart b/lib/features/monthly_report/controller.dart new file mode 100644 index 0000000..99a9b66 --- /dev/null +++ b/lib/features/monthly_report/controller.dart @@ -0,0 +1,47 @@ +import 'package:clean_stream_laundry_app/logic/parsing/transaction_parser.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class MonthlyReportController extends ChangeNotifier { + final List> transactions; + + int? _selectedYear; + + MonthlyReportController({required this.transactions}); + + late final Map> monthlySums = + TransactionParser.getMonthlySums(transactions); + + late final List sortedMonths = monthlySums.keys.toList() + ..sort((a, b) { + final dateA = DateFormat('MMM yyyy').parse(a); + final dateB = DateFormat('MMM yyyy').parse(b); + return dateB.compareTo(dateA); + }); + + late final List availableYears = () { + final years = sortedMonths + .map((month) => DateFormat('MMM yyyy').parse(month).year) + .toSet() + .toList() + ..sort((a, b) => b.compareTo(a)); + if (years.isEmpty) years.add(DateTime.now().year); + return years; + }(); + + int get selectedYear => + (_selectedYear != null && availableYears.contains(_selectedYear)) + ? _selectedYear! + : availableYears.first; + + List get filteredMonths => sortedMonths.where((month) { + final date = DateFormat('MMM yyyy').parse(month); + return date.year == selectedYear; + }).toList(); + + void selectYear(int year) { + if (year == selectedYear) return; + _selectedYear = year; + notifyListeners(); + } +} \ No newline at end of file diff --git a/lib/features/monthly_report/monthly_report.dart b/lib/features/monthly_report/monthly_report.dart new file mode 100644 index 0000000..aa2f00d --- /dev/null +++ b/lib/features/monthly_report/monthly_report.dart @@ -0,0 +1,204 @@ +import 'controller.dart'; +import 'widgets/month_card.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class MonthlyReport extends StatefulWidget { + final List> transactions; + + const MonthlyReport({super.key, required this.transactions}); + + @override + State createState() => _MonthlyReportState(); +} + +class _MonthlyReportState extends State { + late final MonthlyReportController _controller; + + @override + void initState() { + super.initState(); + _controller = MonthlyReportController(transactions: widget.transactions); + _controller.addListener(() { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _showYearPickerSheet( + Color cardBackgroundColor, + Color cardTextColor, + ) async { + await showModalBottomSheet( + context: context, + backgroundColor: cardBackgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetContext) => SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + 'Year: ${_controller.selectedYear}', + style: TextStyle( + fontWeight: FontWeight.bold, + color: cardTextColor, + ), + ), + ), + const Divider(height: 1), + ..._controller.availableYears.map( + (year) => ListTile( + key: ValueKey('year-option-$year'), + title: Text( + year.toString(), + style: TextStyle(color: cardTextColor), + ), + trailing: year == _controller.selectedYear + ? Icon( + Icons.check, + color: Theme.of(context).colorScheme.primary, + ) + : null, + onTap: () { + Navigator.of(sheetContext).pop(); + _controller.selectYear(year); + }, + ), + ), + ], + ), + ), + ), + ); + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final cardBackgroundColor = colorScheme.cardPrimary; + final cardTextColor = + ThemeData.estimateBrightnessForColor(cardBackgroundColor) == + Brightness.dark + ? Colors.white + : Colors.black; + + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + flexibleSpace: Container( + decoration: BoxDecoration(gradient: colorScheme.primaryGradient), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => context.pop(), + ), + title: const Text( + 'Monthly Transaction History', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + centerTitle: true, + elevation: 2, + actions: [ + IconButton( + key: const ValueKey('year-filter-button'), + onPressed: () => + _showYearPickerSheet(cardBackgroundColor, cardTextColor), + icon: const Icon(Icons.filter_list, color: Colors.white), + tooltip: 'Filter by year', + ), + const SizedBox(width: 8), + ], + ), + body: _buildBody(cardBackgroundColor, cardTextColor, colorScheme), + ); + } + + Widget _buildBody( + Color cardBackgroundColor, + Color cardTextColor, + dynamic colorScheme, + ) { + final filteredMonths = _controller.filteredMonths; + + if (filteredMonths.isEmpty) { + return const Center( + child: Text( + 'No transactions found for this time range.', + style: TextStyle(fontSize: 16), + ), + ); + } + + // Pre-filter to cards with real data so itemCount is accurate + final visibleMonths = filteredMonths.where((month) { + final data = _controller.monthlySums[month]!; + final total = + data['directWasher']! + data['directDryer']! + data['loyaltyCard']!; + return !(total == 0 && + data['loyaltyWasher'] == 0 && + data['loyaltyDryer'] == 0); + }).toList(); + + if (visibleMonths.isEmpty) { + return const Center( + child: Text( + 'No transactions found for this time range.', + style: TextStyle(fontSize: 16), + ), + ); + } + + final scrollController = ScrollController(); + + return Theme( + data: Theme.of(context).copyWith( + scrollbarTheme: ScrollbarThemeData( + thumbColor: WidgetStateProperty.all(colorScheme.primary), + trackColor: WidgetStateProperty.all(Colors.transparent), + thickness: WidgetStateProperty.all(8), + radius: const Radius.circular(4), + ), + ), + child: Scrollbar( + controller: scrollController, + thumbVisibility: true, + interactive: true, + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.all(16), + itemCount: visibleMonths.length, + itemBuilder: (context, index) { + final month = visibleMonths[index]; + final data = _controller.monthlySums[month]!; + return MonthCard( + month: month, + data: data, + cardBackgroundColor: cardBackgroundColor, + cardTextColor: cardTextColor, + primaryColor: colorScheme.primary, + ); + }, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/monthly_report/widgets/month_card.dart b/lib/features/monthly_report/widgets/month_card.dart new file mode 100644 index 0000000..092fc96 --- /dev/null +++ b/lib/features/monthly_report/widgets/month_card.dart @@ -0,0 +1,91 @@ +import 'transaction_row.dart'; +import 'package:flutter/material.dart'; + +class MonthCard extends StatelessWidget { + final String month; + final Map data; + final Color cardBackgroundColor; + final Color cardTextColor; + final Color primaryColor; + + const MonthCard({ + super.key, + required this.month, + required this.data, + required this.cardBackgroundColor, + required this.cardTextColor, + required this.primaryColor, + }); + + @override + Widget build(BuildContext context) { + final total = + data['directWasher']! + data['directDryer']! + data['loyaltyCard']!; + + return Card( + margin: const EdgeInsets.only(bottom: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 2, + color: cardBackgroundColor, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + month, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: cardTextColor, + ), + ), + Text( + '\$${total.toStringAsFixed(2)}', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: cardTextColor, + ), + ), + ], + ), + const Divider(height: 24), + TransactionRow( + label: 'Direct Washer Payments', + amount: data['directWasher']!, + color: cardTextColor, + ), + const SizedBox(height: 8), + TransactionRow( + label: 'Loyalty Washer Payments', + amount: data['loyaltyWasher']!, + color: primaryColor, + ), + const SizedBox(height: 8), + TransactionRow( + label: 'Direct Dryer Payments', + amount: data['directDryer']!, + color: cardTextColor, + ), + const SizedBox(height: 8), + TransactionRow( + label: 'Loyalty Dryer Payments', + amount: data['loyaltyDryer']!, + color: primaryColor, + ), + const SizedBox(height: 8), + TransactionRow( + label: 'Loyalty Card Loads', + amount: data['loyaltyCard']!, + color: cardTextColor, + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/monthly_report/widgets/transaction_row.dart b/lib/features/monthly_report/widgets/transaction_row.dart new file mode 100644 index 0000000..d4caa6b --- /dev/null +++ b/lib/features/monthly_report/widgets/transaction_row.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +class TransactionRow extends StatelessWidget { + final String label; + final double amount; + final Color color; + + const TransactionRow({ + super.key, + required this.label, + required this.amount, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: TextStyle(fontSize: 16, color: color), + ), + ), + Text( + '\$${amount.toStringAsFixed(2)}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ); + } +} \ No newline at end of file From 6bdda7ca9fe73800fe85b46f75b6d190cdd0bf15 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 21 Mar 2026 11:07:43 -0400 Subject: [PATCH 23/79] Adds tests for monthly report --- .../montlhy_report/controller_test.dart | 337 +++++++++++ .../montlhy_report/monthly_report_test.dart | 567 ++++++++++++++++++ .../widgets/month_card_test.dart | 108 ++++ .../widgets/transaction_row_test.dart | 67 +++ 4 files changed, 1079 insertions(+) create mode 100644 test/features/montlhy_report/controller_test.dart create mode 100644 test/features/montlhy_report/monthly_report_test.dart create mode 100644 test/features/montlhy_report/widgets/month_card_test.dart create mode 100644 test/features/montlhy_report/widgets/transaction_row_test.dart diff --git a/test/features/montlhy_report/controller_test.dart b/test/features/montlhy_report/controller_test.dart new file mode 100644 index 0000000..4d548d3 --- /dev/null +++ b/test/features/montlhy_report/controller_test.dart @@ -0,0 +1,337 @@ +import 'package:clean_stream_laundry_app/features/monthly_report/controller.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart'; + +void main() { + final now = DateTime.now(); + + Map tx({ + required int monthsAgo, + required String description, + required double amount, + }) { + final date = DateTime(now.year, now.month - monthsAgo, 15); + return { + 'created_at': date.toIso8601String(), + 'description': description, + 'amount': amount, + }; + } + + String monthLabel(int monthsAgo) { + final date = DateTime(now.year, now.month - monthsAgo, 1); + return DateFormat('MMM yyyy').format(date); + } + + + group('Initialization', () { + test('monthlySums contains entries for the past 12 months', () { + final controller = MonthlyReportController(transactions: []); + + expect(controller.monthlySums.length, 12); + }); + + test('sortedMonths are in descending chronological order', () { + final controller = MonthlyReportController(transactions: []); + + for (int i = 0; i < controller.sortedMonths.length - 1; i++) { + final a = DateFormat('MMM yyyy').parse(controller.sortedMonths[i]); + final b = DateFormat('MMM yyyy').parse(controller.sortedMonths[i + 1]); + expect(a.isAfter(b), isTrue, + reason: + '${controller.sortedMonths[i]} should come before ${controller.sortedMonths[i + 1]}'); + } + }); + + test('availableYears contains current year when transactions are empty', () { + final controller = MonthlyReportController(transactions: []); + expect(controller.availableYears, contains(now.year)); + }); + + test('availableYears contains only unique years', () { + final controller = MonthlyReportController(transactions: [ + tx(monthsAgo: 1, description: 'Washer #1', amount: 2.50), + tx(monthsAgo: 2, description: 'Washer #2', amount: 3.00), + tx(monthsAgo: 3, description: 'Washer #3', amount: 1.75), + ]); + + final uniqueYears = controller.availableYears.toSet().toList(); + expect(controller.availableYears.length, uniqueYears.length); + }); + + test('availableYears are sorted descending', () { + final controller = MonthlyReportController(transactions: []); + + for (int i = 0; i < controller.availableYears.length - 1; i++) { + expect( + controller.availableYears[i] >= controller.availableYears[i + 1], + isTrue, + ); + } + }); + }); + + group('selectedYear', () { + test('defaults to the most recent available year', () { + final controller = MonthlyReportController(transactions: [ + tx(monthsAgo: 1, description: 'Washer #1', amount: 2.50), + ]); + + expect(controller.selectedYear, controller.availableYears.first); + }); + + test('defaults to current year when no transactions provided', () { + final controller = MonthlyReportController(transactions: []); + expect(controller.selectedYear, now.year); + }); + + test('falls back to availableYears.first when invalid year set internally', + () { + final controller = MonthlyReportController(transactions: [ + tx(monthsAgo: 1, description: 'Washer #1', amount: 2.50), + ]); + + + expect( + controller.availableYears.contains(controller.selectedYear), + isTrue, + ); + }); + }); + + + group('selectYear', () { + test('updates selectedYear to the new value', () { + final olderDate = DateTime(now.year, now.month - 11, 15); + final controller = MonthlyReportController(transactions: [ + tx(monthsAgo: 1, description: 'Washer #1', amount: 2.50), + { + 'created_at': olderDate.toIso8601String(), + 'description': 'Washer #2', + 'amount': 3.00, + }, + ]); + + final targetYear = controller.availableYears.last; + controller.selectYear(targetYear); + + expect(controller.selectedYear, targetYear); + }); + + test('notifies listeners when year changes', () { + final olderDate = DateTime(now.year, now.month - 11, 15); + final controller = MonthlyReportController(transactions: [ + tx(monthsAgo: 1, description: 'Washer #1', amount: 2.50), + { + 'created_at': olderDate.toIso8601String(), + 'description': 'Washer #2', + 'amount': 3.00, + }, + ]); + + var notifyCount = 0; + controller.addListener(() => notifyCount++); + + final targetYear = controller.availableYears.last; + if (targetYear != controller.selectedYear) { + controller.selectYear(targetYear); + expect(notifyCount, 1); + } + }); + + test('does not notify when same year is selected again', () { + final controller = MonthlyReportController(transactions: [ + tx(monthsAgo: 1, description: 'Washer #1', amount: 2.50), + ]); + + var notifyCount = 0; + controller.addListener(() => notifyCount++); + + controller.selectYear(controller.selectedYear); + + expect(notifyCount, 0); + }); + + test('calling selectYear multiple times only notifies once per change', () { + final olderDate = DateTime(now.year, now.month - 11, 15); + final controller = MonthlyReportController(transactions: [ + tx(monthsAgo: 1, description: 'Washer #1', amount: 2.50), + { + 'created_at': olderDate.toIso8601String(), + 'description': 'Washer #2', + 'amount': 3.00, + }, + ]); + + var notifyCount = 0; + controller.addListener(() => notifyCount++); + + final other = controller.availableYears.last; + if (other != controller.selectedYear) { + controller.selectYear(other); + controller.selectYear(other); + expect(notifyCount, 1); + } + }); + }); + + + group('filteredMonths', () { + test('returns only months belonging to selectedYear', () { + final controller = MonthlyReportController(transactions: [ + tx(monthsAgo: 1, description: 'Washer #1', amount: 2.50), + tx(monthsAgo: 2, description: 'Washer #2', amount: 3.00), + ]); + + for (final month in controller.filteredMonths) { + final date = DateFormat('MMM yyyy').parse(month); + expect(date.year, controller.selectedYear); + } + }); + + test('includes month labels that correspond to actual transaction months', + () { + final controller = MonthlyReportController(transactions: [ + tx(monthsAgo: 1, description: 'Washer #1', amount: 2.50), + ]); + + expect( + controller.filteredMonths, + contains(monthLabel(1)), + ); + }); + + test('is empty when no months fall in selected year', () { + + final controller = MonthlyReportController(transactions: []); + + final currentYearMonths = controller.filteredMonths.where((m) { + return DateFormat('MMM yyyy').parse(m).year == now.year; + }).toList(); + + expect(currentYearMonths, isNotEmpty); + }); + + test('updates after selectYear call', () { + final olderDate = DateTime(now.year, now.month - 11, 15); + final controller = MonthlyReportController(transactions: [ + tx(monthsAgo: 1, description: 'Washer #1', amount: 2.50), + { + 'created_at': olderDate.toIso8601String(), + 'description': 'Washer #2', + 'amount': 3.00, + }, + ]); + + final beforeYear = controller.selectedYear; + final beforeMonths = List.from(controller.filteredMonths); + + final otherYear = controller.availableYears.last; + if (otherYear != beforeYear) { + controller.selectYear(otherYear); + + for (final month in controller.filteredMonths) { + final date = DateFormat('MMM yyyy').parse(month); + expect(date.year, otherYear); + } + + expect(controller.filteredMonths, isNot(equals(beforeMonths))); + } + }); + + test('returns months in descending order', () { + final controller = MonthlyReportController(transactions: [ + tx(monthsAgo: 1, description: 'Washer #1', amount: 2.50), + tx(monthsAgo: 2, description: 'Washer #2', amount: 3.00), + tx(monthsAgo: 3, description: 'Washer #3', amount: 1.75), + ]); + + final filtered = controller.filteredMonths; + for (int i = 0; i < filtered.length - 1; i++) { + final a = DateFormat('MMM yyyy').parse(filtered[i]); + final b = DateFormat('MMM yyyy').parse(filtered[i + 1]); + expect(a.isAfter(b), isTrue); + } + }); + }); + + group('monthlySums values', () { + test('aggregates direct washer payments into directWasher', () { + final controller = MonthlyReportController(transactions: [ + tx(monthsAgo: 1, description: 'Washer #1', amount: 2.50), + tx(monthsAgo: 1, description: 'Washer #2', amount: 3.00), + ]); + + final data = controller.monthlySums[monthLabel(1)]!; + expect(data['directWasher'], closeTo(5.50, 0.001)); + }); + + test('aggregates loyalty washer payments into loyaltyWasher', () { + final controller = MonthlyReportController(transactions: [ + tx(monthsAgo: 1, + description: 'Loyalty Payment on Washer #1', + amount: 2.00), + ]); + + final data = controller.monthlySums[monthLabel(1)]!; + expect(data['loyaltyWasher'], closeTo(2.00, 0.001)); + }); + + test('aggregates direct dryer payments into directDryer', () { + final controller = MonthlyReportController(transactions: [ + tx(monthsAgo: 1, description: 'Dryer #1', amount: 1.75), + tx(monthsAgo: 1, description: 'Dryer #2', amount: 1.25), + ]); + + final data = controller.monthlySums[monthLabel(1)]!; + expect(data['directDryer'], closeTo(3.00, 0.001)); + }); + + test('aggregates loyalty dryer payments into loyaltyDryer', () { + final controller = MonthlyReportController(transactions: [ + tx(monthsAgo: 1, + description: 'Loyalty Payment on Dryer #1', + amount: 1.50), + ]); + + final data = controller.monthlySums[monthLabel(1)]!; + expect(data['loyaltyDryer'], closeTo(1.50, 0.001)); + }); + + test('aggregates loyalty card loads into loyaltyCard', () { + final controller = MonthlyReportController(transactions: [ + tx(monthsAgo: 1, description: 'loyalty card', amount: 10.00), + tx(monthsAgo: 1, description: 'loyalty card', amount: 20.00), + ]); + + final data = controller.monthlySums[monthLabel(1)]!; + expect(data['loyaltyCard'], closeTo(30.00, 0.001)); + }); + + test('all categories default to zero when no transactions', () { + final controller = MonthlyReportController(transactions: []); + + final data = controller.monthlySums[monthLabel(1)]!; + expect(data['directWasher'], 0.0); + expect(data['loyaltyWasher'], 0.0); + expect(data['directDryer'], 0.0); + expect(data['loyaltyDryer'], 0.0); + expect(data['loyaltyCard'], 0.0); + }); + + test('does not include current month transactions', () { + final now = DateTime.now(); + final controller = MonthlyReportController(transactions: [ + { + 'created_at': + DateTime(now.year, now.month, 10).toIso8601String(), + 'description': 'Washer #1', + 'amount': 5.00, + }, + ]); + + final currentMonthKey = DateFormat('MMM yyyy').format(now); + expect(controller.monthlySums.containsKey(currentMonthKey), isFalse); + }); + }); +} \ No newline at end of file diff --git a/test/features/montlhy_report/monthly_report_test.dart b/test/features/montlhy_report/monthly_report_test.dart new file mode 100644 index 0000000..8759194 --- /dev/null +++ b/test/features/montlhy_report/monthly_report_test.dart @@ -0,0 +1,567 @@ +import 'package:clean_stream_laundry_app/features/monthly_report/monthly_report.dart'; +import 'package:clean_stream_laundry_app/features/monthly_report/controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +void main() { + Map createTransaction({ + required int monthsAgo, + required String description, + required double amount, + }) { + final now = DateTime.now(); + final date = DateTime(now.year, now.month - monthsAgo, 15); + return { + 'created_at': date.toIso8601String(), + 'description': description, + 'amount': amount, + }; + } + + Widget createWidget(List> transactions) { + return MaterialApp.router( + routerConfig: GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, __) => + MonthlyReport(transactions: transactions), + ), + ], + ), + ); + } + + Future selectYearFilter(WidgetTester tester, int year) async { + await tester.tap(find.byKey(const ValueKey('year-filter-button'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(ValueKey('year-option-$year'))); + await tester.pumpAndSettle(); + } + + group('AppBar', () { + testWidgets('displays Monthly Transaction History title', (tester) async { + await tester.pumpWidget(createWidget([])); + await tester.pumpAndSettle(); + expect(find.text('Monthly Transaction History'), findsOneWidget); + }); + + testWidgets('displays back button', (tester) async { + await tester.pumpWidget(createWidget([])); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.arrow_back), findsOneWidget); + }); + + testWidgets('displays year filter button', (tester) async { + await tester.pumpWidget(createWidget([])); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('year-filter-button')), findsOneWidget); + }); + + testWidgets('back button pops the route', (tester) async { + await tester.pumpWidget(createWidget([])); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNotNull); + }); + }); + + group('Empty state', () { + testWidgets('shows no cards when transactions are empty', (tester) async { + await tester.pumpWidget(createWidget([])); + await tester.pumpAndSettle(); + expect(find.byType(Card), findsNothing); + }); + + testWidgets('shows no transactions message when empty', (tester) async { + await tester.pumpWidget(createWidget([])); + await tester.pumpAndSettle(); + expect( + find.text('No transactions found for this time range.'), + findsOneWidget, + ); + }); + }); + + group('Content display', () { + testWidgets('displays card for month with transactions', (tester) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + + final year = DateTime.parse( + transactions.first['created_at'] as String) + .year; + await selectYearFilter(tester, year); + + expect(find.byType(Card), findsOneWidget); + }); + + testWidgets('displays correct monthly total', (tester) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + createTransaction(monthsAgo: 1, description: 'Dryer #3', amount: 1.75), + createTransaction( + monthsAgo: 1, description: 'loyalty card', amount: 10.00), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + expect(find.text('\$14.25'), findsOneWidget); + }); + + testWidgets('displays all five transaction category labels', (tester) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + createTransaction( + monthsAgo: 1, + description: 'Loyalty Payment on Washer #3', + amount: 2.00), + createTransaction(monthsAgo: 1, description: 'Dryer #2', amount: 1.75), + createTransaction( + monthsAgo: 1, + description: 'Loyalty Payment on Dryer #1', + amount: 1.50), + createTransaction( + monthsAgo: 1, description: 'loyalty card', amount: 10.00), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + + expect(find.text('Direct Washer Payments'), findsOneWidget); + expect(find.text('Loyalty Washer Payments'), findsOneWidget); + expect(find.text('Direct Dryer Payments'), findsOneWidget); + expect(find.text('Loyalty Dryer Payments'), findsOneWidget); + expect(find.text('Loyalty Card Loads'), findsOneWidget); + }); + + testWidgets('shows zero amounts for categories with no transactions', + (tester) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + expect(find.text('\$0.00'), findsWidgets); + }); + + testWidgets('aggregates multiple transactions in the same month', + (tester) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #1', amount: 2.50), + createTransaction(monthsAgo: 1, description: 'Washer #2', amount: 3.00), + createTransaction(monthsAgo: 1, description: 'Washer #3', amount: 2.75), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + expect(find.text('\$8.25'), findsWidgets); + expect(find.byType(Card), findsOneWidget); + }); + + testWidgets('shows multiple months', (tester) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + createTransaction(monthsAgo: 2, description: 'Washer #5', amount: 3.00), + createTransaction(monthsAgo: 3, description: 'Washer #5', amount: 3.50), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + expect(find.byType(Card), findsAtLeastNWidgets(2)); + }); + + testWidgets('sorts months in descending order', (tester) async { + final transactions = [ + createTransaction(monthsAgo: 3, description: 'Washer #5', amount: 2.50), + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 3.00), + createTransaction(monthsAgo: 2, description: 'Washer #5', amount: 2.75), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + + final firstCard = find.byType(Card).first; + final expectedMostRecent = DateFormat('MMM yyyy').format( + DateTime(DateTime.now().year, DateTime.now().month - 1, 1), + ); + expect( + find.descendant(of: firstCard, matching: find.text(expectedMostRecent)), + findsOneWidget, + ); + }); + + testWidgets('displays scrollbar', (tester) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + expect(find.byType(Scrollbar), findsOneWidget); + }); + + testWidgets('ListView has correct padding', (tester) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + final listView = tester.widget(find.byType(ListView)); + expect(listView.padding, const EdgeInsets.all(16)); + }); + + testWidgets('card has correct bottom margin', (tester) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + final card = tester.widget(find.byType(Card)); + expect(card.margin, const EdgeInsets.only(bottom: 16)); + }); + + testWidgets('displays divider between header and breakdown', (tester) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + expect(find.byType(Divider), findsOneWidget); + }); + + testWidgets('formats decimal amounts correctly', (tester) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.5), + createTransaction(monthsAgo: 1, description: 'Dryer #3', amount: 1.76), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + expect(find.text('\$2.50'), findsWidgets); + expect(find.text('\$1.76'), findsWidgets); + }); + + testWidgets('ignores current month transactions', (tester) async { + final now = DateTime.now(); + final transactions = [ + { + 'created_at': DateTime(now.year, now.month, 15).toIso8601String(), + 'description': 'Washer #5', + 'amount': 2.50, + }, + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 3.00), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + + final year = DateTime.parse( + transactions.first['created_at'] as String) + .year; + await selectYearFilter(tester, year); + + expect(find.byType(Card), findsOneWidget); + expect(find.text('\$3.00'), findsWidgets); + }); + }); + + group('Transaction categories', () { + testWidgets('handles loyalty washer payments', (tester) async { + final transactions = [ + createTransaction( + monthsAgo: 1, + description: 'Loyalty Payment on Washer #5', + amount: 2.50), + createTransaction( + monthsAgo: 1, + description: 'loyalty payment on washer #3', + amount: 3.00), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + expect(find.text('\$5.50'), findsWidgets); + }); + + testWidgets('handles loyalty dryer payments', (tester) async { + final transactions = [ + createTransaction( + monthsAgo: 1, + description: 'Loyalty Payment on Dryer #2', + amount: 1.50), + createTransaction( + monthsAgo: 1, + description: 'loyalty payment on dryer #1', + amount: 1.25), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + expect(find.text('\$2.75'), findsWidgets); + }); + + testWidgets('handles direct dryer payments', (tester) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Dryer #2', amount: 1.50), + createTransaction(monthsAgo: 1, description: 'DRYER #1', amount: 1.25), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + expect(find.text('\$2.75'), findsWidgets); + }); + + testWidgets('handles loyalty card loads', (tester) async { + final transactions = [ + createTransaction( + monthsAgo: 1, description: 'loyalty card', amount: 10.00), + createTransaction( + monthsAgo: 1, description: 'loyalty card', amount: 20.00), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + expect(find.text('\$30.00'), findsWidgets); + }); + + testWidgets('handles mixed transaction types in same month', (tester) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + createTransaction( + monthsAgo: 1, + description: 'Loyalty Payment on Washer #3', + amount: 2.00), + createTransaction(monthsAgo: 1, description: 'Dryer #2', amount: 1.75), + createTransaction( + monthsAgo: 1, + description: 'Loyalty Payment on Dryer #1', + amount: 1.50), + createTransaction( + monthsAgo: 1, description: 'loyalty card', amount: 10.00), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + expect(find.text('\$14.25'), findsOneWidget); + }); + }); + + group('Year filter', () { + testWidgets('shows year options from transaction data', (tester) async { + final newerDate = + DateTime(DateTime.now().year, DateTime.now().month - 1, 15); + final olderDate = + DateTime(DateTime.now().year, DateTime.now().month - 11, 15); + final expectedYears = {newerDate.year, olderDate.year}.toList() + ..sort((a, b) => b.compareTo(a)); + + final transactions = [ + { + 'created_at': olderDate.toIso8601String(), + 'description': 'Washer #1', + 'amount': 2.50, + }, + { + 'created_at': newerDate.toIso8601String(), + 'description': 'Dryer #1', + 'amount': 1.75, + }, + ]; + + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('year-filter-button'))); + await tester.pumpAndSettle(); + + expect( + find.text('Year: ${expectedYears.first}'), findsOneWidget); + for (final year in expectedYears) { + expect(find.byKey(ValueKey('year-option-$year')), findsOneWidget); + } + + await tester.tap( + find.byKey(ValueKey('year-option-${expectedYears.first}'))); + await tester.pumpAndSettle(); + }); + + testWidgets('selecting a year shows only that year data', (tester) async { + final newerDate = + DateTime(DateTime.now().year, DateTime.now().month - 1, 15); + final olderDate = + DateTime(DateTime.now().year, DateTime.now().month - 11, 15); + final newerYear = newerDate.year; + final olderYear = olderDate.year; + final olderMonthLabel = DateFormat('MMM yyyy') + .format(DateTime(olderDate.year, olderDate.month, 1)); + final newerMonthLabel = DateFormat('MMM yyyy') + .format(DateTime(newerDate.year, newerDate.month, 1)); + + final transactions = [ + { + 'created_at': olderDate.toIso8601String(), + 'description': 'Washer #5', + 'amount': 2.50, + }, + { + 'created_at': newerDate.toIso8601String(), + 'description': 'Dryer #2', + 'amount': 3.00, + }, + ]; + + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + + if (olderYear == newerYear) { + expect(find.text(newerMonthLabel), findsOneWidget); + expect(find.text(olderMonthLabel), findsOneWidget); + return; + } + + expect(find.text(newerMonthLabel), findsOneWidget); + expect(find.text(olderMonthLabel), findsNothing); + + await selectYearFilter(tester, olderYear); + + expect(find.text(olderMonthLabel), findsOneWidget); + expect(find.text(newerMonthLabel), findsNothing); + }); + + testWidgets('can switch between years without errors', (tester) async { + final newerDate = + DateTime(DateTime.now().year, DateTime.now().month - 1, 15); + final olderDate = + DateTime(DateTime.now().year, DateTime.now().month - 11, 15); + final olderYear = olderDate.year; + final newerYear = newerDate.year; + + final transactions = [ + { + 'created_at': olderDate.toIso8601String(), + 'description': 'Dryer #2', + 'amount': 1.75, + }, + { + 'created_at': newerDate.toIso8601String(), + 'description': 'Washer #2', + 'amount': 2.25, + }, + ]; + + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + + await selectYearFilter(tester, olderYear); + expect(tester.takeException(), isNull); + + if (olderYear != newerYear) { + await selectYearFilter(tester, newerYear); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('handles transaction from 11 months ago', (tester) async { + final transactions = [ + createTransaction( + monthsAgo: 11, description: 'Washer #5', amount: 2.50), + ]; + await tester.pumpWidget(createWidget(transactions)); + await tester.pumpAndSettle(); + + final year = DateTime.parse( + transactions.first['created_at'] as String) + .year; + await selectYearFilter(tester, year); + + expect(find.byType(Card), findsOneWidget); + }); + }); + + group('MonthlyReportController', () { + test('selectedYear defaults to most recent available year', () { + final now = DateTime.now(); + final transactions = [ + { + 'created_at': + DateTime(now.year, now.month - 1, 15).toIso8601String(), + 'description': 'Washer #1', + 'amount': 2.50, + }, + ]; + + final controller = + MonthlyReportController(transactions: transactions); + + expect(controller.selectedYear, now.year); + }); + + test('selectYear updates selectedYear and notifies listeners', () { + final now = DateTime.now(); + final olderDate = DateTime(now.year, now.month - 11, 15); + final transactions = [ + { + 'created_at': olderDate.toIso8601String(), + 'description': 'Washer #1', + 'amount': 2.50, + }, + { + 'created_at': + DateTime(now.year, now.month - 1, 15).toIso8601String(), + 'description': 'Dryer #1', + 'amount': 1.75, + }, + ]; + + final controller = + MonthlyReportController(transactions: transactions); + var notified = false; + controller.addListener(() => notified = true); + + if (olderDate.year != now.year) { + controller.selectYear(olderDate.year); + expect(controller.selectedYear, olderDate.year); + expect(notified, isTrue); + } + }); + + test('selectYear does nothing when same year selected', () { + final now = DateTime.now(); + final transactions = [ + { + 'created_at': + DateTime(now.year, now.month - 1, 15).toIso8601String(), + 'description': 'Washer #1', + 'amount': 2.50, + }, + ]; + + final controller = + MonthlyReportController(transactions: transactions); + var notified = false; + controller.addListener(() => notified = true); + + controller.selectYear(controller.selectedYear); + expect(notified, isFalse); + }); + + test('filteredMonths returns only months for selectedYear', () { + final now = DateTime.now(); + final transactions = [ + { + 'created_at': + DateTime(now.year, now.month - 1, 15).toIso8601String(), + 'description': 'Washer #1', + 'amount': 2.50, + }, + ]; + + final controller = + MonthlyReportController(transactions: transactions); + + for (final month in controller.filteredMonths) { + final date = DateFormat('MMM yyyy').parse(month); + expect(date.year, controller.selectedYear); + } + }); + + test('availableYears contains current year when no transactions', () { + final controller = MonthlyReportController(transactions: []); + expect(controller.availableYears, contains(DateTime.now().year)); + }); + }); +} \ No newline at end of file diff --git a/test/features/montlhy_report/widgets/month_card_test.dart b/test/features/montlhy_report/widgets/month_card_test.dart new file mode 100644 index 0000000..e476c5b --- /dev/null +++ b/test/features/montlhy_report/widgets/month_card_test.dart @@ -0,0 +1,108 @@ +import 'package:clean_stream_laundry_app/features/monthly_report/widgets/month_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Map buildData({ + double directWasher = 0, + double loyaltyWasher = 0, + double directDryer = 0, + double loyaltyDryer = 0, + double loyaltyCard = 0, + }) => + { + 'directWasher': directWasher, + 'loyaltyWasher': loyaltyWasher, + 'directDryer': directDryer, + 'loyaltyDryer': loyaltyDryer, + 'loyaltyCard': loyaltyCard, + 'Rewards': 0.0, + }; + + Widget buildWidget({ + String month = 'Jan 2025', + Map? data, + Color cardBackgroundColor = Colors.white, + Color cardTextColor = Colors.black, + Color primaryColor = Colors.blue, + }) { + return MaterialApp( + home: Scaffold( + body: MonthCard( + month: month, + data: data ?? buildData(), + cardBackgroundColor: cardBackgroundColor, + cardTextColor: cardTextColor, + primaryColor: primaryColor, + ), + ), + ); + } + + group('MonthReportCard', () { + group('Rendering', () { + testWidgets('displays month label', (tester) async { + await tester.pumpWidget(buildWidget(month: 'Mar 2025')); + expect(find.text('Mar 2025'), findsOneWidget); + }); + + testWidgets('displays total as sum of direct + loyalty card amounts', + (tester) async { + await tester.pumpWidget(buildWidget( + data: buildData( + directWasher: 2.50, + directDryer: 1.75, + loyaltyCard: 10.00, + ), + )); + expect(find.text('\$14.25'), findsOneWidget); + }); + + testWidgets('displays all five category labels', (tester) async { + await tester.pumpWidget(buildWidget(data: buildData())); + + expect(find.text('Direct Washer Payments'), findsOneWidget); + expect(find.text('Loyalty Washer Payments'), findsOneWidget); + expect(find.text('Direct Dryer Payments'), findsOneWidget); + expect(find.text('Loyalty Dryer Payments'), findsOneWidget); + expect(find.text('Loyalty Card Loads'), findsOneWidget); + }); + + testWidgets('displays correct amounts for each category', (tester) async { + await tester.pumpWidget(buildWidget( + data: buildData( + directWasher: 2.50, + loyaltyWasher: 1.00, + directDryer: 1.75, + loyaltyDryer: 0.50, + loyaltyCard: 5.00, + ), + )); + + expect(find.text('\$2.50'), findsWidgets); + expect(find.text('\$1.00'), findsWidgets); + expect(find.text('\$1.75'), findsWidgets); + expect(find.text('\$0.50'), findsWidgets); + expect(find.text('\$5.00'), findsWidgets); + }); + + testWidgets('displays divider', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byType(Divider), findsOneWidget); + }); + + testWidgets('card has bottom margin of 16', (tester) async { + await tester.pumpWidget(buildWidget()); + final card = tester.widget(find.byType(Card)); + expect(card.margin, const EdgeInsets.only(bottom: 16)); + }); + + testWidgets('shows zero for empty categories', (tester) async { + await tester.pumpWidget(buildWidget( + data: buildData(directWasher: 5.00), + )); + expect(find.text('\$0.00'), findsWidgets); + }); + }); + }); +} \ No newline at end of file diff --git a/test/features/montlhy_report/widgets/transaction_row_test.dart b/test/features/montlhy_report/widgets/transaction_row_test.dart new file mode 100644 index 0000000..e2c5c9f --- /dev/null +++ b/test/features/montlhy_report/widgets/transaction_row_test.dart @@ -0,0 +1,67 @@ +import 'package:clean_stream_laundry_app/features/monthly_report/widgets/transaction_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget({ + String label = 'Direct Washer Payments', + double amount = 2.50, + Color color = Colors.black, + }) { + return MaterialApp( + home: Scaffold( + body: TransactionRow( + label: label, + amount: amount, + color: color, + ), + ), + ); + } + + group('TransactionBreakdownRow', () { + testWidgets('displays the label', (tester) async { + await tester.pumpWidget(buildWidget(label: 'Direct Washer Payments')); + expect(find.text('Direct Washer Payments'), findsOneWidget); + }); + + testWidgets('displays amount formatted to two decimal places', + (tester) async { + await tester.pumpWidget(buildWidget(amount: 2.5)); + expect(find.text('\$2.50'), findsOneWidget); + }); + + testWidgets('displays zero amount correctly', (tester) async { + await tester.pumpWidget(buildWidget(amount: 0)); + expect(find.text('\$0.00'), findsOneWidget); + }); + + testWidgets('applies color to label text', (tester) async { + await tester.pumpWidget(buildWidget(color: Colors.blue)); + + final labelText = tester.widget( + find.text('Direct Washer Payments'), + ); + expect(labelText.style?.color, Colors.blue); + }); + + testWidgets('applies color to amount text', (tester) async { + await tester.pumpWidget(buildWidget(amount: 1.50, color: Colors.blue)); + + final amountText = tester.widget(find.text('\$1.50')); + expect(amountText.style?.color, Colors.blue); + }); + + testWidgets('amount text has bold weight', (tester) async { + await tester.pumpWidget(buildWidget(amount: 5.00)); + + final amountText = tester.widget(find.text('\$5.00')); + expect(amountText.style?.fontWeight, FontWeight.w600); + }); + + testWidgets('renders inside a Row', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byType(Row), findsOneWidget); + }); + }); +} \ No newline at end of file From 3cf788e5ddaba5b59356cf21d5baff3165f132ef Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 21 Mar 2026 11:11:26 -0400 Subject: [PATCH 24/79] Moves not found page to features --- lib/features/not_found/not_found.dart | 46 ++++++ test/features/not_found/mocks.dart | 4 + test/features/not_found/not_found_test.dart | 154 ++++++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 lib/features/not_found/not_found.dart create mode 100644 test/features/not_found/mocks.dart create mode 100644 test/features/not_found/not_found_test.dart diff --git a/lib/features/not_found/not_found.dart b/lib/features/not_found/not_found.dart new file mode 100644 index 0000000..05e3015 --- /dev/null +++ b/lib/features/not_found/not_found.dart @@ -0,0 +1,46 @@ +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; + +class NotFound extends StatelessWidget { + const NotFound({super.key}); + + @override + Widget build(BuildContext context) { + + final authService = GetIt.instance(); + + return Container( + color: Colors.white, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, color: Colors.redAccent, size: 80), + const SizedBox(height: 20), + const Text( + '404 - Page Not Found', + style: TextStyle( + fontSize: 24, + color: Colors.blue, + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () async { + if (await authService.isLoggedIn() == AuthenticationResponses.success){ + context.go("/homePage"); + }else{ + context.go("/login"); + } + }, + child: const Text("Go to Home"), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/test/features/not_found/mocks.dart b/test/features/not_found/mocks.dart new file mode 100644 index 0000000..53a8c48 --- /dev/null +++ b/test/features/not_found/mocks.dart @@ -0,0 +1,4 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; + +class MockAuthService extends Mock implements AuthService {} \ No newline at end of file diff --git a/test/features/not_found/not_found_test.dart b/test/features/not_found/not_found_test.dart new file mode 100644 index 0000000..fb0c8b0 --- /dev/null +++ b/test/features/not_found/not_found_test.dart @@ -0,0 +1,154 @@ +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/pages/not_found_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + + setUp(() { + mockAuthService = MockAuthService(); + + // Default stubs to prevent null errors + when( + () => mockAuthService.isLoggedIn(), + ).thenAnswer((_) async => AuthenticationResponses.failure); + when(() => mockAuthService.isEmailVerified()).thenReturn(false); + when( + () => mockAuthService.login(any(), any()), + ).thenAnswer((_) async => AuthenticationResponses.failure); + when( + () => mockAuthService.signUp(any(), any(), any()), + ).thenAnswer((_) async => AuthenticationResponses.failure); + when( + () => mockAuthService.resendVerification(), + ).thenAnswer((_) async => AuthenticationResponses.failure); + when( + () => mockAuthService.logout(), + ).thenAnswer((_) async => Future.value()); + when(() => mockAuthService.getCurrentUserId).thenReturn(null); + when( + () => mockAuthService.onAuthChange, + ).thenAnswer((_) => const Stream.empty()); + + final getIt = GetIt.instance; + if (getIt.isRegistered()) { + getIt.unregister(); + } + + getIt.registerSingleton(mockAuthService); + }); + + tearDown(() => GetIt.instance.reset()); + + Widget createWidgetUnderTest() { + final router = GoRouter( + routes: [ + GoRoute(path: '/', builder: (context, state) => const NotFoundScreen()), + GoRoute( + path: '/homePage', + builder: (context, state) => const Scaffold(body: Text('Home Page')), + ), + GoRoute( + path: '/login', + builder: (context, state) => const Scaffold(body: Text('Login Page')), + ), + ], + ); + return MaterialApp.router(routerConfig: router); + } + + void mockAuthResponse(AuthenticationResponses response) { + when(() => mockAuthService.isLoggedIn()).thenAnswer((_) async => response); + } + + group('NotFoundScreen Widget Tests', () { + testWidgets('should display 404 error icon', (WidgetTester tester) async { + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.byIcon(Icons.error_outline), findsOneWidget); + + final icon = tester.widget(find.byIcon(Icons.error_outline)); + expect(icon.size, 80); + expect(icon.color, Colors.redAccent); + }); + + testWidgets('should display 404 - Page Not Found text', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.text('404 - Page Not Found'), findsOneWidget); + + final text = tester.widget(find.text('404 - Page Not Found')); + expect(text.style?.fontSize, 24); + expect(text.style?.color, Colors.blue); + }); + + testWidgets('should display Go to Home button', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createWidgetUnderTest()); + + expect(find.text('Go to Home'), findsOneWidget); + expect(find.byType(ElevatedButton), findsOneWidget); + }); + + testWidgets('should navigate to home page when user is logged in', ( + WidgetTester tester, + ) async { + mockAuthResponse(AuthenticationResponses.success); + + await tester.pumpWidget(createWidgetUnderTest()); + + await tester.tap(find.text('Go to Home')); + await tester.pumpAndSettle(); + + expect(find.text('Home Page'), findsOneWidget); + verify(() => mockAuthService.isLoggedIn()).called(1); + }); + + testWidgets('should navigate to login page when user is not logged in', ( + WidgetTester tester, + ) async { + mockAuthResponse(AuthenticationResponses.failure); + + await tester.pumpWidget(createWidgetUnderTest()); + + await tester.tap(find.text('Go to Home')); + await tester.pumpAndSettle(); + + expect(find.text('Login Page'), findsOneWidget); + verify(() => mockAuthService.isLoggedIn()).called(1); + }); + + testWidgets('Go to Home button should be tappable', ( + WidgetTester tester, + ) async { + mockAuthResponse(AuthenticationResponses.success); + + await tester.pumpWidget(createWidgetUnderTest()); + + final button = tester.widget(find.byType(ElevatedButton)); + expect(button.onPressed, isNotNull); + }); + + testWidgets('should handle auth check when button is pressed', ( + WidgetTester tester, + ) async { + mockAuthResponse(AuthenticationResponses.success); + + await tester.pumpWidget(createWidgetUnderTest()); + + await tester.tap(find.text('Go to Home')); + await tester.pump(); + + verify(() => mockAuthService.isLoggedIn()).called(1); + }); + }); +} From 12aee0c9ea3e7ec2504c809face90fa2883ae1f1 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 21 Mar 2026 11:58:34 -0400 Subject: [PATCH 25/79] Separates reset password --- lib/features/password_reset/controller.dart | 48 +++++ .../password_reset/password_reset.dart | 168 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 lib/features/password_reset/controller.dart create mode 100644 lib/features/password_reset/password_reset.dart diff --git a/lib/features/password_reset/controller.dart b/lib/features/password_reset/controller.dart new file mode 100644 index 0000000..70d771f --- /dev/null +++ b/lib/features/password_reset/controller.dart @@ -0,0 +1,48 @@ +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +class PasswordResetController extends ChangeNotifier { + final AuthService authService; + + PasswordResetController({AuthService? authService}) + : authService = authService ?? GetIt.instance(); + + final TextEditingController emailController = TextEditingController(); + final formKey = GlobalKey(); + + bool isLoading = false; + + void disposeController() { + emailController.dispose(); + } + + String? validateEmail(String? value) { + if (value == null || value.isEmpty) { + return 'Please enter your email'; + } + final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); + if (!emailRegex.hasMatch(value)) { + return 'Please enter a valid email address'; + } + return null; + } + + Future sendResetEmail() async { + if (!formKey.currentState!.validate()) return null; + + isLoading = true; + notifyListeners(); + + try { + final response = await authService.resetPassword( + emailController.text.trim(), + ); + return response; + } finally { + isLoading = false; + notifyListeners(); + } + } +} \ No newline at end of file diff --git a/lib/features/password_reset/password_reset.dart b/lib/features/password_reset/password_reset.dart new file mode 100644 index 0000000..3cd26c0 --- /dev/null +++ b/lib/features/password_reset/password_reset.dart @@ -0,0 +1,168 @@ +import 'package:clean_stream_laundry_app/features/password_reset/controller.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class PasswordResetPage extends StatefulWidget { + const PasswordResetPage({super.key}); + + @override + State createState() => _PasswordResetPageState(); +} + +class _PasswordResetPageState extends State { + late final PasswordResetController _controller; + + @override + void initState() { + super.initState(); + _controller = PasswordResetController(); + _controller.addListener(() { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _controller.disposeController(); + _controller.dispose(); + super.dispose(); + } + + void _showMessage(String text) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(text))); + } + + Future _onSendPressed() async { + try { + final response = await _controller.sendResetEmail(); + + if (!mounted) return; + + if (response == null) return; + + if (response == AuthenticationResponses.success) { + _showMessage('Password reset email sent! Check your email.'); + context.go( + '/verify-code', + extra: _controller.emailController.text.trim(), + ); + } else { + _showMessage('Failed to send reset email.'); + } + } catch (e) { + if (!mounted) return; + _showMessage('Error: $e'); + } + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: scheme.surface, + appBar: AppBar( + backgroundColor: scheme.surface, + foregroundColor: scheme.fontInverted, + title: const Text('Reset Password'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/login'), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Form( + key: _controller.formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 32), + Icon(Icons.lock_reset, size: 80, color: scheme.primary), + const SizedBox(height: 32), + Text( + 'Forgot your password?', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: scheme.fontInverted, + ) ?? + TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: scheme.fontInverted, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'Enter your email address and we\'ll send you a reset link.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scheme.fontSecondary, + ) ?? + TextStyle(color: scheme.fontSecondary), + textAlign: TextAlign.center, + ), + const SizedBox(height: 40), + TextFormField( + controller: _controller.emailController, + style: TextStyle(color: scheme.fontInverted), + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + labelText: 'Email', + labelStyle: TextStyle(color: scheme.primary), + hintText: 'Enter your email address', + hintStyle: TextStyle( + color: scheme.fontInverted.withOpacity(0.6), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderSide: + BorderSide(color: scheme.primary, width: 2.0), + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: scheme.fontSecondary), + borderRadius: BorderRadius.circular(12), + ), + filled: true, + fillColor: scheme.surface, + prefixIcon: Icon(Icons.email, color: scheme.primary), + ), + validator: _controller.validateEmail, + enabled: !_controller.isLoading, + ), + const SizedBox(height: 24), + _controller.isLoading + ? const Center(child: CircularProgressIndicator()) + : SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _onSendPressed, + style: ElevatedButton.styleFrom( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + ), + child: const Text('Send Reset Link'), + ), + ), + const SizedBox(height: 16), + TextButton( + onPressed: + _controller.isLoading ? null : () => context.go('/login'), + child: Text( + 'Back to Login', + style: TextStyle(color: scheme.primary), + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file From 4cb3ca175f7101cdc9da21da05ba01deace331eb Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 21 Mar 2026 12:05:13 -0400 Subject: [PATCH 26/79] Fixes controller by adding trimming --- lib/features/password_reset/controller.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/features/password_reset/controller.dart b/lib/features/password_reset/controller.dart index 70d771f..9bbb38c 100644 --- a/lib/features/password_reset/controller.dart +++ b/lib/features/password_reset/controller.dart @@ -19,11 +19,13 @@ class PasswordResetController extends ChangeNotifier { } String? validateEmail(String? value) { - if (value == null || value.isEmpty) { + final trimmed = value?.trim(); + + if (trimmed == null || trimmed.isEmpty) { return 'Please enter your email'; } final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); - if (!emailRegex.hasMatch(value)) { + if (!emailRegex.hasMatch(trimmed)) { return 'Please enter a valid email address'; } return null; From 4881cef509f71b980d2829866c4969a2dab7a8f2 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 21 Mar 2026 12:05:35 -0400 Subject: [PATCH 27/79] Adds tests for password_reset --- .../password_reset/controller_test.dart | 261 +++++++++++++++ test/features/password_reset/mocks.dart | 4 + .../password_reset/password_reset_test.dart | 312 ++++++++++++++++++ 3 files changed, 577 insertions(+) create mode 100644 test/features/password_reset/controller_test.dart create mode 100644 test/features/password_reset/mocks.dart create mode 100644 test/features/password_reset/password_reset_test.dart diff --git a/test/features/password_reset/controller_test.dart b/test/features/password_reset/controller_test.dart new file mode 100644 index 0000000..b5387f2 --- /dev/null +++ b/test/features/password_reset/controller_test.dart @@ -0,0 +1,261 @@ +import 'dart:async'; +import 'package:clean_stream_laundry_app/features/password_reset/controller.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + + setUp(() { + mockAuthService = MockAuthService(); + }); + + PasswordResetController buildController() => + PasswordResetController(authService: mockAuthService); + + group('validateEmail', () { + test('returns error for null value', () { + final controller = buildController(); + expect(controller.validateEmail(null), 'Please enter your email'); + }); + + test('returns error for empty string', () { + final controller = buildController(); + expect(controller.validateEmail(''), 'Please enter your email'); + }); + + test('returns error for invalid email — missing @', () { + final controller = buildController(); + expect( + controller.validateEmail('notanemail'), + 'Please enter a valid email address', + ); + }); + + test('returns error for invalid email — missing domain', () { + final controller = buildController(); + expect( + controller.validateEmail('user@'), + 'Please enter a valid email address', + ); + }); + + test('returns error for invalid email — missing TLD', () { + final controller = buildController(); + expect( + controller.validateEmail('user@domain'), + 'Please enter a valid email address', + ); + }); + + test('returns null for valid email', () { + final controller = buildController(); + expect(controller.validateEmail('user@example.com'), isNull); + }); + + test('returns null for valid email with subdomain', () { + final controller = buildController(); + expect(controller.validateEmail('user@mail.example.com'), isNull); + }); + }); + + group('sendResetEmail', () { + testWidgets('returns null without calling service when validation fails', + (tester) async { + final controller = buildController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + key: controller.formKey, + child: TextFormField( + controller: controller.emailController, + validator: controller.validateEmail, + ), + ), + ), + ), + ); + + controller.emailController.text = ''; + final result = await controller.sendResetEmail(); + + expect(result, isNull); + verifyNever(() => mockAuthService.resetPassword(any())); + }); + + testWidgets('calls resetPassword with trimmed email on valid input', + (tester) async { + when(() => mockAuthService.resetPassword(any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = buildController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + key: controller.formKey, + child: TextFormField( + controller: controller.emailController, + validator: controller.validateEmail, + ), + ), + ), + ), + ); + + controller.emailController.text = ' test@example.com '; + await controller.sendResetEmail(); + + verify(() => mockAuthService.resetPassword('test@example.com')) + .called(1); + }); + + testWidgets('returns success response from service', (tester) async { + when(() => mockAuthService.resetPassword(any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = buildController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + key: controller.formKey, + child: TextFormField( + controller: controller.emailController, + validator: controller.validateEmail, + ), + ), + ), + ), + ); + + controller.emailController.text = 'test@example.com'; + final result = await controller.sendResetEmail(); + + expect(result, AuthenticationResponses.success); + }); + + testWidgets('returns failure response from service', (tester) async { + when(() => mockAuthService.resetPassword(any())) + .thenAnswer((_) async => AuthenticationResponses.failure); + + final controller = buildController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + key: controller.formKey, + child: TextFormField( + controller: controller.emailController, + validator: controller.validateEmail, + ), + ), + ), + ), + ); + + controller.emailController.text = 'test@example.com'; + final result = await controller.sendResetEmail(); + + expect(result, AuthenticationResponses.failure); + }); + + testWidgets('sets and clears isLoading around the service call', + (tester) async { + final completer = Completer(); + when(() => mockAuthService.resetPassword(any())) + .thenAnswer((_) => completer.future); + + final controller = buildController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + key: controller.formKey, + child: TextFormField( + controller: controller.emailController, + validator: controller.validateEmail, + ), + ), + ), + ), + ); + + controller.emailController.text = 'test@example.com'; + final future = controller.sendResetEmail(); + + expect(controller.isLoading, isTrue); + + completer.complete(AuthenticationResponses.success); + await future; + + expect(controller.isLoading, isFalse); + }); + + testWidgets('clears isLoading even when service throws', (tester) async { + when(() => mockAuthService.resetPassword(any())) + .thenThrow(Exception('network')); + + final controller = buildController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + key: controller.formKey, + child: TextFormField( + controller: controller.emailController, + validator: controller.validateEmail, + ), + ), + ), + ), + ); + + controller.emailController.text = 'test@example.com'; + + await expectLater(controller.sendResetEmail(), throwsException); + + expect(controller.isLoading, isFalse); + }); + + testWidgets('notifies listeners when isLoading changes', (tester) async { + when(() => mockAuthService.resetPassword(any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = buildController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + key: controller.formKey, + child: TextFormField( + controller: controller.emailController, + validator: controller.validateEmail, + ), + ), + ), + ), + ); + + var notifyCount = 0; + controller.addListener(() => notifyCount++); + + controller.emailController.text = 'test@example.com'; + await controller.sendResetEmail(); + + // One notify for isLoading = true, one for isLoading = false + expect(notifyCount, 2); + }); + }); +} \ No newline at end of file diff --git a/test/features/password_reset/mocks.dart b/test/features/password_reset/mocks.dart new file mode 100644 index 0000000..5227aca --- /dev/null +++ b/test/features/password_reset/mocks.dart @@ -0,0 +1,4 @@ +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthService extends Mock implements AuthService {} \ No newline at end of file diff --git a/test/features/password_reset/password_reset_test.dart b/test/features/password_reset/password_reset_test.dart new file mode 100644 index 0000000..7ff8993 --- /dev/null +++ b/test/features/password_reset/password_reset_test.dart @@ -0,0 +1,312 @@ +import 'dart:async'; +import 'package:clean_stream_laundry_app/features/password_reset/password_reset.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + + setUp(() async { + mockAuthService = MockAuthService(); + await GetIt.instance.reset(); + GetIt.instance.registerSingleton(mockAuthService); + }); + + tearDown(() async { + await GetIt.instance.reset(); + }); + + Widget createWidget() { + return MaterialApp.router( + routerConfig: GoRouter( + initialLocation: '/password-reset', + routes: [ + GoRoute( + path: '/password-reset', + builder: (_, __) => const PasswordResetPage(), + ), + GoRoute( + path: '/login', + builder: (_, __) => + const Scaffold(body: Text('Login Page')), + ), + GoRoute( + path: '/verify-code', + builder: (_, __) => + const Scaffold(body: Text('Verify Code Page')), + ), + ], + ), + ); + } + + group('Static UI', () { + testWidgets('displays Reset Password appbar title', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + expect(find.text('Reset Password'), findsOneWidget); + }); + + testWidgets('displays Forgot your password heading', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + expect(find.text('Forgot your password?'), findsOneWidget); + }); + + testWidgets('displays instruction text', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + expect( + find.textContaining('send you a reset link'), + findsOneWidget, + ); + }); + + testWidgets('displays email text field', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + expect(find.byType(TextFormField), findsOneWidget); + }); + + testWidgets('displays Send Reset Link button', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + expect(find.text('Send Reset Link'), findsOneWidget); + }); + + testWidgets('displays Back to Login text button', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + expect(find.text('Back to Login'), findsOneWidget); + }); + + testWidgets('displays lock_reset icon', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.lock_reset), findsOneWidget); + }); + + testWidgets('displays email prefix icon', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.email), findsOneWidget); + }); + + testWidgets('displays back arrow', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.arrow_back), findsOneWidget); + }); + }); + + group('Form validation', () { + testWidgets('shows error for empty email', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Send Reset Link')); + await tester.pumpAndSettle(); + + expect(find.text('Please enter your email'), findsOneWidget); + }); + + testWidgets('shows error for invalid email format', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextFormField), 'not-an-email'); + await tester.tap(find.text('Send Reset Link')); + await tester.pumpAndSettle(); + + expect( + find.text('Please enter a valid email address'), + findsOneWidget, + ); + }); + + testWidgets('does not call service when email is invalid', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Send Reset Link')); + await tester.pumpAndSettle(); + + verifyNever(() => mockAuthService.resetPassword(any())); + }); + }); + + + group('Send reset email', () { + testWidgets('calls resetPassword with trimmed email on valid submit', + (tester) async { + when(() => mockAuthService.resetPassword(any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byType(TextFormField), ' test@example.com '); + await tester.tap(find.text('Send Reset Link')); + await tester.pumpAndSettle(); + + verify(() => mockAuthService.resetPassword('test@example.com')) + .called(1); + }); + + testWidgets('shows success snackbar on success', (tester) async { + when(() => mockAuthService.resetPassword(any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byType(TextFormField), 'test@example.com'); + await tester.tap(find.text('Send Reset Link')); + await tester.pumpAndSettle(); + + expect( + find.text('Password reset email sent! Check your email.'), + findsOneWidget, + ); + }); + + testWidgets('navigates to /verify-code on success', (tester) async { + when(() => mockAuthService.resetPassword(any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byType(TextFormField), 'test@example.com'); + await tester.tap(find.text('Send Reset Link')); + await tester.pumpAndSettle(); + + expect(find.text('Verify Code Page'), findsOneWidget); + }); + + testWidgets('shows failure snackbar when service returns failure', + (tester) async { + when(() => mockAuthService.resetPassword(any())) + .thenAnswer((_) async => AuthenticationResponses.failure); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byType(TextFormField), 'test@example.com'); + await tester.tap(find.text('Send Reset Link')); + await tester.pumpAndSettle(); + + expect(find.text('Failed to send reset email.'), findsOneWidget); + }); + + testWidgets('shows error snackbar when service throws', (tester) async { + when(() => mockAuthService.resetPassword(any())) + .thenThrow(Exception('network')); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byType(TextFormField), 'test@example.com'); + await tester.tap(find.text('Send Reset Link')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Error:'), findsOneWidget); + }); + + testWidgets('shows loading indicator while request is in flight', + (tester) async { + final completer = Completer(); + when(() => mockAuthService.resetPassword(any())) + .thenAnswer((_) => completer.future); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byType(TextFormField), 'test@example.com'); + await tester.tap(find.text('Send Reset Link')); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Send Reset Link'), findsNothing); + + completer.complete(AuthenticationResponses.success); + await tester.pumpAndSettle(); + }); + + testWidgets('disables text field while loading', (tester) async { + final completer = Completer(); + when(() => mockAuthService.resetPassword(any())) + .thenAnswer((_) => completer.future); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byType(TextFormField), 'test@example.com'); + await tester.tap(find.text('Send Reset Link')); + await tester.pump(); + + final field = tester.widget(find.byType(TextFormField)); + expect(field.enabled, isFalse); + + completer.complete(AuthenticationResponses.success); + await tester.pumpAndSettle(); + }); + + testWidgets('disables Back to Login while loading', (tester) async { + final completer = Completer(); + when(() => mockAuthService.resetPassword(any())) + .thenAnswer((_) => completer.future); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byType(TextFormField), 'test@example.com'); + await tester.tap(find.text('Send Reset Link')); + await tester.pump(); + + final button = tester.widget( + find.widgetWithText(TextButton, 'Back to Login'), + ); + expect(button.onPressed, isNull); + + completer.complete(AuthenticationResponses.success); + await tester.pumpAndSettle(); + }); + }); + + group('Navigation', () { + testWidgets('Back to Login button navigates to /login', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Back to Login')); + await tester.pumpAndSettle(); + + expect(find.text('Login Page'), findsOneWidget); + }); + + testWidgets('back arrow navigates to /login', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + + expect(find.text('Login Page'), findsOneWidget); + }); + }); +} \ No newline at end of file From 67327817d4a99a66868acdb3e7de673e99779a7f Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 21 Mar 2026 12:18:02 -0400 Subject: [PATCH 28/79] Separates payment page into feature --- lib/features/payment_page/controller.dart | 171 +++++++++++++++++ lib/features/payment_page/payment_page.dart | 177 ++++++++++++++++++ .../payment_page/widgets/amount_card.dart | 56 ++++++ .../payment_page/widgets/back_to_home.dart | 32 ++++ .../widgets/dryer_controls_card.dart | 0 .../payment_page/widgets/payment_buttons.dart | 87 +++++++++ .../widgets/washer_controls_card.dart | 0 lib/pages/payment_page.dart | 4 +- 8 files changed, 525 insertions(+), 2 deletions(-) create mode 100644 lib/features/payment_page/controller.dart create mode 100644 lib/features/payment_page/payment_page.dart create mode 100644 lib/features/payment_page/widgets/amount_card.dart create mode 100644 lib/features/payment_page/widgets/back_to_home.dart rename lib/{ => features/payment_page}/widgets/dryer_controls_card.dart (100%) create mode 100644 lib/features/payment_page/widgets/payment_buttons.dart rename lib/{ => features/payment_page}/widgets/washer_controls_card.dart (100%) diff --git a/lib/features/payment_page/controller.dart b/lib/features/payment_page/controller.dart new file mode 100644 index 0000000..77fa426 --- /dev/null +++ b/lib/features/payment_page/controller.dart @@ -0,0 +1,171 @@ +import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; +import 'package:clean_stream_laundry_app/logic/parsing/machine_parser.dart'; +import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; +import 'package:clean_stream_laundry_app/services/notification_service.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +enum PaymentOutcome { + success, + machineError, + failed, + canceled, +} + +class PaymentController extends ChangeNotifier { + final AuthService authService; + final MachineService machineService; + final ProfileService profileService; + final TransactionService transactionService; + final MachineCommunicationService machineCommunicator; + final NotificationService notificationService; + final PaymentProcessor paymentProcessor; + + final String machineId; + + PaymentController({ + required this.machineId, + AuthService? authService, + MachineService? machineService, + ProfileService? profileService, + TransactionService? transactionService, + MachineCommunicationService? machineCommunicator, + NotificationService? notificationService, + PaymentProcessor? paymentProcessor, + }) : authService = authService ?? GetIt.instance(), + machineService = machineService ?? GetIt.instance(), + profileService = profileService ?? GetIt.instance(), + transactionService = + transactionService ?? GetIt.instance(), + machineCommunicator = machineCommunicator ?? + GetIt.instance(), + notificationService = + notificationService ?? GetIt.instance(), + paymentProcessor = + paymentProcessor ?? GetIt.instance(); + + + bool isLoading = true; + bool paymentCompleted = false; + + double? price; + String? machineName; + double? userBalance; + + double _basePrice = 0; + double _addedWasherCost = 0; + int dryerMinutes = 5; + + bool get isDryer => + machineName != null && + machineName!.toLowerCase().contains('dryer'); + + Future init() async { + final data = await machineService.getMachineById(machineId); + final userId = authService.getCurrentUserId; + + if (userId == null) { + isLoading = false; + notifyListeners(); + return; + } + + final balance = await profileService.getUserBalanceById(userId); + + if (data != null && balance != null) { + _basePrice = (data['Price'] as num).toDouble(); + machineName = data['Name'] as String?; + userBalance = (balance['balance'] as num).toDouble(); + price = _basePrice; + } else { + userBalance = 0; + machineName = 'Unknown'; + price = 0; + } + + isLoading = false; + notifyListeners(); + } + + void onDryerChanged(double newPrice, int minutes) { + price = newPrice; + dryerMinutes = minutes; + notifyListeners(); + } + + void onWasherCycleChanged(double addedCost) { + _addedWasherCost = addedCost; + price = _basePrice + _addedWasherCost; + notifyListeners(); + } + + Future makeNotification(String name) async { + await notificationService.scheduleEarlyMachineNotification( + id: 1, + machineTime: isDryer + ? Duration(minutes: dryerMinutes) + : const Duration(minutes: 5, seconds: 5), + machineName: name, + ); + } + + Future processDirectPayment() async { + final result = await paymentProcessor.processPayment( + price!, + MachineFormatter.formatMachineType(machineName.toString()), + ); + + if (result != PaymentResult.success) { + return result == PaymentResult.canceled + ? PaymentOutcome.canceled + : PaymentOutcome.failed; + } + + final deviceAuthorized = await machineCommunicator.wakeDevice(machineId); + + if (deviceAuthorized) { + paymentCompleted = true; + await makeNotification(machineName.toString()); + notifyListeners(); + return PaymentOutcome.success; + } else { + return PaymentOutcome.machineError; + } + } + + Future processLoyaltyPayment() async { + final userId = authService.getCurrentUserId; + final updatedBalance = userBalance! - price!; + userBalance = updatedBalance; + notifyListeners(); + + final deviceAuthorized = await machineCommunicator.wakeDevice(machineId); + + if (!deviceAuthorized) { + userBalance = userBalance! + price!; + notifyListeners(); + return PaymentOutcome.machineError; + } + + await profileService.updateBalanceById(userId!, updatedBalance); + + paymentCompleted = true; + notifyListeners(); + + await transactionService.recordTransaction( + amount: price!, + description: + 'Loyalty payment on ${MachineFormatter.formatMachineType(machineName.toString())}', + type: 'laundry', + ); + + await makeNotification(machineName.toString()); + + return PaymentOutcome.success; + } +} \ No newline at end of file diff --git a/lib/features/payment_page/payment_page.dart b/lib/features/payment_page/payment_page.dart new file mode 100644 index 0000000..c4ede34 --- /dev/null +++ b/lib/features/payment_page/payment_page.dart @@ -0,0 +1,177 @@ +import 'controller.dart'; +import 'widgets/amount_card.dart'; +import 'widgets/back_to_home.dart'; +import 'widgets/payment_buttons.dart'; +import 'package:clean_stream_laundry_app/widgets/base_page.dart'; +import 'widgets/dryer_controls_card.dart'; +import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; +import 'widgets/washer_controls_card.dart'; +import 'package:flutter/material.dart'; + +class PaymentPage extends StatefulWidget { + final String machineId; + + const PaymentPage({super.key, required this.machineId}); + + @override + State createState() => _PaymentPageState(); +} + +class _PaymentPageState extends State { + late final PaymentController _controller; + + @override + void initState() { + super.initState(); + _controller = PaymentController(machineId: widget.machineId); + _controller.addListener(() { + if (mounted) setState(() {}); + }); + _controller.init(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _onDirectPay() async { + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator()), + ); + + final outcome = await _controller.processDirectPayment(); + + if (!mounted) return; + Navigator.of(context, rootNavigator: true).pop(); + + switch (outcome) { + case PaymentOutcome.success: + statusDialog( + context, + title: 'Payment Processed! Machine Ready!', + message: 'Machine ${_controller.machineName} is now active.', + isSuccess: true, + ); + break; + case PaymentOutcome.machineError: + statusDialog( + context, + title: 'Machine Error', + message: 'Payment succeeded but machine did not wake up.', + isSuccess: false, + ); + break; + case PaymentOutcome.failed: + case PaymentOutcome.canceled: + statusDialog( + context, + title: 'Payment Failed', + message: 'Your payment could not be processed.', + isSuccess: false, + ); + break; + } + } + + Future _onLoyaltyPay() async { + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator()), + ); + + final outcome = await _controller.processLoyaltyPayment(); + + if (!mounted) return; + Navigator.of(context, rootNavigator: true).pop(); + + switch (outcome) { + case PaymentOutcome.success: + statusDialog( + context, + title: 'Machine Ready!', + message: 'Machine ${_controller.machineName} is now active.', + isSuccess: true, + ); + statusDialog( + context, + title: 'Payment Successful!', + message: + 'Thank you! \$${_controller.price?.toStringAsFixed(2)} was taken from your Loyalty Card.', + isSuccess: true, + ); + break; + case PaymentOutcome.machineError: + statusDialog( + context, + title: 'Machine Error', + message: + 'Machine did not respond. Your balance has not been charged. Please contact support.', + isSuccess: false, + ); + break; + case PaymentOutcome.failed: + case PaymentOutcome.canceled: + statusDialog( + context, + title: 'Payment Failed', + message: 'Your payment could not be processed.', + isSuccess: false, + ); + break; + } + } + + @override + Widget build(BuildContext context) { + return BasePage( + body: _controller.isLoading + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AmountCard( + machineName: _controller.machineName, + price: _controller.price, + paymentCompleted: _controller.paymentCompleted, + ), + const SizedBox(height: 20), + if (_controller.isDryer) + DryerControlsCard( + onChanged: _controller.onDryerChanged, + ) + else + WasherControlsCard( + onCycleChanged: _controller.onWasherCycleChanged, + ), + const SizedBox(height: 30), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(24.0), + child: _controller.paymentCompleted + ? const BackToHome() + : PaymentButtons( + price: _controller.price, + userBalance: _controller.userBalance, + isProcessing: false, + onDirectPay: _onDirectPay, + onLoyaltyPay: _onLoyaltyPay, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/payment_page/widgets/amount_card.dart b/lib/features/payment_page/widgets/amount_card.dart new file mode 100644 index 0000000..545a030 --- /dev/null +++ b/lib/features/payment_page/widgets/amount_card.dart @@ -0,0 +1,56 @@ +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; + +class AmountCard extends StatelessWidget { + final String? machineName; + final double? price; + final bool paymentCompleted; + + const AmountCard({ + super.key, + required this.machineName, + required this.price, + required this.paymentCompleted, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(30), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.greyCard, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + const Icon( + Icons.local_laundry_service, + size: 80, + color: Color(0xFF2073A9), + ), + const SizedBox(height: 20), + Text( + 'Machine $machineName', + style: const TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + Text( + paymentCompleted ? 'Payment Complete' : 'Amount Due', + style: const TextStyle(fontSize: 16, color: Colors.black87), + ), + Text( + '\$${price?.toStringAsFixed(2) ?? '0.00'}', + style: const TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/payment_page/widgets/back_to_home.dart b/lib/features/payment_page/widgets/back_to_home.dart new file mode 100644 index 0000000..f4e26d3 --- /dev/null +++ b/lib/features/payment_page/widgets/back_to_home.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class BackToHome extends StatelessWidget { + const BackToHome({super.key}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => context.go('/homePage'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue[700], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 2, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: const Text( + 'Back to Home', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/dryer_controls_card.dart b/lib/features/payment_page/widgets/dryer_controls_card.dart similarity index 100% rename from lib/widgets/dryer_controls_card.dart rename to lib/features/payment_page/widgets/dryer_controls_card.dart diff --git a/lib/features/payment_page/widgets/payment_buttons.dart b/lib/features/payment_page/widgets/payment_buttons.dart new file mode 100644 index 0000000..d5e6f0a --- /dev/null +++ b/lib/features/payment_page/widgets/payment_buttons.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +class PaymentButtons extends StatelessWidget { + final double? price; + final double? userBalance; + final bool isProcessing; + final VoidCallback onDirectPay; + final VoidCallback onLoyaltyPay; + + const PaymentButtons({ + super.key, + required this.price, + required this.userBalance, + required this.isProcessing, + required this.onDirectPay, + required this.onLoyaltyPay, + }); + + bool get _disabled => isProcessing || price == null || price == 0; + bool get _loyaltyDisabled => + _disabled || (userBalance ?? 0) < (price ?? 0); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: _disabled ? null : onDirectPay, + style: ElevatedButton.styleFrom( + backgroundColor: _disabled ? Colors.grey : Colors.blue[700], + disabledBackgroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 2, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: isProcessing + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.5, + ), + ) + : Text( + price != null && price! > 0 + ? 'Pay \$${price!.toStringAsFixed(2)}' + : 'Pay', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: _loyaltyDisabled ? null : onLoyaltyPay, + style: ElevatedButton.styleFrom( + backgroundColor: + _loyaltyDisabled ? Colors.grey : Colors.green[700], + disabledBackgroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 2, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: const Text( + 'Pay with Loyalty', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/washer_controls_card.dart b/lib/features/payment_page/widgets/washer_controls_card.dart similarity index 100% rename from lib/widgets/washer_controls_card.dart rename to lib/features/payment_page/widgets/washer_controls_card.dart diff --git a/lib/pages/payment_page.dart b/lib/pages/payment_page.dart index 3ef32f2..6b8bf79 100644 --- a/lib/pages/payment_page.dart +++ b/lib/pages/payment_page.dart @@ -13,8 +13,8 @@ import 'package:clean_stream_laundry_app/logic/services/machine_communication_se import 'package:clean_stream_laundry_app/services/notification_service.dart'; import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; import 'package:go_router/go_router.dart'; -import 'package:clean_stream_laundry_app/widgets/washer_controls_card.dart'; -import 'package:clean_stream_laundry_app/widgets/dryer_controls_card.dart'; +import 'package:clean_stream_laundry_app/features/payment_page/widgets/washer_controls_card.dart'; +import 'package:clean_stream_laundry_app/features/payment_page/widgets/dryer_controls_card.dart'; class PaymentPage extends StatefulWidget { final String machineId; From a1920cd887e71d656e269e81fedfbbf494275c00 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 21 Mar 2026 12:27:19 -0400 Subject: [PATCH 29/79] Renames payment_page to machine_payment --- .../{payment_page => machine_payment}/controller.dart | 0 .../machine_payment.dart} | 8 ++++---- .../widgets/amount_card.dart | 0 .../widgets/back_to_home.dart | 0 .../widgets/dryer_controls_card.dart | 0 .../widgets/payment_buttons.dart | 0 .../widgets/washer_controls_card.dart | 0 lib/pages/payment_page.dart | 4 ++-- 8 files changed, 6 insertions(+), 6 deletions(-) rename lib/features/{payment_page => machine_payment}/controller.dart (100%) rename lib/features/{payment_page/payment_page.dart => machine_payment/machine_payment.dart} (95%) rename lib/features/{payment_page => machine_payment}/widgets/amount_card.dart (100%) rename lib/features/{payment_page => machine_payment}/widgets/back_to_home.dart (100%) rename lib/features/{payment_page => machine_payment}/widgets/dryer_controls_card.dart (100%) rename lib/features/{payment_page => machine_payment}/widgets/payment_buttons.dart (100%) rename lib/features/{payment_page => machine_payment}/widgets/washer_controls_card.dart (100%) diff --git a/lib/features/payment_page/controller.dart b/lib/features/machine_payment/controller.dart similarity index 100% rename from lib/features/payment_page/controller.dart rename to lib/features/machine_payment/controller.dart diff --git a/lib/features/payment_page/payment_page.dart b/lib/features/machine_payment/machine_payment.dart similarity index 95% rename from lib/features/payment_page/payment_page.dart rename to lib/features/machine_payment/machine_payment.dart index c4ede34..096396e 100644 --- a/lib/features/payment_page/payment_page.dart +++ b/lib/features/machine_payment/machine_payment.dart @@ -8,16 +8,16 @@ import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; import 'widgets/washer_controls_card.dart'; import 'package:flutter/material.dart'; -class PaymentPage extends StatefulWidget { +class MachinePayment extends StatefulWidget { final String machineId; - const PaymentPage({super.key, required this.machineId}); + const MachinePayment({super.key, required this.machineId}); @override - State createState() => _PaymentPageState(); + State createState() => _MachinePaymentState(); } -class _PaymentPageState extends State { +class _MachinePaymentState extends State { late final PaymentController _controller; @override diff --git a/lib/features/payment_page/widgets/amount_card.dart b/lib/features/machine_payment/widgets/amount_card.dart similarity index 100% rename from lib/features/payment_page/widgets/amount_card.dart rename to lib/features/machine_payment/widgets/amount_card.dart diff --git a/lib/features/payment_page/widgets/back_to_home.dart b/lib/features/machine_payment/widgets/back_to_home.dart similarity index 100% rename from lib/features/payment_page/widgets/back_to_home.dart rename to lib/features/machine_payment/widgets/back_to_home.dart diff --git a/lib/features/payment_page/widgets/dryer_controls_card.dart b/lib/features/machine_payment/widgets/dryer_controls_card.dart similarity index 100% rename from lib/features/payment_page/widgets/dryer_controls_card.dart rename to lib/features/machine_payment/widgets/dryer_controls_card.dart diff --git a/lib/features/payment_page/widgets/payment_buttons.dart b/lib/features/machine_payment/widgets/payment_buttons.dart similarity index 100% rename from lib/features/payment_page/widgets/payment_buttons.dart rename to lib/features/machine_payment/widgets/payment_buttons.dart diff --git a/lib/features/payment_page/widgets/washer_controls_card.dart b/lib/features/machine_payment/widgets/washer_controls_card.dart similarity index 100% rename from lib/features/payment_page/widgets/washer_controls_card.dart rename to lib/features/machine_payment/widgets/washer_controls_card.dart diff --git a/lib/pages/payment_page.dart b/lib/pages/payment_page.dart index 6b8bf79..3b76d93 100644 --- a/lib/pages/payment_page.dart +++ b/lib/pages/payment_page.dart @@ -13,8 +13,8 @@ import 'package:clean_stream_laundry_app/logic/services/machine_communication_se import 'package:clean_stream_laundry_app/services/notification_service.dart'; import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; import 'package:go_router/go_router.dart'; -import 'package:clean_stream_laundry_app/features/payment_page/widgets/washer_controls_card.dart'; -import 'package:clean_stream_laundry_app/features/payment_page/widgets/dryer_controls_card.dart'; +import 'package:clean_stream_laundry_app/features/machine_payment/widgets/washer_controls_card.dart'; +import 'package:clean_stream_laundry_app/features/machine_payment/widgets/dryer_controls_card.dart'; class PaymentPage extends StatefulWidget { final String machineId; From 94c69098c9b17a3b921562a3497f7b24e585ea4e Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 21 Mar 2026 12:27:37 -0400 Subject: [PATCH 30/79] Adds tests for machine_payment --- .../machine_payment/controller_test.dart | 421 +++++++++++++++++ .../machine_payment/machine_payment_test.dart | 427 ++++++++++++++++++ test/features/machine_payment/mocks.dart | 17 + .../widgets/amount_card_test.dart | 54 +++ .../widgets/back_to_home_test.dart | 57 +++ .../widgets/dryer_controls_card_test.dart | 2 +- .../widgets/payment_buttons_test.dart | 139 ++++++ .../widgets/washer_controls_card_test.dart | 2 +- 8 files changed, 1117 insertions(+), 2 deletions(-) create mode 100644 test/features/machine_payment/controller_test.dart create mode 100644 test/features/machine_payment/machine_payment_test.dart create mode 100644 test/features/machine_payment/mocks.dart create mode 100644 test/features/machine_payment/widgets/amount_card_test.dart create mode 100644 test/features/machine_payment/widgets/back_to_home_test.dart rename test/{ => features/machine_payment}/widgets/dryer_controls_card_test.dart (98%) create mode 100644 test/features/machine_payment/widgets/payment_buttons_test.dart rename test/{ => features/machine_payment}/widgets/washer_controls_card_test.dart (97%) diff --git a/test/features/machine_payment/controller_test.dart b/test/features/machine_payment/controller_test.dart new file mode 100644 index 0000000..57010c2 --- /dev/null +++ b/test/features/machine_payment/controller_test.dart @@ -0,0 +1,421 @@ +import 'package:clean_stream_laundry_app/features/machine_payment/controller.dart'; +import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late MockMachineService mockMachineService; + late MockProfileService mockProfileService; + late MockTransactionService mockTransactionService; + late MockMachineCommunicationService mockMachineCommunicator; + late MockNotificationService mockNotificationService; + late MockPaymentProcessor mockPaymentProcessor; + + setUpAll(() { + registerFallbackValue(FakeAuthService()); + registerFallbackValue(const Duration(seconds: 1)); + }); + + setUp(() { + mockAuthService = MockAuthService(); + mockMachineService = MockMachineService(); + mockProfileService = MockProfileService(); + mockTransactionService = MockTransactionService(); + mockMachineCommunicator = MockMachineCommunicationService(); + mockNotificationService = MockNotificationService(); + mockPaymentProcessor = MockPaymentProcessor(); + + when(() => mockNotificationService.scheduleEarlyMachineNotification( + id: any(named: 'id'), + machineTime: any(named: 'machineTime'), + machineName: any(named: 'machineName'), + )).thenAnswer((_) async {}); + }); + + PaymentController buildController({String machineId = 'machine123'}) => + PaymentController( + machineId: machineId, + authService: mockAuthService, + machineService: mockMachineService, + profileService: mockProfileService, + transactionService: mockTransactionService, + machineCommunicator: mockMachineCommunicator, + notificationService: mockNotificationService, + paymentProcessor: mockPaymentProcessor, + ); + + // --------------------------------------------------------------------------- + // init + // --------------------------------------------------------------------------- + + group('init', () { + test('sets machineName, price, userBalance on success', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when(() => mockMachineService.getMachineById('machine123')) + .thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + when(() => mockProfileService.getUserBalanceById('user123')) + .thenAnswer((_) async => {'balance': 10.0}); + + final controller = buildController(); + await controller.init(); + + expect(controller.machineName, 'Washer01'); + expect(controller.price, 3.50); + expect(controller.userBalance, 10.0); + expect(controller.isLoading, isFalse); + }); + + test('sets defaults when machine data is null', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when(() => mockMachineService.getMachineById(any())) + .thenAnswer((_) async => null); + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': 10.0}); + + final controller = buildController(); + await controller.init(); + + expect(controller.machineName, 'Unknown'); + expect(controller.price, 0.0); + expect(controller.userBalance, 0.0); + }); + + test('returns early without fetching balance when userId is null', + () async { + when(() => mockAuthService.getCurrentUserId).thenReturn(null); + when(() => mockMachineService.getMachineById(any())) + .thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + + final controller = buildController(); + await controller.init(); + + verifyNever(() => mockProfileService.getUserBalanceById(any())); + expect(controller.isLoading, isFalse); + }); + + test('notifies listeners when loading completes', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when(() => mockMachineService.getMachineById(any())) + .thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': 10.0}); + + final controller = buildController(); + var notified = false; + controller.addListener(() => notified = true); + + await controller.init(); + + expect(notified, isTrue); + }); + }); + + // --------------------------------------------------------------------------- + // isDryer + // --------------------------------------------------------------------------- + + group('isDryer', () { + test('returns true when machineName contains dryer', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when(() => mockMachineService.getMachineById(any())) + .thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 1.50}); + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': 10.0}); + + final controller = buildController(); + await controller.init(); + + expect(controller.isDryer, isTrue); + }); + + test('returns false when machineName contains washer', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when(() => mockMachineService.getMachineById(any())) + .thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': 10.0}); + + final controller = buildController(); + await controller.init(); + + expect(controller.isDryer, isFalse); + }); + + test('returns false when machineName is null', () { + final controller = buildController(); + expect(controller.isDryer, isFalse); + }); + }); + + // --------------------------------------------------------------------------- + // onDryerChanged / onWasherCycleChanged + // --------------------------------------------------------------------------- + + group('Controls', () { + test('onDryerChanged updates price and dryerMinutes', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when(() => mockMachineService.getMachineById(any())) + .thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 1.50}); + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': 10.0}); + + final controller = buildController(); + await controller.init(); + + controller.onDryerChanged(3.00, 60); + + expect(controller.price, 3.00); + expect(controller.dryerMinutes, 60); + }); + + test('onWasherCycleChanged updates price by adding to base price', + () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when(() => mockMachineService.getMachineById(any())) + .thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': 10.0}); + + final controller = buildController(); + await controller.init(); + + controller.onWasherCycleChanged(0.50); + + expect(controller.price, closeTo(4.00, 0.001)); + }); + + test('onDryerChanged notifies listeners', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when(() => mockMachineService.getMachineById(any())) + .thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 1.50}); + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': 10.0}); + + final controller = buildController(); + await controller.init(); + + var notified = false; + controller.addListener(() => notified = true); + + controller.onDryerChanged(2.00, 40); + + expect(notified, isTrue); + }); + }); + + // --------------------------------------------------------------------------- + // processDirectPayment + // --------------------------------------------------------------------------- + + group('processDirectPayment', () { + setUp(() async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when(() => mockMachineService.getMachineById(any())) + .thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 1.50}); + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': 10.0}); + }); + + test('returns success and sets paymentCompleted when everything succeeds', + () async { + when(() => mockPaymentProcessor.processPayment(any(), any())) + .thenAnswer((_) async => PaymentResult.success); + when(() => mockMachineCommunicator.wakeDevice(any())) + .thenAnswer((_) async => true); + + final controller = buildController(); + await controller.init(); + + final result = await controller.processDirectPayment(); + + expect(result, PaymentOutcome.success); + expect(controller.paymentCompleted, isTrue); + }); + + test('returns machineError when device fails to wake', () async { + when(() => mockPaymentProcessor.processPayment(any(), any())) + .thenAnswer((_) async => PaymentResult.success); + when(() => mockMachineCommunicator.wakeDevice(any())) + .thenAnswer((_) async => false); + + final controller = buildController(); + await controller.init(); + + final result = await controller.processDirectPayment(); + + expect(result, PaymentOutcome.machineError); + expect(controller.paymentCompleted, isFalse); + }); + + test('returns failed when payment processor fails', () async { + when(() => mockPaymentProcessor.processPayment(any(), any())) + .thenAnswer((_) async => PaymentResult.failed); + + final controller = buildController(); + await controller.init(); + + final result = await controller.processDirectPayment(); + + expect(result, PaymentOutcome.failed); + verifyNever(() => mockMachineCommunicator.wakeDevice(any())); + }); + + test('returns canceled when payment is canceled', () async { + when(() => mockPaymentProcessor.processPayment(any(), any())) + .thenAnswer((_) async => PaymentResult.canceled); + + final controller = buildController(); + await controller.init(); + + final result = await controller.processDirectPayment(); + + expect(result, PaymentOutcome.canceled); + }); + + test('schedules notification on success', () async { + when(() => mockPaymentProcessor.processPayment(any(), any())) + .thenAnswer((_) async => PaymentResult.success); + when(() => mockMachineCommunicator.wakeDevice(any())) + .thenAnswer((_) async => true); + + final controller = buildController(); + await controller.init(); + + await controller.processDirectPayment(); + + verify(() => mockNotificationService.scheduleEarlyMachineNotification( + id: 1, + machineTime: any(named: 'machineTime'), + machineName: any(named: 'machineName'), + )).called(1); + }); + + test('does not schedule notification when machine fails to wake', + () async { + when(() => mockPaymentProcessor.processPayment(any(), any())) + .thenAnswer((_) async => PaymentResult.success); + when(() => mockMachineCommunicator.wakeDevice(any())) + .thenAnswer((_) async => false); + + final controller = buildController(); + await controller.init(); + + await controller.processDirectPayment(); + + verifyNever(() => mockNotificationService.scheduleEarlyMachineNotification( + id: any(named: 'id'), + machineTime: any(named: 'machineTime'), + machineName: any(named: 'machineName'), + )); + }); + }); + + // --------------------------------------------------------------------------- + // processLoyaltyPayment + // --------------------------------------------------------------------------- + + group('processLoyaltyPayment', () { + setUp(() async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when(() => mockMachineService.getMachineById(any())) + .thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 1.50}); + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': 10.0}); + when(() => mockProfileService.updateBalanceById(any(), any())) + .thenAnswer((_) async {}); + when(() => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + )) + .thenAnswer((_) async {}); + }); + + test('returns success and sets paymentCompleted', () async { + when(() => mockMachineCommunicator.wakeDevice(any())) + .thenAnswer((_) async => true); + + final controller = buildController(); + await controller.init(); + + final result = await controller.processLoyaltyPayment(); + + expect(result, PaymentOutcome.success); + expect(controller.paymentCompleted, isTrue); + }); + + test('updates balance and calls services on success', () async { + when(() => mockMachineCommunicator.wakeDevice(any())) + .thenAnswer((_) async => true); + + final controller = buildController(); + await controller.init(); + + await controller.processLoyaltyPayment(); + + verify(() => + mockProfileService.updateBalanceById('user123', 8.5)) + .called(1); + verify(() => mockTransactionService.recordTransaction( + amount: 1.50, + description: any(named: 'description'), + type: 'laundry', + )) + .called(1); + }); + + test('returns machineError and does not update balance when device fails', + () async { + when(() => mockMachineCommunicator.wakeDevice(any())) + .thenAnswer((_) async => false); + + final controller = buildController(); + await controller.init(); + + final result = await controller.processLoyaltyPayment(); + + expect(result, PaymentOutcome.machineError); + expect(controller.paymentCompleted, isFalse); + verifyNever(() => mockProfileService.updateBalanceById(any(), any())); + verifyNever(() => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + )); + }); + + test('schedules notification on success', () async { + when(() => mockMachineCommunicator.wakeDevice(any())) + .thenAnswer((_) async => true); + + final controller = buildController(); + await controller.init(); + + await controller.processLoyaltyPayment(); + + verify(() => mockNotificationService.scheduleEarlyMachineNotification( + id: 1, + machineTime: any(named: 'machineTime'), + machineName: any(named: 'machineName'), + )).called(1); + }); + + test('does not schedule notification when machine fails', () async { + when(() => mockMachineCommunicator.wakeDevice(any())) + .thenAnswer((_) async => false); + + final controller = buildController(); + await controller.init(); + + await controller.processLoyaltyPayment(); + + verifyNever(() => mockNotificationService.scheduleEarlyMachineNotification( + id: any(named: 'id'), + machineTime: any(named: 'machineTime'), + machineName: any(named: 'machineName'), + )); + }); + }); +} \ No newline at end of file diff --git a/test/features/machine_payment/machine_payment_test.dart b/test/features/machine_payment/machine_payment_test.dart new file mode 100644 index 0000000..510ecd7 --- /dev/null +++ b/test/features/machine_payment/machine_payment_test.dart @@ -0,0 +1,427 @@ +import 'package:clean_stream_laundry_app/features/machine_payment/machine_payment.dart'; +import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; +import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; +import 'package:clean_stream_laundry_app/services/notification_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late MockMachineService mockMachineService; + late MockProfileService mockProfileService; + late MockTransactionService mockTransactionService; + late MockMachineCommunicationService mockMachineCommunicator; + late MockNotificationService mockNotificationService; + late MockPaymentProcessor mockPaymentProcessor; + + setUpAll(() { + registerFallbackValue(FakeAuthService()); + registerFallbackValue(const Duration(seconds: 1)); + }); + + setUp(() async { + mockAuthService = MockAuthService(); + mockMachineService = MockMachineService(); + mockProfileService = MockProfileService(); + mockTransactionService = MockTransactionService(); + mockMachineCommunicator = MockMachineCommunicationService(); + mockNotificationService = MockNotificationService(); + mockPaymentProcessor = MockPaymentProcessor(); + + await GetIt.instance.reset(); + GetIt.instance.registerSingleton(mockAuthService); + GetIt.instance.registerSingleton(mockMachineService); + GetIt.instance.registerSingleton(mockProfileService); + GetIt.instance.registerSingleton(mockTransactionService); + GetIt.instance.registerSingleton( + mockMachineCommunicator); + GetIt.instance.registerSingleton( + mockNotificationService); + GetIt.instance.registerSingleton(mockPaymentProcessor); + + when(() => mockNotificationService.scheduleEarlyMachineNotification( + id: any(named: 'id'), + machineTime: any(named: 'machineTime'), + machineName: any(named: 'machineName'), + )).thenAnswer((_) async {}); + }); + + tearDown(() async { + await GetIt.instance.reset(); + }); + + Widget createWidget(String machineId) { + return MaterialApp.router( + routerConfig: GoRouter( + initialLocation: '/payment', + routes: [ + GoRoute( + path: '/payment', + builder: (_, __) => MachinePayment(machineId: machineId), + ), + GoRoute( + path: '/homePage', + builder: (_, __) => const Scaffold(body: Text('Home Page')), + ), + ], + ), + ); + } + + void mockWasherMachine({double price = 3.50, double balance = 10.0}) { + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when(() => mockMachineService.getMachineById(any())) + .thenAnswer((_) async => {'Name': 'Washer01', 'Price': price}); + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': balance}); + } + + void mockDryerMachine({double price = 1.50, double balance = 10.0}) { + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when(() => mockMachineService.getMachineById(any())) + .thenAnswer((_) async => {'Name': 'Dryer01', 'Price': price}); + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': balance}); + } + + // --------------------------------------------------------------------------- + // Loading state + // --------------------------------------------------------------------------- + + group('Loading state', () { + testWidgets('shows loading indicator while fetching machine info', + (tester) async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when(() => mockMachineService.getMachineById(any())).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 50)); + return {'Name': 'Washer01', 'Price': 3.50}; + }); + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': 10.0}); + + await tester.pumpWidget(createWidget('machine123')); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.pumpAndSettle(); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + }); + + // --------------------------------------------------------------------------- + // Content display + // --------------------------------------------------------------------------- + + group('Content display', () { + testWidgets('displays machine name and Amount Due after loading', + (tester) async { + mockWasherMachine(); + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + expect(find.text('Machine Washer01'), findsOneWidget); + expect(find.text('Amount Due'), findsOneWidget); + }); + + testWidgets('displays formatted price', (tester) async { + mockWasherMachine(price: 3.50); + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + expect(find.text('\$3.50'), findsOneWidget); + }); + + testWidgets('displays laundry service icon', (tester) async { + mockWasherMachine(); + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.local_laundry_service), findsOneWidget); + }); + + testWidgets('handles null userId gracefully', (tester) async { + when(() => mockAuthService.getCurrentUserId).thenReturn(null); + when(() => mockMachineService.getMachineById(any())) + .thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + verifyNever(() => mockProfileService.getUserBalanceById(any())); + }); + + testWidgets('handles machine not found', (tester) async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when(() => mockMachineService.getMachineById(any())) + .thenAnswer((_) async => null); + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': 10.0}); + + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + expect(find.text('Machine Unknown'), findsOneWidget); + expect(find.text('\$0.00'), findsOneWidget); + }); + + testWidgets('shows WasherControlsCard for washer machines', (tester) async { + mockWasherMachine(); + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + expect(find.text('Select Your Cycle'), findsOneWidget); + }); + + testWidgets('shows DryerControlsCard for dryer machines', (tester) async { + mockDryerMachine(); + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + expect(find.text('Set Dry Time'), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // Payment buttons + // --------------------------------------------------------------------------- + + group('Payment buttons', () { + testWidgets('displays both payment buttons', (tester) async { + mockDryerMachine(); + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + expect(find.text('Pay \$1.50'), findsOneWidget); + expect(find.text('Pay with Loyalty'), findsOneWidget); + }); + + testWidgets('disables loyalty button when balance is insufficient', + (tester) async { + mockDryerMachine(price: 5.00, balance: 1.00); + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + final button = tester.widget( + find.widgetWithText(ElevatedButton, 'Pay with Loyalty'), + ); + expect(button.onPressed, isNull); + }); + + testWidgets('enables loyalty button when balance is sufficient', + (tester) async { + mockDryerMachine(price: 1.50, balance: 10.00); + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + final button = tester.widget( + find.widgetWithText(ElevatedButton, 'Pay with Loyalty'), + ); + expect(button.onPressed, isNotNull); + }); + }); + + // --------------------------------------------------------------------------- + // Direct payment + // --------------------------------------------------------------------------- + + group('Direct payment', () { + testWidgets('shows success dialog when payment and machine wake succeed', + (tester) async { + mockDryerMachine(); + when(() => mockPaymentProcessor.processPayment(any(), any())) + .thenAnswer((_) async => PaymentResult.success); + when(() => mockMachineCommunicator.wakeDevice(any())) + .thenAnswer((_) async => true); + + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pay \$1.50')); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Payment Processed! Machine Ready!'), findsOneWidget); + }); + + testWidgets('shows machine error dialog when machine fails to wake', + (tester) async { + mockDryerMachine(); + when(() => mockPaymentProcessor.processPayment(any(), any())) + .thenAnswer((_) async => PaymentResult.success); + when(() => mockMachineCommunicator.wakeDevice(any())) + .thenAnswer((_) async => false); + + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pay \$1.50')); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Machine Error'), findsOneWidget); + }); + + testWidgets('shows payment failed dialog when payment fails', (tester) async { + mockDryerMachine(); + when(() => mockPaymentProcessor.processPayment(any(), any())) + .thenAnswer((_) async => PaymentResult.failed); + + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pay \$1.50')); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Payment Failed'), findsOneWidget); + }); + + testWidgets('shows Back to Home button after successful direct payment', + (tester) async { + mockDryerMachine(); + when(() => mockPaymentProcessor.processPayment(any(), any())) + .thenAnswer((_) async => PaymentResult.success); + when(() => mockMachineCommunicator.wakeDevice(any())) + .thenAnswer((_) async => true); + + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pay \$1.50')); + await tester.pump(); + await tester.pumpAndSettle(); + + // Dismiss dialog + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + + expect(find.text('Back to Home'), findsOneWidget); + }); + + testWidgets('schedules notification after successful direct payment', + (tester) async { + mockDryerMachine(); + when(() => mockPaymentProcessor.processPayment(any(), any())) + .thenAnswer((_) async => PaymentResult.success); + when(() => mockMachineCommunicator.wakeDevice(any())) + .thenAnswer((_) async => true); + + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pay \$1.50')); + await tester.pump(); + await tester.pumpAndSettle(); + + verify(() => mockNotificationService.scheduleEarlyMachineNotification( + id: 1, + machineTime: any(named: 'machineTime'), + machineName: any(named: 'machineName'), + )).called(1); + }); + }); + + // --------------------------------------------------------------------------- + // Loyalty payment + // --------------------------------------------------------------------------- + + group('Loyalty payment', () { + testWidgets('processes loyalty payment and wakes machine', (tester) async { + mockDryerMachine(price: 1.50, balance: 10.00); + when(() => mockMachineCommunicator.wakeDevice(any())) + .thenAnswer((_) async => true); + when(() => mockProfileService.updateBalanceById(any(), any())) + .thenAnswer((_) async {}); + when(() => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + )) + .thenAnswer((_) async {}); + + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pay with Loyalty')); + await tester.pump(); + await tester.pumpAndSettle(); + + verify(() => + mockProfileService.updateBalanceById('user123', 8.5)) + .called(1); + verify(() => mockMachineCommunicator.wakeDevice('machine123')).called(1); + verify(() => mockTransactionService.recordTransaction( + amount: 1.50, + description: any(named: 'description'), + type: 'laundry', + )) + .called(1); + }); + + testWidgets('shows machine error when device fails to wake', (tester) async { + mockDryerMachine(price: 1.50, balance: 10.00); + when(() => mockMachineCommunicator.wakeDevice(any())) + .thenAnswer((_) async => false); + when(() => mockProfileService.updateBalanceById(any(), any())) + .thenAnswer((_) async {}); + when(() => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + )) + .thenAnswer((_) async {}); + + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pay with Loyalty')); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Machine Error'), findsWidgets); + verifyNever(() => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + )); + }); + + testWidgets('schedules notification after successful loyalty payment', + (tester) async { + mockWasherMachine(price: 3.50, balance: 10.00); + when(() => mockMachineCommunicator.wakeDevice(any())) + .thenAnswer((_) async => true); + when(() => mockProfileService.updateBalanceById(any(), any())) + .thenAnswer((_) async {}); + when(() => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + )) + .thenAnswer((_) async {}); + + await tester.pumpWidget(createWidget('machine123')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pay with Loyalty')); + await tester.pump(); + await tester.pumpAndSettle(); + + verify(() => mockNotificationService.scheduleEarlyMachineNotification( + id: 1, + machineTime: any(named: 'machineTime'), + machineName: any(named: 'machineName'), + )).called(1); + }); + }); +} \ No newline at end of file diff --git a/test/features/machine_payment/mocks.dart b/test/features/machine_payment/mocks.dart new file mode 100644 index 0000000..35bc2eb --- /dev/null +++ b/test/features/machine_payment/mocks.dart @@ -0,0 +1,17 @@ +import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; +import 'package:clean_stream_laundry_app/services/notification_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthService extends Mock implements AuthService {} +class MockMachineService extends Mock implements MachineService {} +class MockProfileService extends Mock implements ProfileService {} +class MockTransactionService extends Mock implements TransactionService {} +class MockMachineCommunicationService extends Mock implements MachineCommunicationService {} +class MockNotificationService extends Mock implements NotificationService {} +class MockPaymentProcessor extends Mock implements PaymentProcessor {} +class FakeAuthService extends Fake implements AuthService {} \ No newline at end of file diff --git a/test/features/machine_payment/widgets/amount_card_test.dart b/test/features/machine_payment/widgets/amount_card_test.dart new file mode 100644 index 0000000..628e871 --- /dev/null +++ b/test/features/machine_payment/widgets/amount_card_test.dart @@ -0,0 +1,54 @@ +import 'package:clean_stream_laundry_app/features/machine_payment/widgets/amount_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget({ + String? machineName = 'Washer01', + double? price = 3.50, + bool paymentCompleted = false, + }) { + return MaterialApp( + home: Scaffold( + body: AmountCard( + machineName: machineName, + price: price, + paymentCompleted: paymentCompleted, + ), + ), + ); + } + + group('AmountCard', () { + testWidgets('displays machine name', (tester) async { + await tester.pumpWidget(buildWidget(machineName: 'Washer01')); + expect(find.text('Machine Washer01'), findsOneWidget); + }); + + testWidgets('displays Amount Due when payment not completed', (tester) async { + await tester.pumpWidget(buildWidget(paymentCompleted: false)); + expect(find.text('Amount Due'), findsOneWidget); + }); + + testWidgets('displays Payment Complete when payment is completed', + (tester) async { + await tester.pumpWidget(buildWidget(paymentCompleted: true)); + expect(find.text('Payment Complete'), findsOneWidget); + }); + + testWidgets('displays formatted price', (tester) async { + await tester.pumpWidget(buildWidget(price: 3.50)); + expect(find.text('\$3.50'), findsOneWidget); + }); + + testWidgets('displays 0.00 when price is null', (tester) async { + await tester.pumpWidget(buildWidget(price: null)); + expect(find.text('\$0.00'), findsOneWidget); + }); + + testWidgets('displays laundry service icon', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byIcon(Icons.local_laundry_service), findsOneWidget); + }); + }); +} \ No newline at end of file diff --git a/test/features/machine_payment/widgets/back_to_home_test.dart b/test/features/machine_payment/widgets/back_to_home_test.dart new file mode 100644 index 0000000..fa54574 --- /dev/null +++ b/test/features/machine_payment/widgets/back_to_home_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:clean_stream_laundry_app/features/machine_payment/widgets/back_to_home.dart'; + +void main() { + testWidgets('BackToHome renders button with correct text', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: BackToHome(), + ), + ), + ); + + expect(find.text('Back to Home'), findsOneWidget); + expect(find.byType(ElevatedButton), findsOneWidget); + }); + + testWidgets('tapping BackToHome navigates to /homePage', + (WidgetTester tester) async { + String? navigatedRoute; + + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const Scaffold( + body: BackToHome(), + ), + ), + GoRoute( + path: '/homePage', + builder: (context, state) { + navigatedRoute = state.uri.toString(); + return const Scaffold( + body: Text('Home Page'), + ); + }, + ), + ], + ); + + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + ), + ); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(navigatedRoute, '/homePage'); + expect(find.text('Home Page'), findsOneWidget); + }); +} \ No newline at end of file diff --git a/test/widgets/dryer_controls_card_test.dart b/test/features/machine_payment/widgets/dryer_controls_card_test.dart similarity index 98% rename from test/widgets/dryer_controls_card_test.dart rename to test/features/machine_payment/widgets/dryer_controls_card_test.dart index 74b53f8..70cb126 100644 --- a/test/widgets/dryer_controls_card_test.dart +++ b/test/features/machine_payment/widgets/dryer_controls_card_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:clean_stream_laundry_app/widgets/dryer_controls_card.dart'; +import 'package:clean_stream_laundry_app/features/machine_payment/widgets/dryer_controls_card.dart'; ThemeData _testTheme() => ThemeData.light(); diff --git a/test/features/machine_payment/widgets/payment_buttons_test.dart b/test/features/machine_payment/widgets/payment_buttons_test.dart new file mode 100644 index 0000000..5aad294 --- /dev/null +++ b/test/features/machine_payment/widgets/payment_buttons_test.dart @@ -0,0 +1,139 @@ +import 'package:clean_stream_laundry_app/features/machine_payment/widgets/payment_buttons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget({ + double? price = 3.50, + double? userBalance = 10.0, + bool isProcessing = false, + VoidCallback? onDirectPay, + VoidCallback? onLoyaltyPay, + }) { + return MaterialApp( + home: Scaffold( + body: PaymentButtons( + price: price, + userBalance: userBalance, + isProcessing: isProcessing, + onDirectPay: onDirectPay ?? () {}, + onLoyaltyPay: onLoyaltyPay ?? () {}, + ), + ), + ); + } + + group('PaymentButtons', () { + group('Pay button', () { + testWidgets('displays correct price text', (tester) async { + await tester.pumpWidget(buildWidget(price: 3.50)); + expect(find.text('Pay \$3.50'), findsOneWidget); + }); + + testWidgets('displays Pay when price is null', (tester) async { + await tester.pumpWidget(buildWidget(price: null)); + expect(find.text('Pay'), findsOneWidget); + }); + + testWidgets('displays Pay when price is zero', (tester) async { + await tester.pumpWidget(buildWidget(price: 0)); + expect(find.text('Pay'), findsOneWidget); + }); + + testWidgets('is enabled when price is set', (tester) async { + await tester.pumpWidget(buildWidget(price: 3.50)); + + final button = tester.widget( + find.widgetWithText(ElevatedButton, 'Pay \$3.50'), + ); + expect(button.onPressed, isNotNull); + }); + + testWidgets('is disabled when price is null', (tester) async { + await tester.pumpWidget(buildWidget(price: null)); + + final button = + tester.widget(find.widgetWithText(ElevatedButton, 'Pay')); + expect(button.onPressed, isNull); + }); + + testWidgets('is disabled when price is zero', (tester) async { + await tester.pumpWidget(buildWidget(price: 0)); + + final button = + tester.widget(find.widgetWithText(ElevatedButton, 'Pay')); + expect(button.onPressed, isNull); + }); + + testWidgets('calls onDirectPay when tapped', (tester) async { + var tapped = false; + await tester.pumpWidget( + buildWidget(price: 3.50, onDirectPay: () => tapped = true)); + + await tester.tap(find.text('Pay \$3.50')); + expect(tapped, isTrue); + }); + + testWidgets('shows spinner when isProcessing is true', (tester) async { + await tester.pumpWidget(buildWidget(isProcessing: true, price: 3.50)); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + }); + + group('Pay with Loyalty button', () { + testWidgets('is enabled when balance exceeds price', (tester) async { + await tester.pumpWidget( + buildWidget(price: 3.50, userBalance: 10.0)); + + final button = tester.widget( + find.widgetWithText(ElevatedButton, 'Pay with Loyalty'), + ); + expect(button.onPressed, isNotNull); + }); + + testWidgets('is disabled when balance is less than price', (tester) async { + await tester.pumpWidget( + buildWidget(price: 5.00, userBalance: 2.00)); + + final button = tester.widget( + find.widgetWithText(ElevatedButton, 'Pay with Loyalty'), + ); + expect(button.onPressed, isNull); + }); + + testWidgets('is disabled when balance equals price', (tester) async { + // balance must be strictly greater than price based on the condition + await tester.pumpWidget( + buildWidget(price: 5.00, userBalance: 5.00)); + + final button = tester.widget( + find.widgetWithText(ElevatedButton, 'Pay with Loyalty'), + ); + // userBalance < price is false here, so it should be enabled + expect(button.onPressed, isNotNull); + }); + + testWidgets('is disabled when price is null', (tester) async { + await tester.pumpWidget( + buildWidget(price: null, userBalance: 10.0)); + + final button = tester.widget( + find.widgetWithText(ElevatedButton, 'Pay with Loyalty'), + ); + expect(button.onPressed, isNull); + }); + + testWidgets('calls onLoyaltyPay when tapped', (tester) async { + var tapped = false; + await tester.pumpWidget(buildWidget( + price: 3.50, + userBalance: 10.0, + onLoyaltyPay: () => tapped = true, + )); + + await tester.tap(find.text('Pay with Loyalty')); + expect(tapped, isTrue); + }); + }); + }); +} \ No newline at end of file diff --git a/test/widgets/washer_controls_card_test.dart b/test/features/machine_payment/widgets/washer_controls_card_test.dart similarity index 97% rename from test/widgets/washer_controls_card_test.dart rename to test/features/machine_payment/widgets/washer_controls_card_test.dart index e4d75f8..996a6aa 100644 --- a/test/widgets/washer_controls_card_test.dart +++ b/test/features/machine_payment/widgets/washer_controls_card_test.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/widgets/washer_controls_card.dart'; +import 'package:clean_stream_laundry_app/features/machine_payment/widgets/washer_controls_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; From f1684e9fa22995f8eebf6cd51ec1ee876ff4775f Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 15:24:45 -0400 Subject: [PATCH 31/79] Separates request refund --- lib/features/refund_request/controller.dart | 119 ++++++++++++++ .../refund_request/refund_request.dart | 154 ++++++++++++++++++ .../widgets/disclaimer_card.dart | 36 ++++ .../refund_request/widgets/header.dart | 62 +++++++ .../refund_request/widgets/refund_form.dart | 126 ++++++++++++++ 5 files changed, 497 insertions(+) create mode 100644 lib/features/refund_request/controller.dart create mode 100644 lib/features/refund_request/refund_request.dart create mode 100644 lib/features/refund_request/widgets/disclaimer_card.dart create mode 100644 lib/features/refund_request/widgets/header.dart create mode 100644 lib/features/refund_request/widgets/refund_form.dart diff --git a/lib/features/refund_request/controller.dart b/lib/features/refund_request/controller.dart new file mode 100644 index 0000000..ce5dcb8 --- /dev/null +++ b/lib/features/refund_request/controller.dart @@ -0,0 +1,119 @@ +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +class RefundController extends ChangeNotifier { + final TransactionService transactionService; + final EdgeFunctionService edgeFunctionService; + final ProfileService profileService; + final AuthService authService; + + RefundController({ + TransactionService? transactionService, + EdgeFunctionService? edgeFunctionService, + ProfileService? profileService, + AuthService? authService, + }) : transactionService = + transactionService ?? GetIt.instance(), + edgeFunctionService = + edgeFunctionService ?? GetIt.instance(), + profileService = profileService ?? GetIt.instance(), + authService = authService ?? GetIt.instance(); + + final TextEditingController descriptionController = TextEditingController(); + + List recentTransactions = []; + List recentTransactionIDs = []; + + String? selectedTransaction; + int? selectedTransactionIndex; + + bool attemptedSubmit = false; + bool isLoading = false; + bool isFetchingTransactions = true; + + void disposeController() { + descriptionController.dispose(); + } + + bool get isFormValid => + selectedTransaction != null && + descriptionController.text.trim().isNotEmpty; + + Future fetchTransactions() async { + try { + final result = + await transactionService.getRefundableTransactionsForUser(); + recentTransactions = result.transactions; + recentTransactionIDs = result.ids; + + recentTransactions.removeWhere( + (t) => t.contains('added to Loyalty Card'), + ); + } catch (e) { + // silently ignore — page stays usable with empty list + } finally { + isFetchingTransactions = false; + notifyListeners(); + } + } + + void selectTransaction(String transaction) { + selectedTransaction = transaction; + selectedTransactionIndex = recentTransactions.indexOf(transaction); + notifyListeners(); + } + + String getTransactionID() { + return recentTransactionIDs[selectedTransactionIndex!].toString(); + } + + Future getUserName() async { + final userId = authService.getCurrentUserId; + if (userId == null) return null; + return profileService.getUserNameById(userId); + } + + Future submitRefund() async { + final userId = authService.getCurrentUserId; + if (userId == null) return false; + + isLoading = true; + notifyListeners(); + + try { + final transactionId = getTransactionID(); + final description = descriptionController.text; + final username = await getUserName(); + + final amount = await transactionService.recordRefundRequest( + transaction_id: transactionId, + description: description, + ); + + await edgeFunctionService.runEdgeFunction( + name: 'refund-email', + body: { + 'username': username, + 'user_id': userId, + 'transaction_id': transactionId, + 'amount': amount, + 'description': description, + }, + ); + + return true; + } finally { + isLoading = false; + notifyListeners(); + } + } + + void markAttemptedSubmit() { + attemptedSubmit = true; + notifyListeners(); + } +} \ No newline at end of file diff --git a/lib/features/refund_request/refund_request.dart b/lib/features/refund_request/refund_request.dart new file mode 100644 index 0000000..132a8c3 --- /dev/null +++ b/lib/features/refund_request/refund_request.dart @@ -0,0 +1,154 @@ +import 'controller.dart'; +import 'widgets/disclaimer_card.dart'; +import 'widgets/refund_form.dart'; +import 'widgets/header.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; + +class RefundPage extends StatefulWidget { + const RefundPage({super.key}); + + @override + State createState() => RefundPageState(); +} + +class RefundPageState extends State { + late final RefundController _controller; + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _controller = RefundController(); + _controller.addListener(() { + if (mounted) setState(() {}); + }); + _controller.descriptionController.addListener(() { + if (mounted) setState(() {}); + }); + _controller.fetchTransactions(); + } + + @override + void dispose() { + _controller.disposeController(); + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + Future _onSubmitPressed() async { + _controller.markAttemptedSubmit(); + if (!_controller.isFormValid) return; + await _handleRefund(); + } + + Future _handleRefund() async { + try { + final success = await _controller.submitRefund(); + if (!mounted) return; + + if (!success) return; + + _showRefundDialog(); + } catch (e) { + if (!mounted) return; + } + } + + void _showRefundDialog() { + statusDialog( + context, + title: 'Success', + message: 'Your refund request has been submitted', + isSuccess: true, + ).then((_) { + if (mounted) context.go('/settings'); + }); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + leading: IconButton( + onPressed: () => context.pop(), + icon: const Icon(Icons.arrow_back, color: Colors.white), + ), + backgroundColor: Colors.transparent, + title: const Text('Request Refund', + style: TextStyle(color: Colors.white)), + centerTitle: true, + flexibleSpace: Container( + decoration: BoxDecoration( + gradient: colorScheme.primaryGradient, + ), + ), + ), + body: KeyboardListener( + focusNode: _focusNode, + autofocus: kIsWeb, + onKeyEvent: (keyEvent) { + if (keyEvent is KeyDownEvent && + keyEvent.logicalKey == LogicalKeyboardKey.enter) { + _handleRefund(); + } + }, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Header(), + const SizedBox(height: 28), + RefundForm(controller: _controller), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _controller.isLoading ? null : _onSubmitPressed, + style: ElevatedButton.styleFrom( + backgroundColor: _controller.isFormValid + ? colorScheme.primary + : Colors.grey, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: _controller.isFormValid ? 2 : 0, + ), + child: _controller.isLoading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.5, + ), + ) + : const Text( + 'Submit Refund Request', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 12), + const DisclaimerCard(), + const SizedBox(height: 4), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/refund_request/widgets/disclaimer_card.dart b/lib/features/refund_request/widgets/disclaimer_card.dart new file mode 100644 index 0000000..b421b50 --- /dev/null +++ b/lib/features/refund_request/widgets/disclaimer_card.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +class DisclaimerCard extends StatelessWidget { + const DisclaimerCard({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFFFFFDE7).withOpacity(0.8), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFF9A825).withOpacity(0.4)), + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline_rounded, size: 16, color: Colors.black), + SizedBox(width: 10), + Expanded( + child: Text( + 'Refund requests are reviewed within 3–5 business days. ' + 'Approved refunds will be returned to your loyalty card balance. ' + 'We reserve the right to deny requests that do not meet our refund policy criteria.', + style: TextStyle( + fontSize: 12, + color: Colors.black, + height: 1.5, + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/refund_request/widgets/header.dart b/lib/features/refund_request/widgets/header.dart new file mode 100644 index 0000000..b5490bb --- /dev/null +++ b/lib/features/refund_request/widgets/header.dart @@ -0,0 +1,62 @@ +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; + +class Header extends StatelessWidget { + const Header({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colorScheme.primary.withOpacity(0.08), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colorScheme.primary.withOpacity(0.2)), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primary.withOpacity(0.15), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + Icons.receipt_long_rounded, + color: colorScheme.primary, + size: 28, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Submit a Refund Request', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: colorScheme.fontInverted, + ), + ), + const SizedBox(height: 4), + Text( + 'Select a transaction and describe your issue. ' + 'Our team will review it shortly.', + style: TextStyle( + fontSize: 13, + color: colorScheme.fontSecondary, + ), + ), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/refund_request/widgets/refund_form.dart b/lib/features/refund_request/widgets/refund_form.dart new file mode 100644 index 0000000..b6a7f3a --- /dev/null +++ b/lib/features/refund_request/widgets/refund_form.dart @@ -0,0 +1,126 @@ +import '../controller.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/widgets/transactions_search_sheet.dart'; +import 'package:flutter/material.dart'; + +class RefundForm extends StatelessWidget { + final RefundController controller; + + const RefundForm({super.key, required this.controller}); + + InputDecoration _inputDecoration(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: colorScheme.fontSecondary, width: 1.5), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: colorScheme.fontSecondary, width: 1.5), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Colors.blue, width: 2), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Select a Transaction', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: colorScheme.fontInverted, + ), + ), + const SizedBox(height: 8), + + controller.isFetchingTransactions + ? const Center(child: CircularProgressIndicator()) + : GestureDetector( + onTap: () async { + final selected = + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => TransactionSearchSheet( + transactions: controller.recentTransactions, + ), + ); + if (selected != null) { + controller.selectTransaction(selected); + } + }, + child: AbsorbPointer( + child: TextFormField( + decoration: _inputDecoration(context).copyWith( + hintText: 'Select a transaction', + hintStyle: + TextStyle(color: colorScheme.fontSecondary), + ), + controller: TextEditingController( + text: controller.selectedTransaction, + ), + style: TextStyle(color: colorScheme.fontInverted), + ), + ), + ), + + const SizedBox(height: 24), + + Text( + 'Reason for Refund', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: colorScheme.fontInverted, + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller.descriptionController, + minLines: 4, + maxLines: null, + keyboardType: TextInputType.multiline, + style: TextStyle(color: colorScheme.fontInverted), + decoration: _inputDecoration(context).copyWith( + hintText: 'Describe the issue with your transaction...', + hintStyle: TextStyle(color: colorScheme.fontSecondary), + ), + ), + + if (controller.attemptedSubmit && !controller.isFormValid) + const Padding( + padding: EdgeInsets.only(top: 12), + child: Row( + children: [ + Icon(Icons.info_outline, color: Colors.red, size: 16), + SizedBox(width: 6), + Text( + 'Please fill in all fields', + style: TextStyle(color: Colors.red, fontSize: 13), + ), + ], + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file From b95d258e3343727b9f4e8bb5482c03e907667f83 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 15:38:22 -0400 Subject: [PATCH 32/79] Adds tests for refund --- .../refund_request/controller_test.dart | 399 +++++++++++++++ test/features/refund_request/mocks.dart | 10 + .../refund_request/refund_request_test.dart | 461 ++++++++++++++++++ .../widgets/disclaimer_card_test.dart | 61 +++ .../refund_request/widgets/header_test.dart | 45 ++ .../widgets/refund_form_test.dart | 279 +++++++++++ 6 files changed, 1255 insertions(+) create mode 100644 test/features/refund_request/controller_test.dart create mode 100644 test/features/refund_request/mocks.dart create mode 100644 test/features/refund_request/refund_request_test.dart create mode 100644 test/features/refund_request/widgets/disclaimer_card_test.dart create mode 100644 test/features/refund_request/widgets/header_test.dart create mode 100644 test/features/refund_request/widgets/refund_form_test.dart diff --git a/test/features/refund_request/controller_test.dart b/test/features/refund_request/controller_test.dart new file mode 100644 index 0000000..2653316 --- /dev/null +++ b/test/features/refund_request/controller_test.dart @@ -0,0 +1,399 @@ +import 'package:clean_stream_laundry_app/features/refund_request/controller.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; +import 'dart:async'; + +void main() { + late MockAuthService mockAuthService; + late MockTransactionService mockTransactionService; + late MockEdgeFunctionService mockEdgeFunctionService; + late MockProfileService mockProfileService; + + setUp(() { + mockAuthService = MockAuthService(); + mockTransactionService = MockTransactionService(); + mockEdgeFunctionService = MockEdgeFunctionService(); + mockProfileService = MockProfileService(); + }); + + RefundController buildController() => RefundController( + authService: mockAuthService, + transactionService: mockTransactionService, + edgeFunctionService: mockEdgeFunctionService, + profileService: mockProfileService, + ); + + group('fetchTransactions', () { + test('populates recentTransactions and recentTransactionIDs', () async { + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => ( + transactions: ['\$10.00 - Washer on Jan 01'], + ids: [1], + )); + + final controller = buildController(); + await controller.fetchTransactions(); + + expect(controller.recentTransactions, ['\$10.00 - Washer on Jan 01']); + expect(controller.recentTransactionIDs, [1]); + }); + + test('filters out loyalty card transactions', () async { + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => ( + transactions: [ + '\$10.00 added to Loyalty Card on Jan 01', + '\$5.00 - Washer on Jan 02', + ], + ids: [1, 2], + )); + + final controller = buildController(); + await controller.fetchTransactions(); + + expect(controller.recentTransactions.length, 1); + expect(controller.recentTransactions.first, '\$5.00 - Washer on Jan 02'); + }); + + test('sets isFetchingTransactions to false after completion', () async { + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => + (transactions: [], ids: [])); + + final controller = buildController(); + expect(controller.isFetchingTransactions, isTrue); + + await controller.fetchTransactions(); + + expect(controller.isFetchingTransactions, isFalse); + }); + + test('sets isFetchingTransactions to false even when service throws', + () async { + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenThrow(Exception('Network error')); + + final controller = buildController(); + await controller.fetchTransactions(); + + expect(controller.isFetchingTransactions, isFalse); + }); + + test('notifies listeners when fetch completes', () async { + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => + (transactions: [], ids: [])); + + final controller = buildController(); + var notified = false; + controller.addListener(() => notified = true); + + await controller.fetchTransactions(); + + expect(notified, isTrue); + }); + }); + + group('selectTransaction', () { + test('sets selectedTransaction and selectedTransactionIndex', () async { + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => ( + transactions: [ + '\$10.00 - Washer on Jan 01', + '\$20.00 - Dryer on Jan 02', + ], + ids: [1, 2], + )); + + final controller = buildController(); + await controller.fetchTransactions(); + + controller.selectTransaction('\$20.00 - Dryer on Jan 02'); + + expect(controller.selectedTransaction, '\$20.00 - Dryer on Jan 02'); + expect(controller.selectedTransactionIndex, 1); + }); + + test('notifies listeners on selection', () async { + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => ( + transactions: ['\$10.00 - Washer on Jan 01'], + ids: [1], + )); + + final controller = buildController(); + await controller.fetchTransactions(); + + var notified = false; + controller.addListener(() => notified = true); + + controller.selectTransaction('\$10.00 - Washer on Jan 01'); + + expect(notified, isTrue); + }); + }); + + group('isFormValid', () { + test('returns false when no transaction selected and description empty', + () { + final controller = buildController(); + expect(controller.isFormValid, isFalse); + }); + + test('returns false when transaction selected but description empty', () async { + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => ( + transactions: ['\$10.00 - Washer on Jan 01'], + ids: [1], + )); + + final controller = buildController(); + await controller.fetchTransactions(); + controller.selectTransaction('\$10.00 - Washer on Jan 01'); + + expect(controller.isFormValid, isFalse); + }); + + test('returns false when description filled but no transaction selected', + () { + final controller = buildController(); + controller.descriptionController.text = 'Some reason'; + + expect(controller.isFormValid, isFalse); + }); + + test('returns true when both transaction and description are set', () async { + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => ( + transactions: ['\$10.00 - Washer on Jan 01'], + ids: [1], + )); + + final controller = buildController(); + await controller.fetchTransactions(); + controller.selectTransaction('\$10.00 - Washer on Jan 01'); + controller.descriptionController.text = 'I want a refund'; + + expect(controller.isFormValid, isTrue); + }); + + test('returns false when description is whitespace only', () async { + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => ( + transactions: ['\$10.00 - Washer on Jan 01'], + ids: [1], + )); + + final controller = buildController(); + await controller.fetchTransactions(); + controller.selectTransaction('\$10.00 - Washer on Jan 01'); + controller.descriptionController.text = ' '; + + expect(controller.isFormValid, isFalse); + }); + }); + + group('getTransactionID', () { + test('returns the correct ID for selected index', () async { + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => ( + transactions: [ + '\$10.00 - Washer on Jan 01', + '\$20.00 - Dryer on Jan 02', + ], + ids: [111, 222], + )); + + final controller = buildController(); + await controller.fetchTransactions(); + controller.selectTransaction('\$20.00 - Dryer on Jan 02'); + + expect(controller.getTransactionID(), '222'); + }); + }); + + group('submitRefund', () { + setUp(() async {}); + + test('returns false immediately when userId is null', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn(null); + + final controller = buildController(); + final result = await controller.submitRefund(); + + expect(result, isFalse); + verifyNever(() => mockTransactionService.recordRefundRequest( + transaction_id: any(named: 'transaction_id'), + description: any(named: 'description'), + )); + }); + + test('calls recordRefundRequest with correct args', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user-1'); + when(() => mockProfileService.getUserNameById('user-1')) + .thenAnswer((_) async => 'John Doe'); + when(() => mockTransactionService.recordRefundRequest( + transaction_id: any(named: 'transaction_id'), + description: any(named: 'description'), + )).thenAnswer((_) async => '25.50'); + when(() => mockEdgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )).thenAnswer((_) async => null); + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => ( + transactions: ['\$25.50 - Washer on Jan 01'], + ids: [123], + )); + + final controller = buildController(); + await controller.fetchTransactions(); + controller.selectTransaction('\$25.50 - Washer on Jan 01'); + controller.descriptionController.text = 'Test reason'; + + await controller.submitRefund(); + + verify(() => mockTransactionService.recordRefundRequest( + transaction_id: '123', + description: 'Test reason', + )).called(1); + }); + + test('calls edge function with correct body', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user-1'); + when(() => mockProfileService.getUserNameById('user-1')) + .thenAnswer((_) async => 'John Doe'); + when(() => mockTransactionService.recordRefundRequest( + transaction_id: any(named: 'transaction_id'), + description: any(named: 'description'), + )).thenAnswer((_) async => '25.50'); + when(() => mockEdgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )).thenAnswer((_) async => null); + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => ( + transactions: ['\$25.50 - Washer on Jan 01'], + ids: [123], + )); + + final controller = buildController(); + await controller.fetchTransactions(); + controller.selectTransaction('\$25.50 - Washer on Jan 01'); + controller.descriptionController.text = 'Wrong charge'; + + await controller.submitRefund(); + + verify(() => mockEdgeFunctionService.runEdgeFunction( + name: 'refund-email', + body: { + 'username': 'John Doe', + 'user_id': 'user-1', + 'transaction_id': '123', + 'amount': '25.50', + 'description': 'Wrong charge', + }, + )).called(1); + }); + + test('returns true on successful submission', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user-1'); + when(() => mockProfileService.getUserNameById('user-1')) + .thenAnswer((_) async => 'John Doe'); + when(() => mockTransactionService.recordRefundRequest( + transaction_id: any(named: 'transaction_id'), + description: any(named: 'description'), + )).thenAnswer((_) async => '10.00'); + when(() => mockEdgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )).thenAnswer((_) async => null); + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => ( + transactions: ['\$10.00 - Washer on Jan 01'], + ids: [1], + )); + + final controller = buildController(); + await controller.fetchTransactions(); + controller.selectTransaction('\$10.00 - Washer on Jan 01'); + controller.descriptionController.text = 'Reason'; + + final result = await controller.submitRefund(); + + expect(result, isTrue); + }); + + test('sets and clears isLoading around submission', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user-1'); + when(() => mockProfileService.getUserNameById('user-1')) + .thenAnswer((_) async => 'John Doe'); + + final completer = Completer(); + when(() => mockTransactionService.recordRefundRequest( + transaction_id: any(named: 'transaction_id'), + description: any(named: 'description'), + )).thenAnswer((_) => completer.future); + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => ( + transactions: ['\$10.00 - Washer on Jan 01'], + ids: [1], + )); + + final controller = buildController(); + await controller.fetchTransactions(); + controller.selectTransaction('\$10.00 - Washer on Jan 01'); + controller.descriptionController.text = 'Reason'; + + final future = controller.submitRefund(); + expect(controller.isLoading, isTrue); + + when(() => mockEdgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )).thenAnswer((_) async => null); + completer.complete('10.00'); + await future; + + expect(controller.isLoading, isFalse); + }); + + test('clears isLoading even when service throws', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user-1'); + when(() => mockProfileService.getUserNameById('user-1')) + .thenAnswer((_) async => 'John Doe'); + when(() => mockTransactionService.recordRefundRequest( + transaction_id: any(named: 'transaction_id'), + description: any(named: 'description'), + )).thenThrow(Exception('Server error')); + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => ( + transactions: ['\$10.00 - Washer on Jan 01'], + ids: [1], + )); + + final controller = buildController(); + await controller.fetchTransactions(); + controller.selectTransaction('\$10.00 - Washer on Jan 01'); + controller.descriptionController.text = 'Reason'; + + await expectLater(controller.submitRefund(), throwsException); + + expect(controller.isLoading, isFalse); + }); + }); + + group('markAttemptedSubmit', () { + test('sets attemptedSubmit to true and notifies', () { + final controller = buildController(); + var notified = false; + controller.addListener(() => notified = true); + + controller.markAttemptedSubmit(); + + expect(controller.attemptedSubmit, isTrue); + expect(notified, isTrue); + }); + }); +} \ No newline at end of file diff --git a/test/features/refund_request/mocks.dart b/test/features/refund_request/mocks.dart new file mode 100644 index 0000000..78b2e86 --- /dev/null +++ b/test/features/refund_request/mocks.dart @@ -0,0 +1,10 @@ +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthService extends Mock implements AuthService {} +class MockTransactionService extends Mock implements TransactionService {} +class MockEdgeFunctionService extends Mock implements EdgeFunctionService {} +class MockProfileService extends Mock implements ProfileService {} \ No newline at end of file diff --git a/test/features/refund_request/refund_request_test.dart b/test/features/refund_request/refund_request_test.dart new file mode 100644 index 0000000..7c30e06 --- /dev/null +++ b/test/features/refund_request/refund_request_test.dart @@ -0,0 +1,461 @@ +import 'package:clean_stream_laundry_app/features/refund_request/refund_request.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; +import 'package:clean_stream_laundry_app/widgets/transactions_search_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; +import 'dart:async'; + +void main() { + late MockAuthService mockAuthService; + late MockTransactionService mockTransactionService; + late MockEdgeFunctionService mockEdgeFunctionService; + late MockProfileService mockProfileService; + + setUp(() async { + mockAuthService = MockAuthService(); + mockTransactionService = MockTransactionService(); + mockEdgeFunctionService = MockEdgeFunctionService(); + mockProfileService = MockProfileService(); + + await GetIt.instance.reset(); + GetIt.instance.registerSingleton(mockAuthService); + GetIt.instance.registerSingleton(mockTransactionService); + GetIt.instance.registerSingleton(mockEdgeFunctionService); + GetIt.instance.registerSingleton(mockProfileService); + + when(() => mockAuthService.getCurrentUserId).thenReturn('test-user-id'); + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => (transactions: [], ids: [])); + }); + + tearDown(() async { + await GetIt.instance.reset(); + }); + + Widget createWidget() { + return MaterialApp.router( + routerConfig: GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const RefundPage(), + ), + GoRoute( + path: '/settings', + builder: (_, __) => + const Scaffold(body: Text('Settings Page')), + ), + ], + ), + ); + } + + void mockTransactions({ + List transactions = const [], + List ids = const [], + }) { + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => (transactions: transactions, ids: ids)); + } + + Future selectTransaction(WidgetTester tester, String transaction) async { + await tester.tap(find.byType(TextFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text(transaction)); + await tester.pumpAndSettle(); + } + + Future enterDescription(WidgetTester tester, String text) async { + await tester.enterText( + find.widgetWithText( + TextField, 'Describe the issue with your transaction...'), + text, + ); + await tester.pumpAndSettle(); + } + + group('Static UI', () { + testWidgets('renders all required UI elements', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Request Refund'), findsOneWidget); + expect(find.text('Submit a Refund Request'), findsOneWidget); + expect( + find.text( + 'Select a transaction and describe your issue. ' + 'Our team will review it shortly.', + ), + findsOneWidget, + ); + expect(find.byIcon(Icons.receipt_long_rounded), findsOneWidget); + expect(find.text('Select a Transaction'), findsOneWidget); + expect( + find.text('Describe the issue with your transaction...'), + findsOneWidget, + ); + expect(find.text('Submit Refund Request'), findsOneWidget); + }); + + testWidgets('displays disclaimer text', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect( + find.textContaining('Refund requests are reviewed within 3–5 business days.'), + findsOneWidget, + ); + expect(find.byIcon(Icons.info_outline_rounded), findsOneWidget); + }); + + testWidgets('displays back arrow in AppBar', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.arrow_back), findsOneWidget); + }); + }); + + + group('Transaction fetching', () { + testWidgets('calls getRefundableTransactionsForUser on init', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + verify(() => mockTransactionService.getRefundableTransactionsForUser()) + .called(1); + }); + + testWidgets('shows loading indicator while fetching', (tester) async { + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 100)); + return (transactions: [], ids: []); + }); + + await tester.pumpWidget(createWidget()); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + await tester.pumpAndSettle(); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('handles fetch error gracefully', (tester) async { + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenThrow(Exception('Network error')); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Request Refund'), findsOneWidget); + }); + }); + + + group('Transaction selection', () { + testWidgets('opens bottom sheet when transaction picker tapped', + (tester) async { + mockTransactions( + transactions: ['\$10.00 - machine on Jan 01, 2024'], + ids: [1], + ); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(TextFormField)); + await tester.pumpAndSettle(); + + expect(find.byType(TransactionSearchSheet), findsOneWidget); + }); + + testWidgets('selecting a transaction updates the picker field', + (tester) async { + mockTransactions( + transactions: ['\$10.00 - machine on Jan 01, 2024'], + ids: [1], + ); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await selectTransaction(tester, '\$10.00 - machine on Jan 01, 2024'); + + expect(find.text('\$10.00 - machine on Jan 01, 2024'), findsOneWidget); + }); + }); + + group('Form validation', () { + testWidgets('shows error when submit tapped with empty form', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Submit Refund Request')); + await tester.pump(); + + expect(find.text('Please fill in all fields'), findsOneWidget); + }); + + testWidgets('does not show error before first submit attempt', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Please fill in all fields'), findsNothing); + }); + + testWidgets('error disappears after form becomes valid', (tester) async { + mockTransactions( + transactions: ['\$10.00 - machine on Jan 01, 2024'], + ids: [1], + ); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + // Trigger validation error + await tester.tap(find.widgetWithText(ElevatedButton, 'Submit Refund Request')); + await tester.pump(); + expect(find.text('Please fill in all fields'), findsOneWidget); + + // Fill in form + await selectTransaction(tester, '\$10.00 - machine on Jan 01, 2024'); + await enterDescription(tester, 'Test reason'); + + expect(find.text('Please fill in all fields'), findsNothing); + }); + }); + + group('Refund submission', () { + testWidgets('calls all required services on valid submission', + (tester) async { + mockTransactions( + transactions: ['\$25.50 - machine on Jan 01, 2024'], + ids: [123], + ); + when(() => mockProfileService.getUserNameById('test-user-id')) + .thenAnswer((_) async => 'Test User'); + when(() => mockTransactionService.recordRefundRequest( + transaction_id: any(named: 'transaction_id'), + description: any(named: 'description'), + )).thenAnswer((_) async => '25.50'); + when(() => mockEdgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )).thenAnswer((_) async => null); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await selectTransaction(tester, '\$25.50 - machine on Jan 01, 2024'); + await enterDescription(tester, 'Test refund reason'); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Submit Refund Request')); + await tester.pumpAndSettle(); + + verify(() => mockProfileService.getUserNameById('test-user-id')).called(1); + verify(() => mockTransactionService.recordRefundRequest( + transaction_id: '123', + description: 'Test refund reason', + )).called(1); + verify(() => mockEdgeFunctionService.runEdgeFunction( + name: 'refund-email', + body: any(named: 'body'), + )).called(1); + }); + + testWidgets('calls edge function with correct parameters', (tester) async { + mockTransactions( + transactions: ['\$50.00 - machine on Jan 15, 2024'], + ids: [456], + ); + when(() => mockProfileService.getUserNameById('test-user-id')) + .thenAnswer((_) async => 'John Doe'); + when(() => mockTransactionService.recordRefundRequest( + transaction_id: any(named: 'transaction_id'), + description: any(named: 'description'), + )).thenAnswer((_) async => '50.00'); + when(() => mockEdgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )).thenAnswer((_) async => null); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await selectTransaction(tester, '\$50.00 - machine on Jan 15, 2024'); + await enterDescription(tester, 'Wrong charge'); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Submit Refund Request')); + await tester.pumpAndSettle(); + + verify(() => mockEdgeFunctionService.runEdgeFunction( + name: 'refund-email', + body: { + 'username': 'John Doe', + 'user_id': 'test-user-id', + 'transaction_id': '456', + 'amount': '50.00', + 'description': 'Wrong charge', + }, + )).called(1); + }); + + testWidgets('does not call services when userId is null', (tester) async { + when(() => mockAuthService.getCurrentUserId).thenReturn(null); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + verifyNever(() => mockProfileService.getUserNameById(any())); + verifyNever(() => mockTransactionService.recordRefundRequest( + transaction_id: any(named: 'transaction_id'), + description: any(named: 'description'), + )); + }); + + testWidgets('shows success dialog after submission', (tester) async { + mockTransactions( + transactions: ['\$10.00 - machine on Jan 01, 2024'], + ids: [1], + ); + when(() => mockProfileService.getUserNameById(any())) + .thenAnswer((_) async => 'Test User'); + when(() => mockTransactionService.recordRefundRequest( + transaction_id: any(named: 'transaction_id'), + description: any(named: 'description'), + )).thenAnswer((_) async => '10.00'); + when(() => mockEdgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )).thenAnswer((_) async => null); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await selectTransaction(tester, '\$10.00 - machine on Jan 01, 2024'); + await enterDescription(tester, 'Test reason'); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Submit Refund Request')); + await tester.pumpAndSettle(); + + expect(find.text('Success'), findsOneWidget); + expect( + find.text('Your refund request has been submitted'), + findsOneWidget, + ); + }); + + testWidgets('navigates to settings after dismissing success dialog', + (tester) async { + mockTransactions( + transactions: ['\$10.00 - machine on Jan 01, 2024'], + ids: [1], + ); + when(() => mockProfileService.getUserNameById(any())) + .thenAnswer((_) async => 'Test User'); + when(() => mockTransactionService.recordRefundRequest( + transaction_id: any(named: 'transaction_id'), + description: any(named: 'description'), + )).thenAnswer((_) async => '10.00'); + when(() => mockEdgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )).thenAnswer((_) async => null); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await selectTransaction(tester, '\$10.00 - machine on Jan 01, 2024'); + await enterDescription(tester, 'Test reason'); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Submit Refund Request')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + + expect(find.text('Settings Page'), findsOneWidget); + }); + + testWidgets('shows loading indicator while submitting', (tester) async { + mockTransactions( + transactions: ['\$10.00 - machine on Jan 01, 2024'], + ids: [1], + ); + when(() => mockProfileService.getUserNameById(any())) + .thenAnswer((_) async => 'Test User'); + + final completer = Completer(); + when(() => mockTransactionService.recordRefundRequest( + transaction_id: any(named: 'transaction_id'), + description: any(named: 'description'), + )).thenAnswer((_) => completer.future); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await selectTransaction(tester, '\$10.00 - machine on Jan 01, 2024'); + await enterDescription(tester, 'Test reason'); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Submit Refund Request')); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + completer.complete('10.00'); + when(() => mockEdgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )).thenAnswer((_) async => null); + await tester.pumpAndSettle(); + }); + }); + + group('Keyboard enter', () { + testWidgets('pressing Enter triggers refund when form is valid', + (tester) async { + mockTransactions( + transactions: ['\$25.50 - machine on Jan 01, 2024'], + ids: [123], + ); + when(() => mockProfileService.getUserNameById('test-user-id')) + .thenAnswer((_) async => 'Test User'); + when(() => mockTransactionService.recordRefundRequest( + transaction_id: any(named: 'transaction_id'), + description: any(named: 'description'), + )).thenAnswer((_) async => '25.50'); + when(() => mockEdgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )).thenAnswer((_) async => null); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await selectTransaction(tester, '\$25.50 - machine on Jan 01, 2024'); + await enterDescription(tester, 'Test refund reason'); + + final keyboardListener = + tester.widget(find.byType(KeyboardListener)); + keyboardListener.focusNode.requestFocus(); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + verify(() => mockEdgeFunctionService.runEdgeFunction( + name: 'refund-email', + body: any(named: 'body'), + )).called(1); + }); + }); +} \ No newline at end of file diff --git a/test/features/refund_request/widgets/disclaimer_card_test.dart b/test/features/refund_request/widgets/disclaimer_card_test.dart new file mode 100644 index 0000000..cd33b5b --- /dev/null +++ b/test/features/refund_request/widgets/disclaimer_card_test.dart @@ -0,0 +1,61 @@ +import 'package:clean_stream_laundry_app/features/refund_request/widgets/disclaimer_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget() { + return const MaterialApp( + home: Scaffold(body: DisclaimerCard()), + ); + } + + group('RefundDisclaimerCard', () { + testWidgets('displays info icon', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byIcon(Icons.info_outline_rounded), findsOneWidget); + }); + + testWidgets('displays review period text', (tester) async { + await tester.pumpWidget(buildWidget()); + expect( + find.textContaining( + 'Refund requests are reviewed within 3–5 business days.'), + findsOneWidget, + ); + }); + + testWidgets('displays loyalty card refund text', (tester) async { + await tester.pumpWidget(buildWidget()); + expect( + find.textContaining( + 'Approved refunds will be returned to your loyalty card balance.'), + findsOneWidget, + ); + }); + + testWidgets('displays denial policy text', (tester) async { + await tester.pumpWidget(buildWidget()); + expect( + find.textContaining( + 'We reserve the right to deny requests that do not meet our refund policy criteria.'), + findsOneWidget, + ); + }); + + testWidgets('has yellow background container', (tester) async { + await tester.pumpWidget(buildWidget()); + + final container = tester.widget( + find + .ancestor( + of: find.byIcon(Icons.info_outline_rounded), + matching: find.byType(Container), + ) + .first, + ); + final decoration = container.decoration as BoxDecoration; + expect(decoration.color, const Color(0xFFFFFDE7).withOpacity(0.8)); + expect(decoration.borderRadius, BorderRadius.circular(14)); + }); + }); +} \ No newline at end of file diff --git a/test/features/refund_request/widgets/header_test.dart b/test/features/refund_request/widgets/header_test.dart new file mode 100644 index 0000000..009d4b9 --- /dev/null +++ b/test/features/refund_request/widgets/header_test.dart @@ -0,0 +1,45 @@ +import 'package:clean_stream_laundry_app/features/refund_request/widgets/header.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget() { + return const MaterialApp( + home: Scaffold(body: Header()), + ); + } + + group('Header', () { + testWidgets('displays receipt_long_rounded icon', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byIcon(Icons.receipt_long_rounded), findsOneWidget); + }); + + testWidgets('displays Submit a Refund Request title', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.text('Submit a Refund Request'), findsOneWidget); + }); + + testWidgets('displays description text', (tester) async { + await tester.pumpWidget(buildWidget()); + expect( + find.text( + 'Select a transaction and describe your issue. ' + 'Our team will review it shortly.', + ), + findsOneWidget, + ); + }); + + testWidgets('title has bold font weight', (tester) async { + await tester.pumpWidget(buildWidget()); + final text = tester.widget(find.text('Submit a Refund Request')); + expect(text.style?.fontWeight, FontWeight.bold); + }); + + testWidgets('renders inside a Row', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byType(Row), findsOneWidget); + }); + }); +} \ No newline at end of file diff --git a/test/features/refund_request/widgets/refund_form_test.dart b/test/features/refund_request/widgets/refund_form_test.dart new file mode 100644 index 0000000..420fd0f --- /dev/null +++ b/test/features/refund_request/widgets/refund_form_test.dart @@ -0,0 +1,279 @@ +import 'package:clean_stream_laundry_app/features/refund_request/controller.dart'; +import 'package:clean_stream_laundry_app/features/refund_request/widgets/refund_form.dart'; +import 'package:clean_stream_laundry_app/widgets/transactions_search_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import '../mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late MockTransactionService mockTransactionService; + late MockEdgeFunctionService mockEdgeFunctionService; + late MockProfileService mockProfileService; + + setUp(() { + mockAuthService = MockAuthService(); + mockTransactionService = MockTransactionService(); + mockEdgeFunctionService = MockEdgeFunctionService(); + mockProfileService = MockProfileService(); + }); + + RefundController buildController({ + List transactions = const [], + List ids = const [], + bool isFetchingTransactions = false, + String? selectedTransaction, + bool attemptedSubmit = false, + }) { + when(() => mockTransactionService.getRefundableTransactionsForUser()) + .thenAnswer((_) async => (transactions: transactions, ids: ids)); + + final controller = RefundController( + authService: mockAuthService, + transactionService: mockTransactionService, + edgeFunctionService: mockEdgeFunctionService, + profileService: mockProfileService, + ); + + controller.recentTransactions = List.from(transactions); + controller.recentTransactionIDs = List.from(ids); + controller.isFetchingTransactions = isFetchingTransactions; + if (selectedTransaction != null) { + controller.selectedTransaction = selectedTransaction; + controller.selectedTransactionIndex = + transactions.indexOf(selectedTransaction); + } + if (attemptedSubmit) controller.attemptedSubmit = true; + + return controller; + } + + Widget buildWidget(RefundController controller) { + return MaterialApp( + home: Scaffold( + body: _ControllerWrapper( + controller: controller, + ), + ), + ); + } + + + group('Initial rendering', () { + testWidgets('displays Select a Transaction label', (tester) async { + final controller = buildController(); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.text('Select a Transaction'), findsOneWidget); + }); + + testWidgets('displays Reason for Refund label', (tester) async { + final controller = buildController(); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.text('Reason for Refund'), findsOneWidget); + }); + + testWidgets('displays transaction picker hint text', (tester) async { + final controller = buildController(); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.text('Select a transaction'), findsOneWidget); + }); + + testWidgets('displays description hint text', (tester) async { + final controller = buildController(); + await tester.pumpWidget(buildWidget(controller)); + + expect( + find.text('Describe the issue with your transaction...'), + findsOneWidget, + ); + }); + + testWidgets('does not show validation error initially', (tester) async { + final controller = buildController(); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.text('Please fill in all fields'), findsNothing); + }); + + testWidgets('shows TextFormField and TextField', (tester) async { + final controller = buildController(); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.byType(TextFormField), findsOneWidget); + expect(find.byType(TextField), findsNWidgets(2)); + }); + }); + + + group('Loading state', () { + testWidgets('shows CircularProgressIndicator while fetching', (tester) async { + final controller = buildController(isFetchingTransactions: true); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byType(GestureDetector), findsNothing); + }); + + testWidgets('shows picker when not fetching', (tester) async { + final controller = buildController(isFetchingTransactions: false); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byType(GestureDetector), findsOneWidget); + }); + }); + + + group('Selected transaction', () { + testWidgets('displays selected transaction in picker field', (tester) async { + final controller = buildController( + transactions: ['\$10.00 - Washer on Jan 01'], + ids: [1], + selectedTransaction: '\$10.00 - Washer on Jan 01', + ); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.text('\$10.00 - Washer on Jan 01'), findsOneWidget); + }); + }); + + + group('Bottom sheet', () { + testWidgets('tapping picker opens TransactionSearchSheet', (tester) async { + final controller = buildController( + transactions: ['\$10.00 - Washer on Jan 01'], + ids: [1], + ); + await tester.pumpWidget(buildWidget(controller)); + + await tester.tap(find.byType(GestureDetector)); + await tester.pumpAndSettle(); + + expect(find.byType(TransactionSearchSheet), findsOneWidget); + }); + + testWidgets('selecting a transaction from sheet calls selectTransaction', + (tester) async { + final controller = buildController( + transactions: ['\$10.00 - Washer on Jan 01'], + ids: [1], + ); + await tester.pumpWidget(buildWidget(controller)); + + await tester.tap(find.byType(GestureDetector)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('\$10.00 - Washer on Jan 01')); + await tester.pumpAndSettle(); + + expect(controller.selectedTransaction, '\$10.00 - Washer on Jan 01'); + }); + }); + + + group('Description input', () { + testWidgets('can enter text in description field', (tester) async { + final controller = buildController(); + await tester.pumpWidget(buildWidget(controller)); + + await tester.enterText( + find.widgetWithText( + TextField, 'Describe the issue with your transaction...'), + 'My machine broke', + ); + + expect(controller.descriptionController.text, 'My machine broke'); + }); + }); + + + group('Validation error', () { + testWidgets('shows error when attemptedSubmit is true and form is invalid', + (tester) async { + final controller = buildController(attemptedSubmit: true); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.text('Please fill in all fields'), findsOneWidget); + expect(find.byIcon(Icons.info_outline), findsOneWidget); + }); + + testWidgets( + 'does not show error when attemptedSubmit is true but form is valid', + (tester) async { + final controller = buildController( + transactions: ['\$10.00 - Washer on Jan 01'], + ids: [1], + selectedTransaction: '\$10.00 - Washer on Jan 01', + attemptedSubmit: true, + ); + controller.descriptionController.text = 'Valid reason'; + await tester.pumpWidget(buildWidget(controller)); + + expect(find.text('Please fill in all fields'), findsNothing); + }); + + testWidgets('error disappears after form becomes valid', (tester) async { + final controller = buildController( + transactions: ['\$10.00 - Washer on Jan 01'], + ids: [1], + attemptedSubmit: true, + ); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.text('Please fill in all fields'), findsOneWidget); + + await tester.tap(find.byType(GestureDetector)); + await tester.pumpAndSettle(); + await tester.tap(find.text('\$10.00 - Washer on Jan 01')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText( + TextField, 'Describe the issue with your transaction...'), + 'Good reason', + ); + await tester.pump(); + + expect(find.text('Please fill in all fields'), findsNothing); + }); + }); +} + +// rebuilds form whenever the controller notifies. + +class _ControllerWrapper extends StatefulWidget { + final RefundController controller; + + const _ControllerWrapper({ + required this.controller, + }); + + @override + State<_ControllerWrapper> createState() => _ControllerWrapperState(); +} + +class _ControllerWrapperState extends State<_ControllerWrapper> { + @override + void initState() { + super.initState(); + widget.controller.addListener(_rebuild); + widget.controller.descriptionController.addListener(_rebuild); + } + + void _rebuild() => setState(() {}); + + @override + void dispose() { + widget.controller.removeListener(_rebuild); + widget.controller.descriptionController.removeListener(_rebuild); + super.dispose(); + } + + @override + Widget build(BuildContext context) => + RefundForm(controller: widget.controller); +} \ No newline at end of file From efa18fe285fe4c1a2f524b6f3c540425cbf64a90 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 18:53:35 -0400 Subject: [PATCH 33/79] Separates reset_protected --- lib/features/reset_protected/controller.dart | 103 +++++++++++ .../reset_protected/reset_protected.dart | 162 ++++++++++++++++++ .../widgets/password_field.dart | 61 +++++++ .../widgets/password_requirements.dart | 38 ++++ 4 files changed, 364 insertions(+) create mode 100644 lib/features/reset_protected/controller.dart create mode 100644 lib/features/reset_protected/reset_protected.dart create mode 100644 lib/features/reset_protected/widgets/password_field.dart create mode 100644 lib/features/reset_protected/widgets/password_requirements.dart diff --git a/lib/features/reset_protected/controller.dart b/lib/features/reset_protected/controller.dart new file mode 100644 index 0000000..c770980 --- /dev/null +++ b/lib/features/reset_protected/controller.dart @@ -0,0 +1,103 @@ +import 'package:clean_stream_laundry_app/logic/parsing/password_parser.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +class ResetProtectedController extends ChangeNotifier { + final AuthService authService; + + ResetProtectedController({AuthService? authService}) + : authService = authService ?? GetIt.instance(); + + final TextEditingController passwordController = TextEditingController(); + final TextEditingController confirmController = TextEditingController(); + + bool obscurePassword = true; + bool obscureConfirm = true; + bool isLoading = false; + + String passwordLabel = 'New Password'; + String confirmLabel = 'Confirm Password'; + Color iconColor = Colors.blue; + Color labelColor = Colors.blue; + + void disposeController() { + passwordController.dispose(); + confirmController.dispose(); + } + + void initColors(Color primary) { + iconColor = primary; + labelColor = primary; + notifyListeners(); + } + + void togglePasswordVisibility() { + obscurePassword = !obscurePassword; + notifyListeners(); + } + + void toggleConfirmVisibility() { + obscureConfirm = !obscureConfirm; + notifyListeners(); + } + + void changeColorsToRed(String reason) { + passwordLabel = reason; + confirmLabel = reason; + iconColor = Colors.red; + labelColor = Colors.red; + notifyListeners(); + } + + void resetColors() { + passwordLabel = 'New Password'; + confirmLabel = 'Confirm Password'; + iconColor = Colors.blue; + labelColor = Colors.blue; + notifyListeners(); + } + + void onPasswordChanged() { + if (iconColor == Colors.red) resetColors(); + } + + void onConfirmChanged() { + if (passwordController.text != confirmController.text) { + changeColorsToRed("Passwords don't match"); + } else { + resetColors(); + } + } + + Future submit() async { + final password = passwordController.text.trim(); + final confirm = confirmController.text.trim(); + + if (password.isEmpty || confirm.isEmpty) { + return 'Please fill in all fields'; + } + + if (password != confirm) { + changeColorsToRed("Passwords don't match"); + return 'Passwords don\'t match'; + } + + final requirementError = PasswordParser.process(password); + if (requirementError != null) { + changeColorsToRed(requirementError); + return requirementError; + } + + isLoading = true; + notifyListeners(); + + try { + await authService.updatePassword(password); + return null; + } finally { + isLoading = false; + notifyListeners(); + } + } +} \ No newline at end of file diff --git a/lib/features/reset_protected/reset_protected.dart b/lib/features/reset_protected/reset_protected.dart new file mode 100644 index 0000000..1930da2 --- /dev/null +++ b/lib/features/reset_protected/reset_protected.dart @@ -0,0 +1,162 @@ +import 'controller.dart'; +import 'widgets/password_field.dart'; +import 'widgets/password_requirements.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class ResetProtectedPage extends StatefulWidget { + const ResetProtectedPage({super.key}); + + @override + State createState() => _ResetProtectedPageState(); +} + +class _ResetProtectedPageState extends State { + late final ResetProtectedController _controller; + + @override + void initState() { + super.initState(); + _controller = ResetProtectedController(); + _controller.addListener(() { + if (mounted) setState(() {}); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _controller.initColors(Theme.of(context).colorScheme.primary); + } + + @override + void dispose() { + _controller.disposeController(); + _controller.dispose(); + super.dispose(); + } + + void _showMessage(String msg) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(msg))); + } + + Future _onSubmitPressed() async { + try { + final error = await _controller.submit(); + + if (!mounted) return; + + if (error != null) { + if (error == 'Please fill in all fields') { + _showMessage(error); + } + return; + } + + _showMessage('Password reset successful'); + context.go('/login'); + } catch (e) { + if (!mounted) return; + _showMessage('Failed to reset password'); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + backgroundColor: theme.colorScheme.surface, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 20), + Image.asset( + 'assets/Logo.png', + height: 250, + width: 250, + key: const Key('app_logo'), + ), + Text( + 'Reset Password', + style: theme.textTheme.headlineMedium + ?.copyWith(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Enter your new password below', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 30), + PasswordRequirementsHint( + controller: _controller.passwordController, + ), + PasswordField( + controller: _controller.passwordController, + label: _controller.passwordLabel, + obscureText: _controller.obscurePassword, + onToggleVisibility: + _controller.togglePasswordVisibility, + onChanged: (_) => _controller.onPasswordChanged(), + iconColor: _controller.iconColor, + labelColor: _controller.labelColor, + ), + const SizedBox(height: 16), + PasswordField( + controller: _controller.confirmController, + label: _controller.confirmLabel, + obscureText: _controller.obscureConfirm, + onToggleVisibility: + _controller.toggleConfirmVisibility, + onChanged: (_) => _controller.onConfirmChanged(), + iconColor: _controller.iconColor, + labelColor: _controller.labelColor, + ), + const SizedBox(height: 24), + SizedBox( + height: 50, + child: ElevatedButton( + onPressed: + _controller.isLoading ? null : _onSubmitPressed, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _controller.isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + 'Reset Password', + style: TextStyle(fontSize: 16), + ), + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/reset_protected/widgets/password_field.dart b/lib/features/reset_protected/widgets/password_field.dart new file mode 100644 index 0000000..194ec57 --- /dev/null +++ b/lib/features/reset_protected/widgets/password_field.dart @@ -0,0 +1,61 @@ +import 'package:clean_stream_laundry_app/Logic/Theme/theme.dart'; +import 'package:flutter/material.dart'; + +class PasswordField extends StatelessWidget { + final TextEditingController controller; + final String label; + final bool obscureText; + final VoidCallback onToggleVisibility; + final ValueChanged? onChanged; + final Color iconColor; + final Color labelColor; + + const PasswordField({ + super.key, + required this.controller, + required this.label, + required this.obscureText, + required this.onToggleVisibility, + required this.iconColor, + required this.labelColor, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return TextField( + controller: controller, + obscureText: obscureText, + onChanged: onChanged, + decoration: InputDecoration( + labelText: label, + labelStyle: TextStyle(color: labelColor), + contentPadding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.blue, width: 2.0), + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorScheme.fontSecondary), + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: Icon(Icons.lock, color: iconColor), + suffixIcon: IconButton( + icon: Icon( + obscureText ? Icons.visibility_off : Icons.visibility, + color: Colors.blue, + ), + onPressed: onToggleVisibility, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/reset_protected/widgets/password_requirements.dart b/lib/features/reset_protected/widgets/password_requirements.dart new file mode 100644 index 0000000..03561d9 --- /dev/null +++ b/lib/features/reset_protected/widgets/password_requirements.dart @@ -0,0 +1,38 @@ +import 'package:clean_stream_laundry_app/logic/parsing/password_parser.dart'; +import 'package:flutter/material.dart'; + +class PasswordRequirementsHint extends StatelessWidget { + final TextEditingController controller; + + const PasswordRequirementsHint({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) { + final requirement = PasswordParser.process(value.text); + + if (requirement == null) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey), + ), + child: Text( + requirement, + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + ); + }, + ); + } +} \ No newline at end of file From 9fe4fdb17390969be4463ba3e75dddc0a76061de Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 19:25:32 -0400 Subject: [PATCH 34/79] Adds tests for reset_protected --- .../reset_protected/controller_test.dart | 245 ++++++++++++++ test/features/reset_protected/mocks.dart | 4 + .../reset_protected/reset_protected_test.dart | 302 ++++++++++++++++++ .../widgets/password_field_test.dart | 103 ++++++ .../widgets/password_requirements_test.dart | 63 ++++ 5 files changed, 717 insertions(+) create mode 100644 test/features/reset_protected/controller_test.dart create mode 100644 test/features/reset_protected/mocks.dart create mode 100644 test/features/reset_protected/reset_protected_test.dart create mode 100644 test/features/reset_protected/widgets/password_field_test.dart create mode 100644 test/features/reset_protected/widgets/password_requirements_test.dart diff --git a/test/features/reset_protected/controller_test.dart b/test/features/reset_protected/controller_test.dart new file mode 100644 index 0000000..14b3553 --- /dev/null +++ b/test/features/reset_protected/controller_test.dart @@ -0,0 +1,245 @@ +import 'package:clean_stream_laundry_app/features/reset_protected/controller.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + + setUp(() { + mockAuthService = MockAuthService(); + }); + + ResetProtectedController buildController() => + ResetProtectedController(authService: mockAuthService); + + group('Visibility toggles', () { + test('togglePasswordVisibility flips obscurePassword', () { + final controller = buildController(); + + expect(controller.obscurePassword, isTrue); + controller.togglePasswordVisibility(); + expect(controller.obscurePassword, isFalse); + controller.togglePasswordVisibility(); + expect(controller.obscurePassword, isTrue); + }); + + test('toggleConfirmVisibility flips obscureConfirm', () { + final controller = buildController(); + + expect(controller.obscureConfirm, isTrue); + controller.toggleConfirmVisibility(); + expect(controller.obscureConfirm, isFalse); + }); + + test('togglePasswordVisibility notifies listeners', () { + final controller = buildController(); + var notified = false; + controller.addListener(() => notified = true); + + controller.togglePasswordVisibility(); + expect(notified, isTrue); + }); + }); + + group('Color state', () { + test('changeColorsToRed sets labels and colors to red', () { + final controller = buildController(); + + controller.changeColorsToRed("Passwords don't match"); + + expect(controller.passwordLabel, "Passwords don't match"); + expect(controller.confirmLabel, "Passwords don't match"); + expect(controller.iconColor, Colors.red); + expect(controller.labelColor, Colors.red); + }); + + test('resetColors restores default labels and colors', () { + final controller = buildController(); + controller.changeColorsToRed('Some error'); + + controller.resetColors(); + + expect(controller.passwordLabel, 'New Password'); + expect(controller.confirmLabel, 'Confirm Password'); + expect(controller.iconColor, Colors.blue); + expect(controller.labelColor, Colors.blue); + }); + + test('changeColorsToRed notifies listeners', () { + final controller = buildController(); + var notified = false; + controller.addListener(() => notified = true); + + controller.changeColorsToRed('Error'); + expect(notified, isTrue); + }); + }); + + group('onPasswordChanged', () { + test('resets colors when iconColor is red', () { + final controller = buildController(); + controller.changeColorsToRed('Error'); + + controller.onPasswordChanged(); + + expect(controller.iconColor, Colors.blue); + }); + + test('does nothing when iconColor is not red', () { + final controller = buildController(); + + controller.onPasswordChanged(); + + expect(controller.passwordLabel, 'New Password'); + }); + }); + + group('onConfirmChanged', () { + test('sets red colors when passwords do not match', () { + final controller = buildController(); + controller.passwordController.text = 'Password1!'; + controller.confirmController.text = 'Different1!'; + + controller.onConfirmChanged(); + + expect(controller.iconColor, Colors.red); + expect(controller.passwordLabel, "Passwords don't match"); + }); + + test('resets colors when passwords match', () { + final controller = buildController(); + controller.passwordController.text = 'Password1!'; + controller.confirmController.text = 'Password1!'; + + controller.onConfirmChanged(); + + expect(controller.iconColor, Colors.blue); + expect(controller.passwordLabel, 'New Password'); + }); + }); + + + group('submit', () { + test('returns error message when password field is empty', () async { + final controller = buildController(); + controller.passwordController.text = ''; + controller.confirmController.text = 'Password1!'; + + final result = await controller.submit(); + + expect(result, 'Please fill in all fields'); + verifyNever(() => mockAuthService.updatePassword(any())); + }); + + test('returns error message when confirm field is empty', () async { + final controller = buildController(); + controller.passwordController.text = 'Password1!'; + controller.confirmController.text = ''; + + final result = await controller.submit(); + + expect(result, 'Please fill in all fields'); + verifyNever(() => mockAuthService.updatePassword(any())); + }); + + test('returns mismatch error when passwords differ', () async { + final controller = buildController(); + controller.passwordController.text = 'Password1!'; + controller.confirmController.text = 'Different1!'; + + final result = await controller.submit(); + + expect(result, "Passwords don't match"); + expect(controller.iconColor, Colors.red); + verifyNever(() => mockAuthService.updatePassword(any())); + }); + + test('returns requirement error from PasswordParser for weak password', + () async { + final controller = buildController(); + controller.passwordController.text = 'weak'; + controller.confirmController.text = 'weak'; + + final result = await controller.submit(); + + expect(result, isNotNull); // PasswordParser returned a requirement + expect(controller.iconColor, Colors.red); + verifyNever(() => mockAuthService.updatePassword(any())); + }); + + test('returns null and calls updatePassword on valid matching passwords', + () async { + when(() => mockAuthService.updatePassword(any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = buildController(); + controller.passwordController.text = 'StrongPass1!'; + controller.confirmController.text = 'StrongPass1!'; + + final result = await controller.submit(); + + expect(result, isNull); + verify(() => mockAuthService.updatePassword('StrongPass1!')).called(1); + }); + + test('trims whitespace from password before submitting', () async { + when(() => mockAuthService.updatePassword(any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = buildController(); + controller.passwordController.text = ' StrongPass1! '; + controller.confirmController.text = ' StrongPass1! '; + + await controller.submit(); + + verify(() => mockAuthService.updatePassword('StrongPass1!')).called(1); + }); + + test('sets and clears isLoading around updatePassword', () async { + when(() => mockAuthService.updatePassword(any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = buildController(); + controller.passwordController.text = 'StrongPass1!'; + controller.confirmController.text = 'StrongPass1!'; + + final future = controller.submit(); + expect(controller.isLoading, isTrue); + + await future; + expect(controller.isLoading, isFalse); + }); + + test('clears isLoading even when service throws', () async { + when(() => mockAuthService.updatePassword(any())) + .thenThrow(Exception('network')); + + final controller = buildController(); + controller.passwordController.text = 'StrongPass1!'; + controller.confirmController.text = 'StrongPass1!'; + + await expectLater(controller.submit(), throwsException); + expect(controller.isLoading, isFalse); + }); + + test('notifies listeners twice — once for isLoading true, once false', + () async { + when(() => mockAuthService.updatePassword(any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + final controller = buildController(); + controller.passwordController.text = 'StrongPass1!'; + controller.confirmController.text = 'StrongPass1!'; + + var notifyCount = 0; + controller.addListener(() => notifyCount++); + + await controller.submit(); + + expect(notifyCount, 2); + }); + }); +} \ No newline at end of file diff --git a/test/features/reset_protected/mocks.dart b/test/features/reset_protected/mocks.dart new file mode 100644 index 0000000..5227aca --- /dev/null +++ b/test/features/reset_protected/mocks.dart @@ -0,0 +1,4 @@ +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthService extends Mock implements AuthService {} \ No newline at end of file diff --git a/test/features/reset_protected/reset_protected_test.dart b/test/features/reset_protected/reset_protected_test.dart new file mode 100644 index 0000000..41ec4ed --- /dev/null +++ b/test/features/reset_protected/reset_protected_test.dart @@ -0,0 +1,302 @@ +import 'dart:async'; + +import 'package:clean_stream_laundry_app/features/reset_protected/reset_protected.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + + setUp(() async { + mockAuthService = MockAuthService(); + await GetIt.instance.reset(); + GetIt.instance.registerSingleton(mockAuthService); + }); + + tearDown(() async { + await GetIt.instance.reset(); + }); + + Widget createWidget() { + return MaterialApp.router( + routerConfig: GoRouter( + initialLocation: '/reset-protected', + routes: [ + GoRoute( + path: '/reset-protected', + builder: (_, __) => const ResetProtectedPage(), + ), + GoRoute( + path: '/login', + builder: (_, __) => + const Scaffold(body: Text('Login Page')), + ), + ], + ), + ); + } + + Future enterPassword(WidgetTester tester, String text) async { + final field = find.widgetWithText(TextField, 'New Password'); + await tester.ensureVisible(field); + await tester.enterText(field, text); + await tester.pump(); + } + + Future enterConfirm(WidgetTester tester, String text) async { + final field = find.byType(TextField).at(1); + await tester.ensureVisible(field); + await tester.enterText(field, text); + await tester.pump(); + } + + Future tapSubmit(WidgetTester tester) async { + final button = find.widgetWithText(ElevatedButton, 'Reset Password'); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pump(); + } + + group('Static UI', () { + testWidgets('displays logo', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('app_logo')), findsOneWidget); + }); + + testWidgets('displays Reset Password title', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + expect(find.text('Reset Password'), findsWidgets); + }); + + testWidgets('displays subtitle text', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + expect(find.text('Enter your new password below'), findsOneWidget); + }); + + testWidgets('displays both password fields', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + expect(find.widgetWithText(TextField, 'New Password'), findsOneWidget); + expect( + find.widgetWithText(TextField, 'Confirm Password'), findsOneWidget); + }); + + testWidgets('displays two lock icons', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.lock), findsNWidgets(2)); + }); + + testWidgets('both fields obscured by default', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final fields = tester.widgetList(find.byType(TextField)); + for (final f in fields) { + expect(f.obscureText, isTrue); + } + }); + + testWidgets('displays two visibility_off icons initially', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.visibility_off), findsNWidgets(2)); + }); + }); + + group('Password requirements hint', () { + testWidgets('hint appears when password does not meet requirements', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await enterPassword(tester, 'weak'); + + expect(find.byType(Container), findsWidgets); + }); + + testWidgets('hint disappears when password meets requirements', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await enterPassword(tester, 'StrongPass1!'); + + expect(find.textContaining('Password must'), findsNothing); + }); + }); + + group('Validation', () { + testWidgets('shows snackbar when fields are empty', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tapSubmit(tester); + await tester.pump(); + + expect(find.text('Please fill in all fields'), findsOneWidget); + }); + + testWidgets('shows red labels when passwords do not match', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await enterPassword(tester, 'StrongPass1!'); + await enterConfirm(tester, 'DifferentPass1!'); + + await tapSubmit(tester); + await tester.pump(); + + expect(find.text("Passwords don't match"), findsWidgets); + }); + + testWidgets('colors reset when user starts typing after mismatch', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await enterPassword(tester, 'StrongPass1!'); + await enterConfirm(tester, 'DifferentPass1!'); + + await tapSubmit(tester); + await tester.pump(); + + expect(find.text("Passwords don't match"), findsWidgets); + + await enterConfirm(tester, 'StrongPass1!'); + + expect(find.text("Passwords don't match"), findsNothing); + }); + + testWidgets('does not call service when validation fails', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tapSubmit(tester); + await tester.pump(); + + verifyNever(() => mockAuthService.updatePassword(any())); + }); + }); + + group('Submit', () { + const validPassword = 'StrongPass1!'; + + testWidgets('calls updatePassword with correct value', (tester) async { + when(() => mockAuthService.updatePassword(any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await enterPassword(tester, validPassword); + await enterConfirm(tester, validPassword); + + await tapSubmit(tester); + await tester.pumpAndSettle(); + + verify(() => mockAuthService.updatePassword(validPassword)).called(1); + }); + + testWidgets('shows success snackbar on success', (tester) async { + when(() => mockAuthService.updatePassword(any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await enterPassword(tester, validPassword); + await enterConfirm(tester, validPassword); + + await tapSubmit(tester); + await tester.pumpAndSettle(); + + expect(find.text('Password reset successful'), findsOneWidget); + }); + + testWidgets('navigates to /login on success', (tester) async { + when(() => mockAuthService.updatePassword(any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await enterPassword(tester, validPassword); + await enterConfirm(tester, validPassword); + + await tapSubmit(tester); + await tester.pumpAndSettle(); + + expect(find.text('Login Page'), findsOneWidget); + }); + + testWidgets('shows failure snackbar when service throws', (tester) async { + when(() => mockAuthService.updatePassword(any())) + .thenThrow(Exception('network')); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await enterPassword(tester, validPassword); + await enterConfirm(tester, validPassword); + + await tapSubmit(tester); + await tester.pumpAndSettle(); + + expect(find.text('Failed to reset password'), findsOneWidget); + }); + + testWidgets('shows loading indicator while submitting', (tester) async { + final completer = Completer(); + when(() => mockAuthService.updatePassword(any())) + .thenAnswer((_) async => completer.future); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await enterPassword(tester, validPassword); + await enterConfirm(tester, validPassword); + + await tapSubmit(tester); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Reset Password'), findsOneWidget); + + completer.complete(AuthenticationResponses.success); + await tester.pumpAndSettle(); + }); + + testWidgets('disables button while loading', (tester) async { + final completer = Completer(); + when(() => mockAuthService.updatePassword(any())) + .thenAnswer((_) async => completer.future); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await enterPassword(tester, validPassword); + await enterConfirm(tester, validPassword); + + await tapSubmit(tester); + await tester.pump(); + + final button = tester.widget( + find.byType(ElevatedButton), + ); + expect(button.onPressed, isNull); + + completer.complete(AuthenticationResponses.success); + await tester.pumpAndSettle(); + }); + }); +} \ No newline at end of file diff --git a/test/features/reset_protected/widgets/password_field_test.dart b/test/features/reset_protected/widgets/password_field_test.dart new file mode 100644 index 0000000..2442f1e --- /dev/null +++ b/test/features/reset_protected/widgets/password_field_test.dart @@ -0,0 +1,103 @@ +import 'package:clean_stream_laundry_app/features/reset_protected/widgets/password_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget({ + String label = 'New Password', + bool obscureText = true, + VoidCallback? onToggleVisibility, + ValueChanged? onChanged, + Color iconColor = Colors.blue, + Color labelColor = Colors.blue, + TextEditingController? controller, + }) { + return MaterialApp( + home: Scaffold( + body: PasswordField( + controller: controller ?? TextEditingController(), + label: label, + obscureText: obscureText, + onToggleVisibility: onToggleVisibility ?? () {}, + onChanged: onChanged, + iconColor: iconColor, + labelColor: labelColor, + ), + ), + ); + } + + group('PasswordField', () { + group('Rendering', () { + testWidgets('displays the label', (tester) async { + await tester.pumpWidget(buildWidget(label: 'New Password')); + expect(find.widgetWithText(TextField, 'New Password'), findsOneWidget); + }); + + testWidgets('displays lock icon', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byIcon(Icons.lock), findsOneWidget); + }); + + testWidgets('shows visibility_off icon when obscured', (tester) async { + await tester.pumpWidget(buildWidget(obscureText: true)); + expect(find.byIcon(Icons.visibility_off), findsOneWidget); + }); + + testWidgets('shows visibility icon when not obscured', (tester) async { + await tester.pumpWidget(buildWidget(obscureText: false)); + expect(find.byIcon(Icons.visibility), findsOneWidget); + }); + + testWidgets('field is obscured when obscureText is true', (tester) async { + await tester.pumpWidget(buildWidget(obscureText: true)); + final field = tester.widget(find.byType(TextField)); + expect(field.obscureText, isTrue); + }); + + testWidgets('field is not obscured when obscureText is false', + (tester) async { + await tester.pumpWidget(buildWidget(obscureText: false)); + final field = tester.widget(find.byType(TextField)); + expect(field.obscureText, isFalse); + }); + }); + + group('Colors', () { + testWidgets('lock icon uses provided iconColor', (tester) async { + await tester.pumpWidget(buildWidget(iconColor: Colors.red)); + final icon = tester.widget(find.byIcon(Icons.lock)); + expect(icon.color, Colors.red); + }); + }); + + group('Interaction', () { + testWidgets('calls onToggleVisibility when suffix icon tapped', + (tester) async { + var tapped = false; + await tester.pumpWidget( + buildWidget(onToggleVisibility: () => tapped = true)); + + await tester.tap(find.byIcon(Icons.visibility_off)); + expect(tapped, isTrue); + }); + + testWidgets('calls onChanged when text is entered', (tester) async { + String? changed; + await tester.pumpWidget( + buildWidget(onChanged: (v) => changed = v)); + + await tester.enterText(find.byType(TextField), 'hello'); + expect(changed, 'hello'); + }); + + testWidgets('accepts text input via controller', (tester) async { + final controller = TextEditingController(); + await tester.pumpWidget(buildWidget(controller: controller)); + + await tester.enterText(find.byType(TextField), 'MyPassword1!'); + expect(controller.text, 'MyPassword1!'); + }); + }); + }); +} \ No newline at end of file diff --git a/test/features/reset_protected/widgets/password_requirements_test.dart b/test/features/reset_protected/widgets/password_requirements_test.dart new file mode 100644 index 0000000..0cb7028 --- /dev/null +++ b/test/features/reset_protected/widgets/password_requirements_test.dart @@ -0,0 +1,63 @@ +import 'package:clean_stream_laundry_app/features/reset_protected/widgets/password_requirements.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget(TextEditingController controller) { + return MaterialApp( + home: Scaffold( + body: PasswordRequirementsHint(controller: controller), + ), + ); + } + + group('PasswordRequirementsHint', () { + testWidgets('renders nothing when password meets all requirements', + (tester) async { + final controller = TextEditingController(text: 'StrongPass1!'); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.byType(Container), findsNothing); + }); + + testWidgets('shows hint text when password is too weak', (tester) async { + final controller = TextEditingController(text: 'weak'); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.byType(Container), findsOneWidget); + }); + + testWidgets('hint disappears when password becomes strong', (tester) async { + final controller = TextEditingController(text: 'weak'); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.byType(Container), findsOneWidget); + + controller.text = 'StrongPass1!'; + await tester.pump(); + + expect(find.byType(Container), findsNothing); + }); + + testWidgets('uses ValueListenableBuilder to react to text changes', + (tester) async { + final controller = TextEditingController(text: ''); + await tester.pumpWidget(buildWidget(controller)); + + expect(find.byType(ValueListenableBuilder), + findsOneWidget); + }); + + testWidgets('hint text has centred alignment', (tester) async { + final controller = TextEditingController(text: 'weak'); + await tester.pumpWidget(buildWidget(controller)); + + final texts = tester + .widgetList(find.byType(Text)) + .where((t) => t.textAlign == TextAlign.center) + .toList(); + + expect(texts, isNotEmpty); + }); + }); +} \ No newline at end of file From 79e395b110b116061c8c601493b94c4493641f03 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 19:26:45 -0400 Subject: [PATCH 35/79] Moves root_app to lib/ --- lib/{pages => }/root_app.dart | 0 test/{pages => }/root_app_test.dart | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename lib/{pages => }/root_app.dart (100%) rename test/{pages => }/root_app_test.dart (94%) diff --git a/lib/pages/root_app.dart b/lib/root_app.dart similarity index 100% rename from lib/pages/root_app.dart rename to lib/root_app.dart diff --git a/test/pages/root_app_test.dart b/test/root_app_test.dart similarity index 94% rename from test/pages/root_app_test.dart rename to test/root_app_test.dart index 4489cb2..d1a72dd 100644 --- a/test/pages/root_app_test.dart +++ b/test/root_app_test.dart @@ -1,9 +1,9 @@ import 'package:clean_stream_laundry_app/middleware/app_router.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:clean_stream_laundry_app/pages/root_app.dart'; +import 'package:clean_stream_laundry_app/root_app.dart'; import 'package:go_router/go_router.dart'; -import 'mocks.dart'; +import 'pages/mocks.dart'; import 'package:clean_stream_laundry_app/main.dart'; import 'package:mocktail/mocktail.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; From b7850aceaad09080dcedc736a37ec19dc149134f Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 19:34:19 -0400 Subject: [PATCH 36/79] Separates scanner --- lib/features/scanner/controller.dart | 53 ++++++++++++++++ lib/features/scanner/scanner.dart | 62 +++++++++++++++++++ .../scanner/widgets/scanner_overlay.dart | 60 ++++++++++++++++++ .../controller_test.dart | 4 +- .../widgets/resend_verification_test.dart | 4 +- .../widgets/section_header_test.dart | 2 +- test/features/loading/loading_test.dart | 2 +- test/features/loading/widgets/logo_test.dart | 2 +- .../features/loyalty/widgets/header_test.dart | 2 +- test/logic/parsing/location_parser_test.dart | 2 +- test/pages/loading_page_test.dart | 2 +- test/pages/loyalty_card_page_test.dart | 2 +- test/pages/refund_page_test.dart | 2 +- test/pages/sign_up_screen_test.dart | 2 +- test/widgets/ base_page_test.dart | 2 +- test/widgets/custom_app_bar_test.dart | 2 +- test/widgets/settings_card_test.dart | 2 +- 17 files changed, 191 insertions(+), 16 deletions(-) create mode 100644 lib/features/scanner/controller.dart create mode 100644 lib/features/scanner/scanner.dart create mode 100644 lib/features/scanner/widgets/scanner_overlay.dart diff --git a/lib/features/scanner/controller.dart b/lib/features/scanner/controller.dart new file mode 100644 index 0000000..afe8b43 --- /dev/null +++ b/lib/features/scanner/controller.dart @@ -0,0 +1,53 @@ +import 'package:clean_stream_laundry_app/logic/parsing/qr_parser.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +class ScannerController extends ChangeNotifier { + final MachineCommunicationService machineCommunicator; + + ScannerController({MachineCommunicationService? machineCommunicator}) + : machineCommunicator = machineCommunicator ?? + GetIt.instance(); + + final MobileScannerController cameraController = MobileScannerController(); + + String? scannedCode; + + void disposeController() { + cameraController.dispose(); + } + + void handleQRCode(BarcodeCapture capture) { + for (final barcode in capture.barcodes) { + if (barcode.rawValue != null) { + scannedCode = barcode.rawValue; + notifyListeners(); + + final parser = QrScannerParser(scannedCode!); + processNayaxCode( + parser.getNayaxDeviceID(), + onNavigate: (_) {}, + onError: (_, __) {}, + ); + break; + } + } + } + + Future processNayaxCode( + String? code, { + required void Function(String route) onNavigate, + required void Function(String title, String message) onError, + }) async { + cameraController.stop(); + final result = await machineCommunicator.checkAvailability(code!); + if (result == 'pass') { + onNavigate('/paymentPage?machineId=$code'); + } else { + onError('Machine Unavailable', result); + cameraController.start(); + } + } +} \ No newline at end of file diff --git a/lib/features/scanner/scanner.dart b/lib/features/scanner/scanner.dart new file mode 100644 index 0000000..d8baef6 --- /dev/null +++ b/lib/features/scanner/scanner.dart @@ -0,0 +1,62 @@ +import 'controller.dart'; +import 'widgets/scanner_overlay.dart'; +import 'package:clean_stream_laundry_app/widgets/base_page.dart'; +import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +class ScannerPage extends StatefulWidget { + const ScannerPage({super.key}); + + @override + State createState() => _ScannerPageState(); +} + +class _ScannerPageState extends State { + late final ScannerController _controller; + + @override + void initState() { + super.initState(); + _controller = ScannerController(); + } + + @override + void dispose() { + _controller.disposeController(); + _controller.dispose(); + super.dispose(); + } + + Future processNayaxCode(String? code) => + _controller.processNayaxCode( + code, + onNavigate: (route) { + if (mounted) context.go(route); + }, + onError: (title, message) { + if (mounted) { + statusDialog(context, title: title, message: message, + isSuccess: false); + } + }, + ); + + @override + Widget build(BuildContext context) { + return BasePage( + body: Stack( + children: [ + MobileScanner( + controller: _controller.cameraController, + onDetect: (capture) => _controller.handleQRCode(capture), + ), + ScannerOverlay( + onCancel: () => context.go('/startPage'), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/scanner/widgets/scanner_overlay.dart b/lib/features/scanner/widgets/scanner_overlay.dart new file mode 100644 index 0000000..9de7d18 --- /dev/null +++ b/lib/features/scanner/widgets/scanner_overlay.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +class ScannerOverlay extends StatelessWidget { + final VoidCallback onCancel; + + const ScannerOverlay({super.key, required this.onCancel}); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + color: Colors.black54, + padding: const EdgeInsets.all(16), + child: const Text( + 'Point camera at nayax QR code', + style: TextStyle( + color: Colors.grey, + fontSize: 18, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ), + + // Cancel button + Positioned( + bottom: 32, + left: 0, + right: 0, + child: Center( + child: FloatingActionButton.extended( + onPressed: onCancel, + icon: const Icon(Icons.close), + label: const Text('Cancel'), + backgroundColor: Colors.red, + ), + ), + ), + + // Center scanning frame + Center( + child: Container( + width: 250, + height: 250, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 3), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/test/features/change_email_verification/controller_test.dart b/test/features/change_email_verification/controller_test.dart index 3430a42..2c094a7 100644 --- a/test/features/change_email_verification/controller_test.dart +++ b/test/features/change_email_verification/controller_test.dart @@ -27,13 +27,13 @@ void main() { GetIt.instance.reset(); }); - /// Minimal widget tree + /// Minimal widgets tree Widget buildWithContext( Widget Function(BuildContext context) builder) { return MaterialApp(home: Builder(builder: builder)); } - /// GoRouter widget tree - initial route creates and inits controller, + /// GoRouter widgets tree - initial route creates and inits controller, /// use for deep-link and nav tests. Widget buildWithRouter({ required FakeAppLinks appLinks, diff --git a/test/features/change_email_verification/widgets/resend_verification_test.dart b/test/features/change_email_verification/widgets/resend_verification_test.dart index d1b3b3f..0651d94 100644 --- a/test/features/change_email_verification/widgets/resend_verification_test.dart +++ b/test/features/change_email_verification/widgets/resend_verification_test.dart @@ -27,7 +27,7 @@ void main() { GetIt.instance.reset(); }); - /// builds minimal widget tree + /// builds minimal widgets tree Widget buildWidget({ required ChangeEmailVerificationController controller, VoidCallback? onStateChange, @@ -160,7 +160,7 @@ void main() { }); group('Failure state', () { - testWidgets('shows VerificationError widget on failure', (tester) async { + testWidgets('shows VerificationError widgets on failure', (tester) async { when(() => mockAuthService.resendVerification()) .thenAnswer((_) async => AuthenticationResponses.failure); diff --git a/test/features/edit_profile/widgets/section_header_test.dart b/test/features/edit_profile/widgets/section_header_test.dart index 0889e74..512d282 100644 --- a/test/features/edit_profile/widgets/section_header_test.dart +++ b/test/features/edit_profile/widgets/section_header_test.dart @@ -24,7 +24,7 @@ void main() { expect(find.text('Email Address'), findsOneWidget); }); - testWidgets('renders as a Text widget', (tester) async { + testWidgets('renders as a Text widgets', (tester) async { await tester.pumpWidget(buildWidget(title: 'Full Name')); expect(find.byType(Text), findsOneWidget); diff --git a/test/features/loading/loading_test.dart b/test/features/loading/loading_test.dart index c438d45..c76e29a 100644 --- a/test/features/loading/loading_test.dart +++ b/test/features/loading/loading_test.dart @@ -76,7 +76,7 @@ void main() { expect(find.byType(Logo), findsNothing); }); - testWidgets('dispose does not throw when widget is removed', (tester) async { + testWidgets('dispose does not throw when widgets is removed', (tester) async { final controller = _FakeController(); await tester.pumpWidget(_withRouter(LoadingPage(controller: controller))); diff --git a/test/features/loading/widgets/logo_test.dart b/test/features/loading/widgets/logo_test.dart index 47589b1..ad3651f 100644 --- a/test/features/loading/widgets/logo_test.dart +++ b/test/features/loading/widgets/logo_test.dart @@ -26,7 +26,7 @@ void main() { expect(find.byType(Transform), findsAtLeastNWidgets(1)); }); - testWidgets('renders an Image widget for the logo asset', (tester) async { + testWidgets('renders an Image widgets for the logo asset', (tester) async { await tester.pumpWidget(_buildLogo()); expect(find.byType(Image), findsOneWidget); diff --git a/test/features/loyalty/widgets/header_test.dart b/test/features/loyalty/widgets/header_test.dart index e15a12e..d683328 100644 --- a/test/features/loyalty/widgets/header_test.dart +++ b/test/features/loyalty/widgets/header_test.dart @@ -28,7 +28,7 @@ void main() { group('LoyaltyHeader', () { group('Rendering', () { - testWidgets('shows CreditCard widget', (tester) async { + testWidgets('shows CreditCard widgets', (tester) async { await tester.pumpWidget(buildWidget()); expect(find.byType(CreditCard), findsOneWidget); }); diff --git a/test/logic/parsing/location_parser_test.dart b/test/logic/parsing/location_parser_test.dart index 0d035de..27e0350 100644 --- a/test/logic/parsing/location_parser_test.dart +++ b/test/logic/parsing/location_parser_test.dart @@ -41,7 +41,7 @@ void main() { expect(result[0].height, 50); }); - test('parseLocations creates MapMarker widget as child', () { + test('parseLocations creates MapMarker widgets as child', () { final locations = [ {'Latitude': 40.7128, 'Longitude': -74.0060}, ]; diff --git a/test/pages/loading_page_test.dart b/test/pages/loading_page_test.dart index 3bee029..f628510 100644 --- a/test/pages/loading_page_test.dart +++ b/test/pages/loading_page_test.dart @@ -333,7 +333,7 @@ void main() { }); group('State Management Tests', () { - testWidgets('does not navigate if widget is unmounted', (WidgetTester tester) async { + testWidgets('does not navigate if widgets is unmounted', (WidgetTester tester) async { when(() => mockAuthService.isLoggedIn()) .thenAnswer((_) async { await Future.delayed(const Duration(milliseconds: 100)); diff --git a/test/pages/loyalty_card_page_test.dart b/test/pages/loyalty_card_page_test.dart index 7aac360..7dd0578 100644 --- a/test/pages/loyalty_card_page_test.dart +++ b/test/pages/loyalty_card_page_test.dart @@ -785,7 +785,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - // Navigate away to dispose the widget + // Navigate away to dispose the widgets await tester.pumpWidget(const MaterialApp(home: Scaffold())); verify(() => mockViewModel.removeListener(any())).called(1); diff --git a/test/pages/refund_page_test.dart b/test/pages/refund_page_test.dart index 2689bed..3ba0d61 100644 --- a/test/pages/refund_page_test.dart +++ b/test/pages/refund_page_test.dart @@ -696,7 +696,7 @@ void main() { }); - testWidgets('disclosure widget displays correctly', (tester) async { + testWidgets('disclosure widgets displays correctly', (tester) async { when( () => mockTransactionService.getRefundableTransactionsForUser(), ).thenAnswer((_) async => (transactions: [], ids: [])); diff --git a/test/pages/sign_up_screen_test.dart b/test/pages/sign_up_screen_test.dart index f120f8d..768e0a3 100644 --- a/test/pages/sign_up_screen_test.dart +++ b/test/pages/sign_up_screen_test.dart @@ -384,7 +384,7 @@ void main() { testWidgets('Handle sign up is run when enter is clicked', (tester) async { setupViewport(tester); - // Pump the widget + // Pump the widgets await tester.pumpWidget(createWidgetUnderTest()); // Find the KeyboardListener and grab its FocusNode diff --git a/test/widgets/ base_page_test.dart b/test/widgets/ base_page_test.dart index 8f5ff18..c0a3e6f 100644 --- a/test/widgets/ base_page_test.dart +++ b/test/widgets/ base_page_test.dart @@ -28,7 +28,7 @@ void main() { expect(basePage, isA()); }); - testWidgets('renders provided body widget', (tester) async { + testWidgets('renders provided body widgets', (tester) async { await tester.pumpWidget( wrapWithRouter(const BasePage(body: Text("Hello"))), ); diff --git a/test/widgets/custom_app_bar_test.dart b/test/widgets/custom_app_bar_test.dart index 7df3c94..e39028a 100644 --- a/test/widgets/custom_app_bar_test.dart +++ b/test/widgets/custom_app_bar_test.dart @@ -14,7 +14,7 @@ void main() { expect(customAppBar is PreferredSizeWidget, true); }); - testWidgets('CustomAppBar builds an AppBar widget', (tester) async { + testWidgets('CustomAppBar builds an AppBar widgets', (tester) async { await tester.pumpWidget( MaterialApp(home: Scaffold(appBar: const CustomAppBar())), ); diff --git a/test/widgets/settings_card_test.dart b/test/widgets/settings_card_test.dart index af8bf5f..cf4b555 100644 --- a/test/widgets/settings_card_test.dart +++ b/test/widgets/settings_card_test.dart @@ -155,7 +155,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('renders trailing widget when provided', (WidgetTester tester) async { + testWidgets('renders trailing widgets when provided', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( home: Scaffold( From 002a6a4e1f0510ce165e948d66c986ebfaf9a84c Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 19:40:04 -0400 Subject: [PATCH 37/79] Adds tests for scanner --- test/features/scanner/controller_test.dart | 117 +++++++++++++ test/features/scanner/mocks.dart | 5 + test/features/scanner/scanner_test.dart | 165 ++++++++++++++++++ .../scanner/widgets/scanner_overlay_test.dart | 76 ++++++++ 4 files changed, 363 insertions(+) create mode 100644 test/features/scanner/controller_test.dart create mode 100644 test/features/scanner/mocks.dart create mode 100644 test/features/scanner/scanner_test.dart create mode 100644 test/features/scanner/widgets/scanner_overlay_test.dart diff --git a/test/features/scanner/controller_test.dart b/test/features/scanner/controller_test.dart new file mode 100644 index 0000000..117964f --- /dev/null +++ b/test/features/scanner/controller_test.dart @@ -0,0 +1,117 @@ +import 'package:clean_stream_laundry_app/features/scanner/controller.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockMachineCommunicationService mockMachineCommunicator; + + setUp(() { + mockMachineCommunicator = MockMachineCommunicationService(); + }); + + ScannerController buildController() => + ScannerController(machineCommunicator: mockMachineCommunicator); + + group('processNayaxCode', () { + test('calls checkAvailability with the provided code', () async { + when(() => mockMachineCommunicator.checkAvailability(any())) + .thenAnswer((_) async => 'pass'); + + final controller = buildController(); + await controller.processNayaxCode( + 'machine123', + onNavigate: (_) {}, + onError: (_, __) {}, + ); + + verify(() => + mockMachineCommunicator.checkAvailability('machine123')) + .called(1); + + controller.disposeController(); + }); + + test('calls onNavigate with correct route when result is pass', () async { + when(() => mockMachineCommunicator.checkAvailability(any())) + .thenAnswer((_) async => 'pass'); + + String? navigatedRoute; + final controller = buildController(); + await controller.processNayaxCode( + 'machine123', + onNavigate: (route) => navigatedRoute = route, + onError: (_, __) {}, + ); + + expect(navigatedRoute, '/paymentPage?machineId=machine123'); + controller.disposeController(); + }); + + test('calls onError with correct title when result is not pass', () async { + when(() => mockMachineCommunicator.checkAvailability(any())) + .thenAnswer((_) async => 'fail'); + + String? errorTitle; + String? errorMessage; + final controller = buildController(); + await controller.processNayaxCode( + 'machine123', + onNavigate: (_) {}, + onError: (title, message) { + errorTitle = title; + errorMessage = message; + }, + ); + + expect(errorTitle, 'Machine Unavailable'); + expect(errorMessage, 'fail'); + controller.disposeController(); + }); + + test('does not call onNavigate when result is not pass', () async { + when(() => mockMachineCommunicator.checkAvailability(any())) + .thenAnswer((_) async => 'fail'); + + var navigateCalled = false; + final controller = buildController(); + await controller.processNayaxCode( + 'machine123', + onNavigate: (_) => navigateCalled = true, + onError: (_, __) {}, + ); + + expect(navigateCalled, isFalse); + controller.disposeController(); + }); + + test('does not call onError when result is pass', () async { + when(() => mockMachineCommunicator.checkAvailability(any())) + .thenAnswer((_) async => 'pass'); + + var errorCalled = false; + final controller = buildController(); + await controller.processNayaxCode( + 'machine123', + onNavigate: (_) {}, + onError: (_, __) => errorCalled = true, + ); + + expect(errorCalled, isFalse); + controller.disposeController(); + }); + }); + + group('Lifecycle', () { + test('disposeController disposes camera without error', () { + final controller = buildController(); + expect(() => controller.disposeController(), returnsNormally); + }); + + test('cameraController is not null after construction', () { + final controller = buildController(); + expect(controller.cameraController, isNotNull); + controller.disposeController(); + }); + }); +} \ No newline at end of file diff --git a/test/features/scanner/mocks.dart b/test/features/scanner/mocks.dart new file mode 100644 index 0000000..89898eb --- /dev/null +++ b/test/features/scanner/mocks.dart @@ -0,0 +1,5 @@ +import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockMachineCommunicationService extends Mock + implements MachineCommunicationService {} \ No newline at end of file diff --git a/test/features/scanner/scanner_test.dart b/test/features/scanner/scanner_test.dart new file mode 100644 index 0000000..df1798b --- /dev/null +++ b/test/features/scanner/scanner_test.dart @@ -0,0 +1,165 @@ +import 'package:clean_stream_laundry_app/features/scanner/scanner.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockMachineCommunicationService mockMachineCommunicator; + + setUp(() async { + mockMachineCommunicator = MockMachineCommunicationService(); + await GetIt.instance.reset(); + GetIt.instance.registerSingleton( + mockMachineCommunicator); + }); + + tearDown(() async { + await GetIt.instance.reset(); + }); + + Widget createWidget() { + return MaterialApp.router( + routerConfig: GoRouter( + initialLocation: '/scanner', + routes: [ + GoRoute( + path: '/scanner', + builder: (_, __) => const ScannerPage(), + ), + GoRoute( + path: '/startPage', + builder: (_, __) => + const Scaffold(body: Text('Start Page')), + ), + GoRoute( + path: '/paymentPage', + builder: (_, __) => const Scaffold( + body: Column( + children: [ + Text('Payment Page'), + Text('Pay with Loyalty'), + ], + ), + ), + ), + ], + ), + ); + } + + group('UI elements', () { + testWidgets('displays MobileScanner', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.byType(MobileScanner), findsOneWidget); + }); + + testWidgets('displays Cancel button', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect( + find.widgetWithText(FloatingActionButton, 'Cancel'), + findsOneWidget, + ); + expect(find.byIcon(Icons.close), findsOneWidget); + }); + + testWidgets('displays scanning frame overlay', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final frameFinder = find.byWidgetPredicate((widget) { + if (widget is Container && widget.decoration is BoxDecoration) { + final decoration = widget.decoration as BoxDecoration; + final border = decoration.border as Border?; + return border?.top.color == Colors.white && + border?.top.width == 3 && + decoration.borderRadius == BorderRadius.circular(12); + } + return false; + }); + + expect(frameFinder, findsOneWidget); + + final container = tester.widget(frameFinder); + expect(container.constraints?.maxWidth, 250); + expect(container.constraints?.maxHeight, 250); + }); + + testWidgets('MobileScanner has controller and onDetect callback', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final scanner = + tester.widget(find.byType(MobileScanner)); + expect(scanner.controller, isNotNull); + expect(scanner.onDetect, isNotNull); + }); + }); + + group('Navigation', () { + testWidgets('Cancel button navigates to /startPage', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap( + find.widgetWithText(FloatingActionButton, 'Cancel')); + await tester.pumpAndSettle(); + + expect(find.text('Start Page'), findsOneWidget); + expect(find.byType(ScannerPage), findsNothing); + }); + + testWidgets('navigates to paymentPage when availability passes', + (tester) async { + when(() => mockMachineCommunicator.checkAvailability(any())) + .thenAnswer((_) async => 'pass'); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final state = tester.state(find.byType(ScannerPage)); + await (state as dynamic).processNayaxCode('machine123'); + await tester.pumpAndSettle(); + + expect(find.byType(ScannerPage), findsNothing); + expect(find.text('Pay with Loyalty'), findsOneWidget); + }); + + testWidgets('stays on page and shows dialog when availability fails', + (tester) async { + when(() => mockMachineCommunicator.checkAvailability(any())) + .thenAnswer((_) async => 'fail'); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final state = tester.state(find.byType(ScannerPage)); + await (state as dynamic).processNayaxCode('machine123'); + await tester.pumpAndSettle(); + + expect(find.text('Machine Unavailable'), findsOneWidget); + }); + }); + + group('Lifecycle', () { + testWidgets('disposes without errors on navigation away', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap( + find.widgetWithText(FloatingActionButton, 'Cancel')); + await tester.pumpAndSettle(); + + expect(find.byType(ScannerPage), findsNothing); + }); + }); +} \ No newline at end of file diff --git a/test/features/scanner/widgets/scanner_overlay_test.dart b/test/features/scanner/widgets/scanner_overlay_test.dart new file mode 100644 index 0000000..f4b4b52 --- /dev/null +++ b/test/features/scanner/widgets/scanner_overlay_test.dart @@ -0,0 +1,76 @@ +import 'package:clean_stream_laundry_app/features/scanner/widgets/scanner_overlay.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget({VoidCallback? onCancel}) { + return MaterialApp( + home: Scaffold( + body: SizedBox.expand( + child: ScannerOverlay(onCancel: onCancel ?? () {}), + ), + ), + ); + } + + group('ScannerOverlay', () { + group('Rendering', () { + testWidgets('displays instruction text', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.text('Point camera at nayax QR code'), findsOneWidget); + }); + + testWidgets('displays Cancel button', (tester) async { + await tester.pumpWidget(buildWidget()); + expect( + find.widgetWithText(FloatingActionButton, 'Cancel'), + findsOneWidget, + ); + }); + + testWidgets('displays close icon in Cancel button', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byIcon(Icons.close), findsOneWidget); + }); + + testWidgets('Cancel button has red background', (tester) async { + await tester.pumpWidget(buildWidget()); + final fab = tester.widget( + find.byType(FloatingActionButton), + ); + expect(fab.backgroundColor, Colors.red); + }); + + testWidgets('displays scanning frame with correct size', (tester) async { + await tester.pumpWidget(buildWidget()); + + final frameFinder = find.byWidgetPredicate((widget) { + if (widget is Container && widget.decoration is BoxDecoration) { + final decoration = widget.decoration as BoxDecoration; + final border = decoration.border as Border?; + return border?.top.color == Colors.white && + border?.top.width == 3 && + decoration.borderRadius == BorderRadius.circular(12); + } + return false; + }); + + expect(frameFinder, findsOneWidget); + + final container = tester.widget(frameFinder); + expect(container.constraints?.maxWidth, 250); + expect(container.constraints?.maxHeight, 250); + }); + }); + + group('Interaction', () { + testWidgets('calls onCancel when Cancel button is tapped', (tester) async { + var tapped = false; + await tester.pumpWidget(buildWidget(onCancel: () => tapped = true)); + + await tester.tap(find.widgetWithText(FloatingActionButton, 'Cancel')); + expect(tapped, isTrue); + }); + }); + }); +} \ No newline at end of file From 90e2b7622d40cad04c08c857c0a4696524317e40 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 19:54:23 -0400 Subject: [PATCH 38/79] Separates settings --- lib/features/settings/controller.dart | 58 +++++++ lib/features/settings/settings.dart | 153 ++++++++++++++++++ .../settings/widgets/notification_lead.dart | 71 ++++++++ .../settings}/widgets/settings_card.dart | 0 lib/pages/settings.dart | 2 +- 5 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 lib/features/settings/controller.dart create mode 100644 lib/features/settings/settings.dart create mode 100644 lib/features/settings/widgets/notification_lead.dart rename lib/{ => features/settings}/widgets/settings_card.dart (100%) diff --git a/lib/features/settings/controller.dart b/lib/features/settings/controller.dart new file mode 100644 index 0000000..3aa402e --- /dev/null +++ b/lib/features/settings/controller.dart @@ -0,0 +1,58 @@ +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +class SettingsController extends ChangeNotifier { + static const int maxNotificationLeadTime = 30; + + final AuthService authService; + final ProfileService profileService; + final TransactionService transactionService; + + SettingsController({ + AuthService? authService, + ProfileService? profileService, + TransactionService? transactionService, + }) : authService = authService ?? GetIt.instance(), + profileService = profileService ?? GetIt.instance(), + transactionService = + transactionService ?? GetIt.instance(); + + int notificationLeadTime = 5; + bool isLoadingDelay = true; + + Future loadNotificationLeadTime() async { + final value = await profileService.getNotificationLeadTime(); + notificationLeadTime = value; + isLoadingDelay = false; + notifyListeners(); + } + + Future updateLeadTime(int newValue) async { + notificationLeadTime = newValue; + notifyListeners(); + await profileService.setNotificationLeadTime(newValue); + } + + Future increment() async { + if (notificationLeadTime < maxNotificationLeadTime) { + await updateLeadTime(notificationLeadTime + 1); + } + } + + Future decrement() async { + if (notificationLeadTime > 0) { + await updateLeadTime(notificationLeadTime - 1); + } + } + + Future>> getTransactions() async { + return transactionService.getTransactionsForUser(); + } + + Future signOut() async { + await authService.logout(); + } +} \ No newline at end of file diff --git a/lib/features/settings/settings.dart b/lib/features/settings/settings.dart new file mode 100644 index 0000000..04cd7d1 --- /dev/null +++ b/lib/features/settings/settings.dart @@ -0,0 +1,153 @@ +import 'controller.dart'; +import 'widgets/notification_lead.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme_manager.dart'; +import 'widgets/settings_card.dart'; +import 'package:clean_stream_laundry_app/widgets/base_page.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +class Settings extends StatefulWidget { + static const int maxNotificationLeadTime = + SettingsController.maxNotificationLeadTime; + + const Settings({super.key}); + + @override + State createState() => _SettingsState(); +} + +class _SettingsState extends State { + late final SettingsController _controller; + + @override + void initState() { + super.initState(); + _controller = SettingsController(); + _controller.addListener(() { + if (mounted) setState(() {}); + }); + _controller.loadNotificationLeadTime(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _showSignOutConfirmation() async { + final shouldSignOut = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text( + 'Sign Out', + style: TextStyle( + color: Theme.of(ctx).colorScheme.fontInverted), + ), + content: Text( + 'Are you sure you want to sign out?', + style: TextStyle( + color: Theme.of(ctx).colorScheme.fontInverted), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Sign Out'), + ), + ], + ), + ); + + if (shouldSignOut == true) { + await _controller.signOut(); + if (mounted) context.go('/login'); + } + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, themeManager, child) { + return BasePage( + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('assets/Logo.png', width: 230, height: 230), + SettingsCard( + icon: Icons.lightbulb, + title: Theme.of(context).colorScheme.modeChangerText, + onTap: themeManager.toggleTheme, + ), + const SizedBox(height: 14), + SettingsCard( + icon: Icons.money, + title: 'Monthly Report', + onTap: () async { + final transactions = + await _controller.getTransactions(); + if (mounted) { + context.push( + '/monthlyTransactionHistory', + extra: transactions, + ); + } + }, + ), + const SizedBox(height: 14), + SettingsCard( + icon: Icons.request_page, + title: 'Request Refund', + onTap: () => context.push('/refundPage'), + ), + const SizedBox(height: 14), + SettingsCard( + icon: Icons.person, + title: 'Edit Profile', + onTap: () => context.go('/editProfile'), + ), + const SizedBox(height: 14), + SettingsCard( + icon: Icons.timer, + title: 'Notify Before Finish', + subtitle: + "Minutes you're notified before machine finish", + trailing: _controller.isLoadingDelay + ? const SizedBox( + height: 110, + width: 110, + child: CircularProgressIndicator(strokeWidth: 4), + ) + : NotificationLead( + value: _controller.notificationLeadTime, + onIncrement: _controller.increment, + onDecrement: _controller.decrement, + ), + ), + const SizedBox(height: 14), + SettingsCard( + icon: Icons.logout, + title: 'Sign Out', + onTap: _showSignOutConfirmation, + ), + ], + ), + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/features/settings/widgets/notification_lead.dart b/lib/features/settings/widgets/notification_lead.dart new file mode 100644 index 0000000..51a015b --- /dev/null +++ b/lib/features/settings/widgets/notification_lead.dart @@ -0,0 +1,71 @@ +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; + +class NotificationLead extends StatelessWidget { + final int value; + final VoidCallback onIncrement; + final VoidCallback onDecrement; + + const NotificationLead({ + super.key, + required this.value, + required this.onIncrement, + required this.onDecrement, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ControlButton( + icon: Icons.add, + onPressed: onIncrement, + ), + SizedBox( + width: 40, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + ' $value', + style: TextStyle( + fontSize: 18, + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + ), + ), + const SizedBox(width: 12), + _ControlButton( + icon: Icons.remove, + onPressed: onDecrement, + ), + ], + ); + } +} + +class _ControlButton extends StatelessWidget { + final IconData icon; + final VoidCallback onPressed; + + const _ControlButton({required this.icon, required this.onPressed}); + + @override + Widget build(BuildContext context) { + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(6), + ), + child: IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + icon: Icon(icon, color: Colors.white, size: 20), + onPressed: onPressed, + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/settings_card.dart b/lib/features/settings/widgets/settings_card.dart similarity index 100% rename from lib/widgets/settings_card.dart rename to lib/features/settings/widgets/settings_card.dart diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index 1513baa..4a1e0ca 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -7,7 +7,7 @@ import 'package:clean_stream_laundry_app/logic/theme/theme_manager.dart'; import 'package:provider/provider.dart'; import 'package:get_it/get_it.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; -import 'package:clean_stream_laundry_app/widgets/settings_card.dart'; +import 'package:clean_stream_laundry_app/features/settings/widgets/settings_card.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; class Settings extends StatefulWidget { From 2dfe5d6b2ad9b79bc406c490acfb7a37711fb867 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 20:04:32 -0400 Subject: [PATCH 39/79] Adds tests for settings --- test/features/settings/controller_test.dart | 156 +++++++++ test/features/settings/mocks.dart | 10 + test/features/settings/settings_test.dart | 303 ++++++++++++++++++ .../widgets/notification_lead_test.dart | 86 +++++ .../settings}/widgets/settings_card_test.dart | 2 +- test/pages/settings_test.dart | 2 +- 6 files changed, 557 insertions(+), 2 deletions(-) create mode 100644 test/features/settings/controller_test.dart create mode 100644 test/features/settings/mocks.dart create mode 100644 test/features/settings/settings_test.dart create mode 100644 test/features/settings/widgets/notification_lead_test.dart rename test/{ => features/settings}/widgets/settings_card_test.dart (98%) diff --git a/test/features/settings/controller_test.dart b/test/features/settings/controller_test.dart new file mode 100644 index 0000000..67246f0 --- /dev/null +++ b/test/features/settings/controller_test.dart @@ -0,0 +1,156 @@ +import 'package:clean_stream_laundry_app/features/settings/controller.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late MockProfileService mockProfileService; + late MockTransactionService mockTransactionService; + + setUp(() { + mockAuthService = MockAuthService(); + mockProfileService = MockProfileService(); + mockTransactionService = MockTransactionService(); + }); + + SettingsController buildController() => SettingsController( + authService: mockAuthService, + profileService: mockProfileService, + transactionService: mockTransactionService, + ); + + + group('loadNotificationLeadTime', () { + test('sets notificationLeadTime from service', () async { + when(() => mockProfileService.getNotificationLeadTime()) + .thenAnswer((_) async => 12); + + final controller = buildController(); + await controller.loadNotificationLeadTime(); + + expect(controller.notificationLeadTime, 12); + }); + + test('sets isLoadingDelay to false after loading', () async { + when(() => mockProfileService.getNotificationLeadTime()) + .thenAnswer((_) async => 5); + + final controller = buildController(); + expect(controller.isLoadingDelay, isTrue); + + await controller.loadNotificationLeadTime(); + + expect(controller.isLoadingDelay, isFalse); + }); + + test('notifies listeners after loading', () async { + when(() => mockProfileService.getNotificationLeadTime()) + .thenAnswer((_) async => 5); + + final controller = buildController(); + var notified = false; + controller.addListener(() => notified = true); + + await controller.loadNotificationLeadTime(); + + expect(notified, isTrue); + }); + }); + + + group('increment', () { + test('increases notificationLeadTime by 1', () async { + when(() => mockProfileService.getNotificationLeadTime()) + .thenAnswer((_) async => 5); + when(() => mockProfileService.setNotificationLeadTime(any())) + .thenAnswer((_) async {}); + + final controller = buildController(); + await controller.loadNotificationLeadTime(); + + await controller.increment(); + + expect(controller.notificationLeadTime, 6); + verify(() => mockProfileService.setNotificationLeadTime(6)).called(1); + }); + + test('does not exceed maxNotificationLeadTime', () async { + when(() => mockProfileService.getNotificationLeadTime()) + .thenAnswer( + (_) async => SettingsController.maxNotificationLeadTime); + when(() => mockProfileService.setNotificationLeadTime(any())) + .thenAnswer((_) async {}); + + final controller = buildController(); + await controller.loadNotificationLeadTime(); + + await controller.increment(); + + expect(controller.notificationLeadTime, + SettingsController.maxNotificationLeadTime); + verifyNever( + () => mockProfileService.setNotificationLeadTime(any())); + }); + }); + + group('decrement', () { + test('decreases notificationLeadTime by 1', () async { + when(() => mockProfileService.getNotificationLeadTime()) + .thenAnswer((_) async => 5); + when(() => mockProfileService.setNotificationLeadTime(any())) + .thenAnswer((_) async {}); + + final controller = buildController(); + await controller.loadNotificationLeadTime(); + + await controller.decrement(); + + expect(controller.notificationLeadTime, 4); + verify(() => mockProfileService.setNotificationLeadTime(4)).called(1); + }); + + test('does not go below 0', () async { + when(() => mockProfileService.getNotificationLeadTime()) + .thenAnswer((_) async => 0); + when(() => mockProfileService.setNotificationLeadTime(any())) + .thenAnswer((_) async {}); + + final controller = buildController(); + await controller.loadNotificationLeadTime(); + + await controller.decrement(); + + expect(controller.notificationLeadTime, 0); + verifyNever( + () => mockProfileService.setNotificationLeadTime(any())); + }); + }); + + + group('getTransactions', () { + test('returns transactions from service', () async { + final expected = [ + {'id': '1', 'amount': 10}, + ]; + when(() => mockTransactionService.getTransactionsForUser()) + .thenAnswer((_) async => expected); + + final controller = buildController(); + final result = await controller.getTransactions(); + + expect(result, expected); + }); + }); + + group('signOut', () { + test('calls authService.logout', () async { + when(() => mockAuthService.logout()).thenAnswer((_) async {}); + + final controller = buildController(); + await controller.signOut(); + + verify(() => mockAuthService.logout()).called(1); + }); + }); +} \ No newline at end of file diff --git a/test/features/settings/mocks.dart b/test/features/settings/mocks.dart new file mode 100644 index 0000000..3d0e09a --- /dev/null +++ b/test/features/settings/mocks.dart @@ -0,0 +1,10 @@ +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme_manager.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthService extends Mock implements AuthService {} +class MockProfileService extends Mock implements ProfileService {} +class MockTransactionService extends Mock implements TransactionService {} +class MockThemeManager extends Mock implements ThemeManager {} \ No newline at end of file diff --git a/test/features/settings/settings_test.dart b/test/features/settings/settings_test.dart new file mode 100644 index 0000000..0e06cf8 --- /dev/null +++ b/test/features/settings/settings_test.dart @@ -0,0 +1,303 @@ +import 'package:clean_stream_laundry_app/features/settings/settings.dart'; +import 'package:clean_stream_laundry_app/features/settings/widgets/settings_card.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme_manager.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:provider/provider.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late MockTransactionService mockTransactionService; + late MockProfileService mockProfileService; + late MockThemeManager mockThemeManager; + late GoRouter router; + + setUp(() async { + mockAuthService = MockAuthService(); + mockTransactionService = MockTransactionService(); + mockThemeManager = MockThemeManager(); + mockProfileService = MockProfileService(); + + await GetIt.instance.reset(); + GetIt.instance.registerSingleton(mockProfileService); + GetIt.instance.registerSingleton(mockAuthService); + GetIt.instance.registerSingleton(mockTransactionService); + + when(() => mockProfileService.getNotificationLeadTime()) + .thenAnswer((_) async => 5); + when(() => mockProfileService.setNotificationLeadTime(any())) + .thenAnswer((_) async {}); + + router = GoRouter( + routes: [ + GoRoute(path: '/', builder: (_, __) => const Settings()), + GoRoute( + path: '/login', + builder: (_, __) => const Scaffold(body: Text('Login Page')), + ), + GoRoute( + path: '/editProfile', + builder: (_, __) => const Scaffold(body: Text('Edit Profile Page')), + ), + GoRoute( + path: '/monthlyTransactionHistory', + builder: (_, __) => const Scaffold(body: Text('Monthly Report Page')), + ), + GoRoute( + path: '/refundPage', + builder: (_, __) => const Scaffold(body: Text('Refund Page')), + ), + ], + ); + }); + + tearDown(() async { + await GetIt.instance.reset(); + }); + + Widget createWidget() { + return ChangeNotifierProvider.value( + value: mockThemeManager, + child: MaterialApp.router(routerConfig: router), + ); + } + + group('Settings page tests', () { + + testWidgets('displays Settings logo', (tester) async { + await tester.pumpWidget(createWidget()); + + expect( + find.byWidgetPredicate( + (w) => + w is Image && + w.image is AssetImage && + (w.image as AssetImage).assetName == 'assets/Logo.png', + ), + findsOneWidget, + ); + }); + + testWidgets('displays all six SettingsCard widgets', (tester) async { + await tester.pumpWidget(createWidget()); + + expect(find.byType(SettingsCard), findsNWidgets(6)); + expect(find.text('Sign Out'), findsOneWidget); + expect(find.text('Monthly Report'), findsOneWidget); + expect(find.text('Request Refund'), findsOneWidget); + expect(find.text('Edit Profile'), findsOneWidget); + expect(find.text('Notify Before Finish'), findsOneWidget); + }); + + testWidgets('displays correct icons for each card', (tester) async { + await tester.pumpWidget(createWidget()); + + expect(find.byIcon(Icons.lightbulb), findsOneWidget); + expect(find.byIcon(Icons.money), findsOneWidget); + expect(find.byIcon(Icons.request_page), findsOneWidget); + expect(find.byIcon(Icons.logout), findsOneWidget); + expect(find.byIcon(Icons.person), findsOneWidget); + expect(find.byIcon(Icons.timer), findsOneWidget); + }); + + testWidgets('centers content inside SingleChildScrollView', (tester) async { + await tester.pumpWidget(createWidget()); + + expect(find.byType(Center), findsWidgets); + expect(find.byType(Column), findsWidgets); + expect(find.byType(SingleChildScrollView), findsOneWidget); + }); + + + testWidgets('calls toggleTheme when theme card is tapped', (tester) async { + await tester.pumpWidget(createWidget()); + + await tester.tap( + find.ancestor( + of: find.byIcon(Icons.lightbulb), + matching: find.byType(SettingsCard), + ), + ); + await tester.pumpAndSettle(); + + verify(() => mockThemeManager.toggleTheme()).called(1); + }); + + + testWidgets('loads notification lead time from ProfileService', + (tester) async { + when(() => mockProfileService.getNotificationLeadTime()) + .thenAnswer((_) async => 7); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text(' 7'), findsOneWidget); + }); + + testWidgets('increments notification lead time when + is tapped', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.byIcon(Icons.add)); + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + verify(() => mockProfileService.setNotificationLeadTime(6)).called(1); + }); + + testWidgets('decrements notification lead time when - is tapped', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.byIcon(Icons.remove)); + await tester.tap(find.byIcon(Icons.remove)); + await tester.pumpAndSettle(); + + verify(() => mockProfileService.setNotificationLeadTime(4)).called(1); + }); + + testWidgets('does not decrement below 0', (tester) async { + when(() => mockProfileService.getNotificationLeadTime()) + .thenAnswer((_) async => 0); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.byIcon(Icons.remove)); + await tester.tap(find.byIcon(Icons.remove)); + await tester.pumpAndSettle(); + + verifyNever(() => mockProfileService.setNotificationLeadTime(any())); + }); + + testWidgets('notification lead time does not exceed max limit', + (tester) async { + when(() => mockProfileService.getNotificationLeadTime()) + .thenAnswer((_) async => Settings.maxNotificationLeadTime - 2); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final plusButton = find.byIcon(Icons.add); + await tester.ensureVisible(plusButton); + + await tester.tap(plusButton); + await tester.pumpAndSettle(); + + await tester.tap(plusButton); + await tester.pumpAndSettle(); + + await tester.tap(plusButton); + await tester.pumpAndSettle(); + + expect( + find.text(' ${Settings.maxNotificationLeadTime}'), + findsOneWidget, + ); + verify(() => mockProfileService + .setNotificationLeadTime(Settings.maxNotificationLeadTime)) + .called(1); + }); + + + testWidgets('shows confirmation dialog when Sign Out is tapped', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.widgetWithText(SettingsCard, 'Sign Out')); + await tester.tap(find.widgetWithText(SettingsCard, 'Sign Out')); + await tester.pumpAndSettle(); + + expect(find.text('Are you sure you want to sign out?'), findsOneWidget); + }); + + testWidgets('calls logout and navigates to /login on Sign Out confirm', + (tester) async { + when(() => mockAuthService.logout()).thenAnswer((_) async {}); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.widgetWithText(SettingsCard, 'Sign Out')); + await tester.tap(find.widgetWithText(SettingsCard, 'Sign Out')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Sign Out')); + await tester.pumpAndSettle(); + + verify(() => mockAuthService.logout()).called(1); + }); + + testWidgets('does not log out when Cancel is tapped', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.widgetWithText(SettingsCard, 'Sign Out')); + await tester.tap(find.widgetWithText(SettingsCard, 'Sign Out')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'Cancel')); + await tester.pumpAndSettle(); + + verifyNever(() => mockAuthService.logout()); + }); + + + testWidgets('navigates to /editProfile when Edit Profile is tapped', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.ensureVisible( + find.widgetWithText(SettingsCard, 'Edit Profile')); + await tester.tap(find.widgetWithText(SettingsCard, 'Edit Profile')); + await tester.pumpAndSettle(); + + expect( + router.routerDelegate.currentConfiguration.uri.path, + '/editProfile', + ); + }); + + testWidgets('fetches transactions and navigates to monthly report', + (tester) async { + when(() => mockTransactionService.getTransactionsForUser()) + .thenAnswer((_) async => [ + {'id': '1', 'amount': 100}, + {'id': '2', 'amount': 200}, + ]); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(SettingsCard, 'Monthly Report')); + await tester.pumpAndSettle(); + + verify(() => mockTransactionService.getTransactionsForUser()).called(1); + }); + + testWidgets('navigates to /refundPage when Request Refund is tapped', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.ensureVisible( + find.widgetWithText(SettingsCard, 'Request Refund')); + await tester.tap(find.widgetWithText(SettingsCard, 'Request Refund')); + await tester.pumpAndSettle(); + + expect(find.text('Refund Page'), findsOneWidget); + }); + }); +} \ No newline at end of file diff --git a/test/features/settings/widgets/notification_lead_test.dart b/test/features/settings/widgets/notification_lead_test.dart new file mode 100644 index 0000000..ad6561f --- /dev/null +++ b/test/features/settings/widgets/notification_lead_test.dart @@ -0,0 +1,86 @@ +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:clean_stream_laundry_app/features/settings/widgets/notification_lead.dart'; + +void main() { + Widget createWidgetUnderTest({ + required int value, + required VoidCallback onIncrement, + required VoidCallback onDecrement, + }) { + return MaterialApp( + theme: lightMode, + home: Scaffold( + body: NotificationLead( + value: value, + onIncrement: onIncrement, + onDecrement: onDecrement, + ), + ), + ); + } + + testWidgets('renders value correctly', (WidgetTester tester) async { + await tester.pumpWidget( + createWidgetUnderTest( + value: 5, + onIncrement: () {}, + onDecrement: () {}, + ), + ); + + expect(find.text(' 5'), findsOneWidget); + }); + + testWidgets('calls onIncrement when + button is tapped', (WidgetTester tester) async { + bool incrementCalled = false; + + await tester.pumpWidget( + createWidgetUnderTest( + value: 0, + onIncrement: () { + incrementCalled = true; + }, + onDecrement: () {}, + ), + ); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + expect(incrementCalled, true); + }); + + testWidgets('calls onDecrement when - button is tapped', (WidgetTester tester) async { + bool decrementCalled = false; + + await tester.pumpWidget( + createWidgetUnderTest( + value: 0, + onIncrement: () {}, + onDecrement: () { + decrementCalled = true; + }, + ), + ); + + await tester.tap(find.byIcon(Icons.remove)); + await tester.pump(); + + expect(decrementCalled, true); + }); + + testWidgets('renders both control buttons', (WidgetTester tester) async { + await tester.pumpWidget( + createWidgetUnderTest( + value: 1, + onIncrement: () {}, + onDecrement: () {}, + ), + ); + + expect(find.byIcon(Icons.add), findsOneWidget); + expect(find.byIcon(Icons.remove), findsOneWidget); + }); +} \ No newline at end of file diff --git a/test/widgets/settings_card_test.dart b/test/features/settings/widgets/settings_card_test.dart similarity index 98% rename from test/widgets/settings_card_test.dart rename to test/features/settings/widgets/settings_card_test.dart index cf4b555..6ba4bd6 100644 --- a/test/widgets/settings_card_test.dart +++ b/test/features/settings/widgets/settings_card_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:clean_stream_laundry_app/widgets/settings_card.dart'; +import 'package:clean_stream_laundry_app/features/settings/widgets/settings_card.dart'; void main() { group('SettingsCard Widget Tests', () { diff --git a/test/pages/settings_test.dart b/test/pages/settings_test.dart index 6f6d36e..742f21c 100644 --- a/test/pages/settings_test.dart +++ b/test/pages/settings_test.dart @@ -10,7 +10,7 @@ import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; import 'package:provider/provider.dart'; import 'mocks.dart'; -import 'package:clean_stream_laundry_app/widgets/settings_card.dart'; +import 'package:clean_stream_laundry_app/features/settings/widgets/settings_card.dart'; void main() { late MockAuthService mockAuthService; From fde8cab73fa090669b6f753adcae2fa582842068 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 20:14:50 -0400 Subject: [PATCH 40/79] Separates signup --- lib/features/sign_up/controller.dart | 132 ++++++++++++++++ lib/features/sign_up/sign_up.dart | 149 ++++++++++++++++++ lib/features/sign_up/widgets/form_fields.dart | 135 ++++++++++++++++ lib/features/sign_up/widgets/info_banner.dart | 27 ++++ .../sign_up/widgets/password_hint.dart | 48 ++++++ 5 files changed, 491 insertions(+) create mode 100644 lib/features/sign_up/controller.dart create mode 100644 lib/features/sign_up/sign_up.dart create mode 100644 lib/features/sign_up/widgets/form_fields.dart create mode 100644 lib/features/sign_up/widgets/info_banner.dart create mode 100644 lib/features/sign_up/widgets/password_hint.dart diff --git a/lib/features/sign_up/controller.dart b/lib/features/sign_up/controller.dart new file mode 100644 index 0000000..573cb44 --- /dev/null +++ b/lib/features/sign_up/controller.dart @@ -0,0 +1,132 @@ +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +class SignUpResult { + final bool success; + + final String? message; + + const SignUpResult._({required this.success, this.message}); + + const SignUpResult.success() : this._(success: true, message: null); + const SignUpResult.snackbar(String msg) + : this._(success: false, message: msg); + + const SignUpResult.colorOnly() : this._(success: false, message: null); +} + +class SignUpController extends ChangeNotifier { + final AuthService authService; + + SignUpController({AuthService? authService}) + : authService = authService ?? GetIt.instance(); + + final TextEditingController nameController = TextEditingController(); + final TextEditingController emailController = TextEditingController(); + final TextEditingController passwordController = TextEditingController(); + final TextEditingController confirmController = TextEditingController(); + + bool obscurePassword = true; + bool obscureConfirmPassword = true; + + String passwordLabel = 'Password'; + String confirmPasswordLabel = 'Confirm Password'; + Color iconColor = Colors.blue; + Color labelColor = Colors.blue; + bool isLoading = false; + + void disposeControllers() { + nameController.dispose(); + emailController.dispose(); + passwordController.dispose(); + confirmController.dispose(); + } + + void togglePasswordVisibility() { + obscurePassword = !obscurePassword; + notifyListeners(); + } + + void toggleConfirmVisibility() { + obscureConfirmPassword = !obscureConfirmPassword; + notifyListeners(); + } + + void changeColorsToRed(String reason) { + passwordLabel = reason; + confirmPasswordLabel = reason; + iconColor = Colors.red; + labelColor = Colors.red; + notifyListeners(); + } + + void changeColorsToDefault() { + passwordLabel = 'Password'; + confirmPasswordLabel = 'Confirm Password'; + iconColor = Colors.blue; + labelColor = Colors.blue; + notifyListeners(); + } + + void onPasswordChanged() { + if (iconColor == Colors.red) changeColorsToDefault(); + } + + void onConfirmChanged() { + final password = passwordController.text.trim(); + final confirm = confirmController.text.trim(); + if (password != confirm) { + if (iconColor != Colors.red) changeColorsToRed("Passwords don't match"); + } else { + changeColorsToDefault(); + } + } + + Future handleSignUp() async { + final name = nameController.text.trim(); + final email = emailController.text.trim(); + final password = passwordController.text; + final confirm = confirmController.text; + + if (name.isEmpty || email.isEmpty || password.isEmpty || confirm.isEmpty) { + return const SignUpResult.snackbar('Please fill in all fields.'); + } + if (password != confirm) { + return const SignUpResult.snackbar('Passwords do not match.'); + } + + isLoading = true; + notifyListeners(); + + try { + final response = await authService.signUp(email, password, name); + + switch (response) { + case AuthenticationResponses.success: + return const SignUpResult.success(); + case AuthenticationResponses.noDigit: + changeColorsToRed('Please include a digit'); + return const SignUpResult.colorOnly(); + case AuthenticationResponses.lessThanMinLength: + changeColorsToRed('Password length is too short'); + return const SignUpResult.colorOnly(); + case AuthenticationResponses.noSpecialCharacter: + changeColorsToRed('Please include a special character'); + return const SignUpResult.colorOnly(); + case AuthenticationResponses.noUppercase: + changeColorsToRed('Please include an uppercase letter'); + return const SignUpResult.colorOnly(); + case AuthenticationResponses.invalidSpecialCharacter: + changeColorsToRed('Please use a different special character'); + return const SignUpResult.colorOnly(); + default: + return const SignUpResult.snackbar('Sign-up failed. Try again.'); + } + } finally { + isLoading = false; + notifyListeners(); + } + } +} \ No newline at end of file diff --git a/lib/features/sign_up/sign_up.dart b/lib/features/sign_up/sign_up.dart new file mode 100644 index 0000000..7202ba7 --- /dev/null +++ b/lib/features/sign_up/sign_up.dart @@ -0,0 +1,149 @@ +import 'controller.dart'; +import 'widgets/form_fields.dart'; +import 'widgets/info_banner.dart'; +import 'package:clean_stream_laundry_app/features/sign_up/widgets/password_hint.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; + +class SignUpPage extends StatefulWidget { + const SignUpPage({super.key}); + + @override + SignUpPageState createState() => SignUpPageState(); +} + +class SignUpPageState extends State { + late final SignUpController _controller; + final ScrollController _scrollCtrl = ScrollController(); + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _controller = SignUpController(); + _controller.addListener(() { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _controller.disposeControllers(); + _controller.dispose(); + _scrollCtrl.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _showMessage(String text) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(text))); + } + + Future _onSubmit() async { + try { + final result = await _controller.handleSignUp(); + if (!mounted) return; + + if (result.success) { + _showMessage('Account created successfully.'); + context.go('/email-verification'); + return; + } + + if (result.message != null) { + _showMessage(result.message!); + } + } catch (e) { + if (!mounted) return; + _showMessage('Error: $e'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + body: KeyboardListener( + focusNode: _focusNode, + autofocus: kIsWeb, + onKeyEvent: (keyEvent) { + if (keyEvent is KeyDownEvent && + keyEvent.logicalKey == LogicalKeyboardKey.enter) { + _onSubmit(); + } + }, + child: ScrollbarTheme( + data: ScrollbarThemeData( + thumbColor: MaterialStateProperty.all(Colors.blue), + ), + child: Scrollbar( + controller: _scrollCtrl, + thumbVisibility: true, + interactive: true, + child: SingleChildScrollView( + controller: _scrollCtrl, + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('assets/Slogan.png', height: 150, width: 250), + const SignUpInfoBanner(), + const SizedBox(height: 10), + SignUpFormFields( + nameController: _controller.nameController, + emailController: _controller.emailController, + passwordController: _controller.passwordController, + confirmController: _controller.confirmController, + passwordLabel: _controller.passwordLabel, + confirmLabel: _controller.confirmPasswordLabel, + iconColor: _controller.iconColor, + labelColor: _controller.labelColor, + obscurePassword: _controller.obscurePassword, + obscureConfirmPassword: _controller.obscureConfirmPassword, + onTogglePassword: _controller.togglePasswordVisibility, + onToggleConfirm: _controller.toggleConfirmVisibility, + onPasswordChanged: (_) => _controller.onPasswordChanged(), + onConfirmChanged: (_) => _controller.onConfirmChanged(), + ), + SignUpPasswordHint( + controller: _controller.passwordController, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _controller.isLoading ? null : _onSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + child: _controller.isLoading + ? const CircularProgressIndicator() + : const Text('Create Account'), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 15.0), + child: InkWell( + onTap: () => context.go('/login'), + child: const Text( + 'Already have an account? Login', + style: TextStyle( + color: Colors.blue, + decoration: TextDecoration.underline, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/sign_up/widgets/form_fields.dart b/lib/features/sign_up/widgets/form_fields.dart new file mode 100644 index 0000000..102b0d2 --- /dev/null +++ b/lib/features/sign_up/widgets/form_fields.dart @@ -0,0 +1,135 @@ +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class SignUpFormFields extends StatelessWidget { + final TextEditingController nameController; + final TextEditingController emailController; + final TextEditingController passwordController; + final TextEditingController confirmController; + + final String passwordLabel; + final String confirmLabel; + final Color iconColor; + final Color labelColor; + + final bool obscurePassword; + final bool obscureConfirmPassword; + + final VoidCallback onTogglePassword; + final VoidCallback onToggleConfirm; + final ValueChanged onPasswordChanged; + final ValueChanged onConfirmChanged; + + const SignUpFormFields({ + super.key, + required this.nameController, + required this.emailController, + required this.passwordController, + required this.confirmController, + required this.passwordLabel, + required this.confirmLabel, + required this.iconColor, + required this.labelColor, + required this.obscurePassword, + required this.obscureConfirmPassword, + required this.onTogglePassword, + required this.onToggleConfirm, + required this.onPasswordChanged, + required this.onConfirmChanged, + }); + + InputDecoration _baseDecoration(BuildContext context) { + return InputDecoration( + contentPadding: + const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + focusedBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.blue, width: 2.0), + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.fontSecondary, + ), + borderRadius: BorderRadius.circular(12), + ), + ); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final base = _baseDecoration(context); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + inputFormatters: [LengthLimitingTextInputFormatter(36)], + maxLength: 36, + style: TextStyle(color: colorScheme.fontInverted), + decoration: base.copyWith( + labelText: 'Name', + labelStyle: const TextStyle(color: Colors.blue), + prefixIcon: const Icon(Icons.person, color: Colors.blue), + ), + ), + const SizedBox(height: 10), + + TextField( + controller: emailController, + style: TextStyle(color: colorScheme.fontInverted), + decoration: base.copyWith( + labelText: 'Email', + labelStyle: const TextStyle(color: Colors.blue), + prefixIcon: const Icon(Icons.email, color: Colors.blue), + ), + ), + const SizedBox(height: 12), + + TextField( + controller: passwordController, + obscureText: obscurePassword, + onChanged: onPasswordChanged, + style: TextStyle(color: colorScheme.fontInverted), + decoration: base.copyWith( + labelText: passwordLabel, + labelStyle: TextStyle(color: labelColor), + prefixIcon: Icon(Icons.lock, color: iconColor), + suffixIcon: IconButton( + icon: Icon( + obscurePassword ? Icons.visibility_off : Icons.visibility, + color: Colors.blue, + ), + onPressed: onTogglePassword, + ), + ), + ), + const SizedBox(height: 10), + + TextField( + controller: confirmController, + obscureText: obscureConfirmPassword, + onChanged: onConfirmChanged, + style: TextStyle(color: colorScheme.fontInverted), + decoration: base.copyWith( + labelText: confirmLabel, + labelStyle: TextStyle(color: labelColor), + prefixIcon: Icon(Icons.lock, color: iconColor), + suffixIcon: IconButton( + icon: Icon( + obscureConfirmPassword + ? Icons.visibility_off + : Icons.visibility, + color: Colors.blue, + ), + onPressed: onToggleConfirm, + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/features/sign_up/widgets/info_banner.dart b/lib/features/sign_up/widgets/info_banner.dart new file mode 100644 index 0000000..551477e --- /dev/null +++ b/lib/features/sign_up/widgets/info_banner.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class SignUpInfoBanner extends StatelessWidget { + const SignUpInfoBanner({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue), + ), + child: const Text( + 'Enter your info to create your account.\n' + ' After submitting, you will receive a confirmation email.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.blue, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/sign_up/widgets/password_hint.dart b/lib/features/sign_up/widgets/password_hint.dart new file mode 100644 index 0000000..aae0392 --- /dev/null +++ b/lib/features/sign_up/widgets/password_hint.dart @@ -0,0 +1,48 @@ +import 'package:clean_stream_laundry_app/logic/parsing/password_parser.dart'; +import 'package:flutter/material.dart'; + +class SignUpPasswordHint extends StatelessWidget { + final TextEditingController controller; + + const SignUpPasswordHint({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) { + final requirementText = PasswordParser.process(value.text); + + if (requirementText == null) return const SizedBox.shrink(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(), + child: Container( + padding: + const EdgeInsets.symmetric(vertical: 4, horizontal: 10), + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey), + ), + child: Text( + requirementText, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + ), + ), + const SizedBox(height: 10), + ], + ); + }, + ); + } +} \ No newline at end of file From e06a6781715d223fa504a48e7c7f5ecb663b87eb Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 20:23:42 -0400 Subject: [PATCH 41/79] Adds tests for sign_up --- test/features/login/login_test.dart | 4 +- test/features/login/widgets/links_test.dart | 4 +- test/features/sign_up/controller_test.dart | 309 +++++++++++++++ test/features/sign_up/mocks.dart | 6 + test/features/sign_up/sign_up_test.dart | 354 ++++++++++++++++++ .../sign_up/widgets/form_fields_test.dart | 223 +++++++++++ .../sign_up/widgets/info_banner_test.dart | 47 +++ .../sign_up/widgets/password_hint_test.dart | 55 +++ test/pages/login_page_test.dart | 4 +- .../authentication/authenticator_test.dart | 2 +- 10 files changed, 1001 insertions(+), 7 deletions(-) create mode 100644 test/features/sign_up/controller_test.dart create mode 100644 test/features/sign_up/mocks.dart create mode 100644 test/features/sign_up/sign_up_test.dart create mode 100644 test/features/sign_up/widgets/form_fields_test.dart create mode 100644 test/features/sign_up/widgets/info_banner_test.dart create mode 100644 test/features/sign_up/widgets/password_hint_test.dart diff --git a/test/features/login/login_test.dart b/test/features/login/login_test.dart index 1a5cc5b..49b8395 100644 --- a/test/features/login/login_test.dart +++ b/test/features/login/login_test.dart @@ -54,7 +54,7 @@ void main() { const Scaffold(body: Text('Email Verification')), ), GoRoute( - path: '/signup', + path: '/sign_up', builder: (_, __) => const Scaffold(body: Text('Sign Up Page')), ), GoRoute( @@ -350,7 +350,7 @@ void main() { group('Navigation', () { - testWidgets('navigates to signup on Create Account tap', (tester) async { + testWidgets('navigates to sign_up on Create Account tap', (tester) async { await tester.pumpWidget(createWidget()); await tester.pumpAndSettle(); diff --git a/test/features/login/widgets/links_test.dart b/test/features/login/widgets/links_test.dart index 4b0e4b7..583dd9f 100644 --- a/test/features/login/widgets/links_test.dart +++ b/test/features/login/widgets/links_test.dart @@ -10,7 +10,7 @@ void main() { routes: [ GoRoute(path: '/', builder: (_, __) => const Scaffold(body: LoginLinks())), GoRoute( - path: '/signup', + path: '/sign_up', builder: (_, __) => const Scaffold(body: Text('Sign Up Page')), ), GoRoute( @@ -52,7 +52,7 @@ void main() { expect(text.style?.decoration, TextDecoration.underline); }); - testWidgets('Create Account navigates to /signup', (tester) async { + testWidgets('Create Account navigates to /sign_up', (tester) async { await tester.pumpWidget(buildWidget()); await tester.tap(find.text('Create Account')); diff --git a/test/features/sign_up/controller_test.dart b/test/features/sign_up/controller_test.dart new file mode 100644 index 0000000..743773d --- /dev/null +++ b/test/features/sign_up/controller_test.dart @@ -0,0 +1,309 @@ +import 'dart:async'; +import 'package:clean_stream_laundry_app/features/sign_up/controller.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + + setUp(() { + mockAuthService = MockAuthService(); + }); + + SignUpController buildController() => + SignUpController(authService: mockAuthService); + + group('Visibility toggles', () { + test('togglePasswordVisibility flips obscurePassword', () { + final c = buildController(); + expect(c.obscurePassword, isTrue); + c.togglePasswordVisibility(); + expect(c.obscurePassword, isFalse); + c.togglePasswordVisibility(); + expect(c.obscurePassword, isTrue); + }); + + test('toggleConfirmVisibility flips obscureConfirmPassword', () { + final c = buildController(); + expect(c.obscureConfirmPassword, isTrue); + c.toggleConfirmVisibility(); + expect(c.obscureConfirmPassword, isFalse); + }); + + test('togglePasswordVisibility notifies listeners', () { + final c = buildController(); + var notified = false; + c.addListener(() => notified = true); + c.togglePasswordVisibility(); + expect(notified, isTrue); + }); + }); + + group('Color state', () { + test('changeColorsToRed sets labels and colors', () { + final c = buildController(); + c.changeColorsToRed('Some error'); + expect(c.passwordLabel, 'Some error'); + expect(c.confirmPasswordLabel, 'Some error'); + expect(c.iconColor, Colors.red); + expect(c.labelColor, Colors.red); + }); + + test('changeColorsToDefault restores labels and colors', () { + final c = buildController(); + c.changeColorsToRed('error'); + c.changeColorsToDefault(); + expect(c.passwordLabel, 'Password'); + expect(c.confirmPasswordLabel, 'Confirm Password'); + expect(c.iconColor, Colors.blue); + expect(c.labelColor, Colors.blue); + }); + + test('changeColorsToRed notifies listeners', () { + final c = buildController(); + var notified = false; + c.addListener(() => notified = true); + c.changeColorsToRed('error'); + expect(notified, isTrue); + }); + }); + + group('onPasswordChanged', () { + test('resets colors when iconColor is red', () { + final c = buildController(); + c.changeColorsToRed('error'); + c.onPasswordChanged(); + expect(c.iconColor, Colors.blue); + }); + + test('does nothing when iconColor is not red', () { + final c = buildController(); + c.onPasswordChanged(); + expect(c.passwordLabel, 'Password'); + }); + }); + + group('onConfirmChanged', () { + test('sets red colors when passwords do not match', () { + final c = buildController(); + c.passwordController.text = 'Password1!'; + c.confirmController.text = 'Different1!'; + c.onConfirmChanged(); + expect(c.iconColor, Colors.red); + }); + + test('resets colors when passwords match', () { + final c = buildController(); + c.passwordController.text = 'Password1!'; + c.confirmController.text = 'Password1!'; + c.changeColorsToRed('mismatch'); + c.onConfirmChanged(); + expect(c.iconColor, Colors.blue); + }); + + test('does not call changeColorsToRed again if already red', () { + final c = buildController(); + c.passwordController.text = 'Password1!'; + c.confirmController.text = 'Other1!'; + c.changeColorsToRed('mismatch'); + var notifyCount = 0; + c.addListener(() => notifyCount++); + c.onConfirmChanged(); + expect(notifyCount, 0); + }); + }); + + group('handleSignUp — validation', () { + test('returns snackbar error when name is empty', () async { + final c = buildController(); + c.emailController.text = 'a@b.com'; + c.passwordController.text = 'Password1!'; + c.confirmController.text = 'Password1!'; + + final result = await c.handleSignUp(); + expect(result.success, isFalse); + expect(result.message, 'Please fill in all fields.'); + verifyNever(() => mockAuthService.signUp(any(), any(), any())); + }); + + test('returns snackbar error when all fields are empty', () async { + final c = buildController(); + final result = await c.handleSignUp(); + expect(result.message, 'Please fill in all fields.'); + }); + + test('returns snackbar error when passwords do not match', () async { + final c = buildController(); + c.nameController.text = 'Name'; + c.emailController.text = 'a@b.com'; + c.passwordController.text = 'Password1!'; + c.confirmController.text = 'Different1!'; + + final result = await c.handleSignUp(); + expect(result.message, 'Passwords do not match.'); + verifyNever(() => mockAuthService.signUp(any(), any(), any())); + }); + }); + + group('handleSignUp — auth responses', () { + void fillValidForm(SignUpController c) { + c.nameController.text = 'Test User'; + c.emailController.text = 'test@example.com'; + c.passwordController.text = 'Password1!'; + c.confirmController.text = 'Password1!'; + } + + test('returns success on AuthenticationResponses.success', () async { + when(() => mockAuthService.signUp(any(), any(), any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + final c = buildController(); + fillValidForm(c); + + final result = await c.handleSignUp(); + expect(result.success, isTrue); + expect(result.message, isNull); + }); + + test('calls signUp with trimmed email and name', () async { + when(() => mockAuthService.signUp(any(), any(), any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + final c = buildController(); + c.nameController.text = ' Test User '; + c.emailController.text = ' test@example.com '; + c.passwordController.text = 'Password1!'; + c.confirmController.text = 'Password1!'; + + await c.handleSignUp(); + + verify(() => mockAuthService.signUp( + 'test@example.com', + 'Password1!', + 'Test User', + )).called(1); + }); + + test('returns colorOnly and sets red on noDigit', () async { + when(() => mockAuthService.signUp(any(), any(), any())) + .thenAnswer((_) async => AuthenticationResponses.noDigit); + + final c = buildController(); + fillValidForm(c); + final result = await c.handleSignUp(); + + expect(result.success, isFalse); + expect(result.message, isNull); + expect(c.iconColor, Colors.red); + expect(c.passwordLabel, 'Please include a digit'); + }); + + test('returns colorOnly and sets red on lessThanMinLength', () async { + when(() => mockAuthService.signUp(any(), any(), any())) + .thenAnswer( + (_) async => AuthenticationResponses.lessThanMinLength); + + final c = buildController(); + fillValidForm(c); + final result = await c.handleSignUp(); + + expect(result.message, isNull); + expect(c.passwordLabel, 'Password length is too short'); + }); + + test('returns colorOnly and sets red on noSpecialCharacter', () async { + when(() => mockAuthService.signUp(any(), any(), any())) + .thenAnswer( + (_) async => AuthenticationResponses.noSpecialCharacter); + + final c = buildController(); + fillValidForm(c); + final result = await c.handleSignUp(); + + expect(c.passwordLabel, 'Please include a special character'); + expect(result.message, isNull); + }); + + test('returns colorOnly and sets red on noUppercase', () async { + when(() => mockAuthService.signUp(any(), any(), any())) + .thenAnswer((_) async => AuthenticationResponses.noUppercase); + + final c = buildController(); + fillValidForm(c); + final result = await c.handleSignUp(); + + expect(c.passwordLabel, 'Please include an uppercase letter'); + expect(result.message, isNull); + }); + + test('returns colorOnly and sets red on invalidSpecialCharacter', + () async { + when(() => mockAuthService.signUp(any(), any(), any())) + .thenAnswer( + (_) async => AuthenticationResponses.invalidSpecialCharacter); + + final c = buildController(); + fillValidForm(c); + final result = await c.handleSignUp(); + + expect(c.passwordLabel, 'Please use a different special character'); + expect(result.message, isNull); + }); + + test('returns snackbar error on generic failure', () async { + when(() => mockAuthService.signUp(any(), any(), any())) + .thenAnswer((_) async => AuthenticationResponses.failure); + + final c = buildController(); + fillValidForm(c); + final result = await c.handleSignUp(); + + expect(result.message, 'Sign-up failed. Try again.'); + }); + + test('sets and clears isLoading around signUp call', () async { + final completer = Completer(); + when(() => mockAuthService.signUp(any(), any(), any())) + .thenAnswer((_) => completer.future); + + final c = buildController(); + fillValidForm(c); + + final future = c.handleSignUp(); + expect(c.isLoading, isTrue); + + completer.complete(AuthenticationResponses.success); + await future; + expect(c.isLoading, isFalse); + }); + + test('clears isLoading even when service throws', () async { + when(() => mockAuthService.signUp(any(), any(), any())) + .thenThrow(Exception('network')); + + final c = buildController(); + fillValidForm(c); + + await expectLater(c.handleSignUp(), throwsException); + expect(c.isLoading, isFalse); + }); + + test('notifies twice for isLoading transitions', () async { + when(() => mockAuthService.signUp(any(), any(), any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + final c = buildController(); + fillValidForm(c); + + var notifyCount = 0; + c.addListener(() => notifyCount++); + + await c.handleSignUp(); + + expect(notifyCount, greaterThanOrEqualTo(2)); + }); + }); +} \ No newline at end of file diff --git a/test/features/sign_up/mocks.dart b/test/features/sign_up/mocks.dart new file mode 100644 index 0000000..3231489 --- /dev/null +++ b/test/features/sign_up/mocks.dart @@ -0,0 +1,6 @@ +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthService extends Mock implements AuthService {} +class MockProfileService extends Mock implements ProfileService {} \ No newline at end of file diff --git a/test/features/sign_up/sign_up_test.dart b/test/features/sign_up/sign_up_test.dart new file mode 100644 index 0000000..6583ce7 --- /dev/null +++ b/test/features/sign_up/sign_up_test.dart @@ -0,0 +1,354 @@ +import 'package:clean_stream_laundry_app/features/sign_up/sign_up.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + late MockProfileService mockProfileService; + late GoRouter router; + + setUp(() async { + mockAuthService = MockAuthService(); + mockProfileService = MockProfileService(); + + await GetIt.instance.reset(); + GetIt.instance.registerSingleton(mockAuthService); + GetIt.instance.registerSingleton(mockProfileService); + + router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const SignUpPage(), + ), + GoRoute( + path: '/login', + builder: (_, __) => + const Scaffold(body: Text('Login Page')), + ), + GoRoute( + path: '/email-verification', + builder: (_, __) => + const Scaffold(body: Text('Email Verification Page')), + ), + ], + ); + }); + + tearDown(() async { + await GetIt.instance.reset(); + }); + + void setupViewport(WidgetTester tester) { + tester.view.physicalSize = const Size(800, 1200); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.reset()); + } + + Widget createWidget() { + return MaterialApp.router(routerConfig: router); + } + + group('Static UI', () { + testWidgets('displays logo', (tester) async { + setupViewport(tester); + await tester.pumpWidget(createWidget()); + + expect(find.byType(Image), findsOneWidget); + }); + + testWidgets('displays all four input fields', (tester) async { + setupViewport(tester); + await tester.pumpWidget(createWidget()); + + expect(find.byType(TextField), findsNWidgets(4)); + expect(find.text('Name'), findsOneWidget); + expect(find.text('Email'), findsOneWidget); + expect(find.text('Password'), findsOneWidget); + expect(find.text('Confirm Password'), findsOneWidget); + }); + + testWidgets('displays Create Account button', (tester) async { + setupViewport(tester); + await tester.pumpWidget(createWidget()); + + expect( + find.widgetWithText(ElevatedButton, 'Create Account'), + findsOneWidget, + ); + }); + + testWidgets('displays login link', (tester) async { + setupViewport(tester); + await tester.pumpWidget(createWidget()); + + expect( + find.text('Already have an account? Login'), + findsOneWidget, + ); + }); + + testWidgets('all text fields have prefix icons', (tester) async { + setupViewport(tester); + await tester.pumpWidget(createWidget()); + + for (int i = 0; i < 4; i++) { + final field = + tester.widget(find.byType(TextField).at(i)); + expect( + (field.decoration as InputDecoration).prefixIcon, + isA(), + ); + } + }); + + testWidgets('password fields are obscured by default', (tester) async { + setupViewport(tester); + await tester.pumpWidget(createWidget()); + + final passwordField = + tester.widget(find.byType(TextField).at(2)); + final confirmField = + tester.widget(find.byType(TextField).at(3)); + + expect(passwordField.obscureText, isTrue); + expect(confirmField.obscureText, isTrue); + }); + + testWidgets('Create Account button has correct style', (tester) async { + setupViewport(tester); + await tester.pumpWidget(createWidget()); + + final button = tester.widget( + find.widgetWithText(ElevatedButton, 'Create Account'), + ); + expect(button.style, isNotNull); + }); + }); + + group('Navigation', () { + testWidgets('tapping login link navigates to /login', (tester) async { + setupViewport(tester); + await tester.pumpWidget(createWidget()); + + await tester.tap(find.text('Already have an account? Login')); + await tester.pumpAndSettle(); + + expect(find.text('Login Page'), findsOneWidget); + }); + + testWidgets('navigates to email-verification on success', (tester) async { + when(() => mockAuthService.signUp(any(), any(), any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + setupViewport(tester); + await tester.pumpWidget(createWidget()); + + await tester.enterText(find.byType(TextField).at(0), 'Test User'); + await tester.enterText( + find.byType(TextField).at(1), 'test@example.com'); + await tester.enterText(find.byType(TextField).at(2), 'Password123!'); + await tester.enterText(find.byType(TextField).at(3), 'Password123!'); + + await tester.tap( + find.widgetWithText(ElevatedButton, 'Create Account')); + await tester.pump(); + await tester.pumpAndSettle(const Duration(seconds: 2)); + + expect(find.text('Email Verification Page'), findsOneWidget); + }); + }); + + group('Form validation', () { + testWidgets('shows error when fields are empty', (tester) async { + setupViewport(tester); + await tester.pumpWidget(createWidget()); + + await tester.tap( + find.widgetWithText(ElevatedButton, 'Create Account')); + await tester.pumpAndSettle(); + + expect(find.text('Please fill in all fields.'), findsOneWidget); + }); + + testWidgets('shows error when passwords do not match', (tester) async { + setupViewport(tester); + await tester.pumpWidget(createWidget()); + + await tester.enterText(find.byType(TextField).at(0), 'Test User'); + await tester.enterText( + find.byType(TextField).at(1), 'test@example.com'); + await tester.enterText(find.byType(TextField).at(2), 'Password123!'); + await tester.enterText(find.byType(TextField).at(3), 'Password456!'); + + await tester.tap( + find.widgetWithText(ElevatedButton, 'Create Account')); + await tester.pumpAndSettle(); + + expect(find.text('Passwords do not match.'), findsOneWidget); + }); + }); + + group('Auth response handling', () { + Future submitForm(WidgetTester tester) async { + await tester.enterText(find.byType(TextField).at(0), 'Test User'); + await tester.enterText( + find.byType(TextField).at(1), 'test@example.com'); + await tester.enterText(find.byType(TextField).at(2), 'Password123!'); + await tester.enterText(find.byType(TextField).at(3), 'Password123!'); + await tester.tap( + find.widgetWithText(ElevatedButton, 'Create Account')); + await tester.pumpAndSettle(); + } + + testWidgets('shows error for noDigit response', (tester) async { + setupViewport(tester); + when(() => mockAuthService.signUp(any(), any(), any())) + .thenAnswer((_) async => AuthenticationResponses.noDigit); + + await tester.pumpWidget(createWidget()); + await submitForm(tester); + + expect( + find.text('Please include a digit'), + findsAtLeastNWidgets(1), + ); + }); + + testWidgets('shows error for lessThanMinLength response', (tester) async { + setupViewport(tester); + when(() => mockAuthService.signUp(any(), any(), any())) + .thenAnswer( + (_) async => AuthenticationResponses.lessThanMinLength); + + await tester.pumpWidget(createWidget()); + await submitForm(tester); + + expect( + find.text('Password length is too short'), + findsAtLeastNWidgets(1), + ); + }); + + testWidgets('shows error for noSpecialCharacter response', (tester) async { + setupViewport(tester); + when(() => mockAuthService.signUp(any(), any(), any())) + .thenAnswer( + (_) async => AuthenticationResponses.noSpecialCharacter); + + await tester.pumpWidget(createWidget()); + await submitForm(tester); + + expect( + find.text('Please include a special character'), + findsAtLeastNWidgets(1), + ); + }); + + testWidgets('shows error for noUppercase response', (tester) async { + setupViewport(tester); + when(() => mockAuthService.signUp(any(), any(), any())) + .thenAnswer((_) async => AuthenticationResponses.noUppercase); + + await tester.pumpWidget(createWidget()); + await submitForm(tester); + + expect( + find.text('Please include an uppercase letter'), + findsAtLeastNWidgets(1), + ); + }); + + testWidgets('shows error for invalidSpecialCharacter response', + (tester) async { + setupViewport(tester); + when(() => mockAuthService.signUp(any(), any(), any())).thenAnswer( + (_) async => AuthenticationResponses.invalidSpecialCharacter); + + await tester.pumpWidget(createWidget()); + await submitForm(tester); + + expect( + find.text('Please use a different special character'), + findsAtLeastNWidgets(1), + ); + }); + }); + + group('Password requirements hint', () { + testWidgets('shows all requirements for completely weak password', + (tester) async { + setupViewport(tester); + await tester.pumpWidget(createWidget()); + + await tester.enterText(find.byType(TextField).at(2), 'abc'); + await tester.pump(); + + expect(find.textContaining('Have 8 character length'), findsOneWidget); + expect(find.textContaining('Include special character'), findsOneWidget); + expect(find.textContaining('Include a digit'), findsOneWidget); + expect( + find.textContaining('Include an uppercase letter'), + findsOneWidget, + ); + }); + + testWidgets('shows remaining requirements for partially valid password', + (tester) async { + setupViewport(tester); + await tester.pumpWidget(createWidget()); + + await tester.enterText(find.byType(TextField).at(2), 'Abcdefgh'); + await tester.pump(); + + expect(find.textContaining('Have 8 character length'), findsNothing); + expect(find.textContaining('Include special character'), findsOneWidget); + expect(find.textContaining('Include a digit'), findsOneWidget); + expect( + find.textContaining('Include an uppercase letter'), + findsNothing, + ); + }); + + testWidgets('shows no requirements for valid password', (tester) async { + setupViewport(tester); + await tester.pumpWidget(createWidget()); + + await tester.enterText(find.byType(TextField).at(2), 'Abc1234!'); + await tester.pump(); + + expect(find.textContaining('Have 8 character length'), findsNothing); + expect(find.textContaining('Include special character'), findsNothing); + expect(find.textContaining('Include a digit'), findsNothing); + expect( + find.textContaining('Include an uppercase letter'), + findsNothing, + ); + }); + }); + + group('Keyboard enter', () { + testWidgets('pressing Enter triggers submission', (tester) async { + setupViewport(tester); + await tester.pumpWidget(createWidget()); + + final keyboardListener = + tester.widget(find.byType(KeyboardListener)); + keyboardListener.focusNode.requestFocus(); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.text('Please fill in all fields.'), findsOneWidget); + }); + }); +} \ No newline at end of file diff --git a/test/features/sign_up/widgets/form_fields_test.dart b/test/features/sign_up/widgets/form_fields_test.dart new file mode 100644 index 0000000..0f36ab1 --- /dev/null +++ b/test/features/sign_up/widgets/form_fields_test.dart @@ -0,0 +1,223 @@ +import 'package:clean_stream_laundry_app/features/sign_up/widgets/form_fields.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late TextEditingController nameCtrl; + late TextEditingController emailCtrl; + late TextEditingController passwordCtrl; + late TextEditingController confirmCtrl; + + setUp(() { + nameCtrl = TextEditingController(); + emailCtrl = TextEditingController(); + passwordCtrl = TextEditingController(); + confirmCtrl = TextEditingController(); + }); + + tearDown(() { + nameCtrl.dispose(); + emailCtrl.dispose(); + passwordCtrl.dispose(); + confirmCtrl.dispose(); + }); + + Widget buildWidget({ + Color iconColor = Colors.blue, + Color labelColor = Colors.blue, + bool obscurePassword = true, + bool obscureConfirm = true, + String passwordLabel = 'Password', + String confirmLabel = 'Confirm Password', + VoidCallback? onTogglePassword, + VoidCallback? onToggleConfirm, + ValueChanged? onPasswordChanged, + ValueChanged? onConfirmChanged, + }) { + return MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: SignUpFormFields( + nameController: nameCtrl, + emailController: emailCtrl, + passwordController: passwordCtrl, + confirmController: confirmCtrl, + passwordLabel: passwordLabel, + confirmLabel: confirmLabel, + iconColor: iconColor, + labelColor: labelColor, + obscurePassword: obscurePassword, + obscureConfirmPassword: obscureConfirm, + onTogglePassword: onTogglePassword ?? () {}, + onToggleConfirm: onToggleConfirm ?? () {}, + onPasswordChanged: onPasswordChanged ?? (_) {}, + onConfirmChanged: onConfirmChanged ?? (_) {}, + ), + ), + ), + ); + } + + group('SignUpFormFields', () { + group('Rendering', () { + testWidgets('displays four TextFields', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byType(TextField), findsNWidgets(4)); + }); + + testWidgets('displays Name label', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.text('Name'), findsOneWidget); + }); + + testWidgets('displays Email label', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.text('Email'), findsOneWidget); + }); + + testWidgets('displays Password label', (tester) async { + await tester.pumpWidget(buildWidget(passwordLabel: 'Password')); + expect(find.text('Password'), findsOneWidget); + }); + + testWidgets('displays Confirm Password label', (tester) async { + await tester.pumpWidget( + buildWidget(confirmLabel: 'Confirm Password')); + expect(find.text('Confirm Password'), findsOneWidget); + }); + + testWidgets('shows person icon for name field', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byIcon(Icons.person), findsOneWidget); + }); + + testWidgets('shows email icon for email field', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byIcon(Icons.email), findsOneWidget); + }); + + testWidgets('shows two lock icons for password fields', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byIcon(Icons.lock), findsNWidgets(2)); + }); + + testWidgets('shows two visibility_off icons when both obscured', + (tester) async { + await tester.pumpWidget( + buildWidget(obscurePassword: true, obscureConfirm: true)); + expect(find.byIcon(Icons.visibility_off), findsNWidgets(2)); + }); + }); + + group('Obscure state', () { + testWidgets('password field is obscured when obscurePassword is true', + (tester) async { + await tester.pumpWidget(buildWidget(obscurePassword: true)); + final field = + tester.widget(find.byType(TextField).at(2)); + expect(field.obscureText, isTrue); + }); + + testWidgets('password field is visible when obscurePassword is false', + (tester) async { + await tester.pumpWidget(buildWidget(obscurePassword: false)); + final field = + tester.widget(find.byType(TextField).at(2)); + expect(field.obscureText, isFalse); + expect(find.byIcon(Icons.visibility), findsAtLeastNWidgets(1)); + }); + + testWidgets('confirm field is obscured when obscureConfirm is true', + (tester) async { + await tester.pumpWidget(buildWidget(obscureConfirm: true)); + final field = + tester.widget(find.byType(TextField).at(3)); + expect(field.obscureText, isTrue); + }); + }); + + group('Colors', () { + testWidgets('lock icons use provided iconColor', (tester) async { + await tester.pumpWidget(buildWidget(iconColor: Colors.red)); + final lockIcons = tester + .widgetList(find.byIcon(Icons.lock)) + .toList(); + for (final icon in lockIcons) { + expect(icon.color, Colors.red); + } + }); + }); + + group('Interaction', () { + testWidgets('calls onTogglePassword when first suffix icon tapped', + (tester) async { + var tapped = false; + await tester.pumpWidget( + buildWidget(onTogglePassword: () => tapped = true)); + + await tester.tap(find.byIcon(Icons.visibility_off).first); + expect(tapped, isTrue); + }); + + testWidgets('calls onToggleConfirm when second suffix icon tapped', + (tester) async { + var tapped = false; + await tester.pumpWidget( + buildWidget(onToggleConfirm: () => tapped = true)); + + await tester.tap(find.byIcon(Icons.visibility_off).last); + expect(tapped, isTrue); + }); + + testWidgets('calls onPasswordChanged when password text changes', + (tester) async { + String? changed; + await tester.pumpWidget( + buildWidget(onPasswordChanged: (v) => changed = v)); + + await tester.enterText(find.byType(TextField).at(2), 'hello'); + expect(changed, 'hello'); + }); + + testWidgets('calls onConfirmChanged when confirm text changes', + (tester) async { + String? changed; + await tester.pumpWidget( + buildWidget(onConfirmChanged: (v) => changed = v)); + + await tester.enterText(find.byType(TextField).at(3), 'world'); + expect(changed, 'world'); + }); + + testWidgets('name field accepts text via controller', (tester) async { + await tester.pumpWidget(buildWidget()); + await tester.enterText(find.byType(TextField).at(0), 'John Doe'); + expect(nameCtrl.text, 'John Doe'); + }); + + testWidgets('name field enforces 36 character limit', (tester) async { + await tester.pumpWidget(buildWidget()); + final field = + tester.widget(find.byType(TextField).at(0)); + expect(field.maxLength, 36); + }); + }); + + group('Error label', () { + testWidgets('shows custom error label on password field when set', + (tester) async { + await tester.pumpWidget( + buildWidget( + passwordLabel: "Passwords don't match", + confirmLabel: "Passwords don't match", + ), + ); + + expect( + find.text("Passwords don't match"), + findsNWidgets(2), + ); + }); + }); + }); +} \ No newline at end of file diff --git a/test/features/sign_up/widgets/info_banner_test.dart b/test/features/sign_up/widgets/info_banner_test.dart new file mode 100644 index 0000000..203012d --- /dev/null +++ b/test/features/sign_up/widgets/info_banner_test.dart @@ -0,0 +1,47 @@ +import 'package:clean_stream_laundry_app/features/sign_up/widgets/info_banner.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget() => + const MaterialApp(home: Scaffold(body: SignUpInfoBanner())); + + group('SignUpInfoBanner', () { + testWidgets('displays instruction text', (tester) async { + await tester.pumpWidget(buildWidget()); + expect( + find.textContaining('Enter your info to create your account'), + findsOneWidget, + ); + }); + + testWidgets('displays confirmation email text', (tester) async { + await tester.pumpWidget(buildWidget()); + expect( + find.textContaining('confirmation email'), + findsOneWidget, + ); + }); + + testWidgets('text is centered', (tester) async { + await tester.pumpWidget(buildWidget()); + final text = tester.widget( + find.textContaining('Enter your info'), + ); + expect(text.textAlign, TextAlign.center); + }); + + testWidgets('has blue border decoration', (tester) async { + await tester.pumpWidget(buildWidget()); + final container = tester.widget( + find.ancestor( + of: find.textContaining('Enter your info'), + matching: find.byType(Container), + ), + ); + final decoration = container.decoration as BoxDecoration; + final border = decoration.border as Border?; + expect(border?.top.color, Colors.blue); + }); + }); +} \ No newline at end of file diff --git a/test/features/sign_up/widgets/password_hint_test.dart b/test/features/sign_up/widgets/password_hint_test.dart new file mode 100644 index 0000000..d857371 --- /dev/null +++ b/test/features/sign_up/widgets/password_hint_test.dart @@ -0,0 +1,55 @@ +import 'package:clean_stream_laundry_app/features/sign_up/widgets/password_hint.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget createWidget(TextEditingController controller) { + return MaterialApp( + home: Scaffold( + body: SignUpPasswordHint(controller: controller), + ), + ); + } + + group('SignUpPasswordHint tests', () { + + testWidgets('shows all requirements when password is empty', (tester) async { + final controller = TextEditingController(text: ''); + + await tester.pumpWidget(createWidget(controller)); + await tester.pumpAndSettle(); + + expect(find.textContaining('Password must contain'), findsOneWidget); + expect(find.textContaining('8 character length'), findsOneWidget); + expect(find.textContaining('special character'), findsOneWidget); + expect(find.textContaining('digit'), findsOneWidget); + expect(find.textContaining('uppercase letter'), findsOneWidget); + }); + + testWidgets('shows only missing requirements', (tester) async { + final controller = TextEditingController(text: 'abcdefg'); + + await tester.pumpWidget(createWidget(controller)); + await tester.pumpAndSettle(); + + expect(find.textContaining('8 character length'), findsOneWidget); + expect(find.textContaining('special character'), findsOneWidget); + expect(find.textContaining('digit'), findsOneWidget); + expect(find.textContaining('uppercase letter'), findsOneWidget); + }); + + testWidgets('partially valid password shows only remaining rules', + (tester) async { + final controller = TextEditingController(text: 'Validpass'); + + await tester.pumpWidget(createWidget(controller)); + await tester.pumpAndSettle(); + + expect(find.textContaining('digit'), findsOneWidget); + expect(find.textContaining('special character'), findsOneWidget); + + expect(find.textContaining('uppercase letter'), findsNothing); + expect(find.textContaining('8 character length'), findsNothing); + }); + }); +} \ No newline at end of file diff --git a/test/pages/login_page_test.dart b/test/pages/login_page_test.dart index d9c6469..fe42d43 100644 --- a/test/pages/login_page_test.dart +++ b/test/pages/login_page_test.dart @@ -58,7 +58,7 @@ void main() { const Scaffold(body: Text('Email Verification')), ), GoRoute( - path: '/signup', + path: '/sign_up', builder: (context, state) => const Scaffold(body: Text('Sign Up Page')), ), @@ -290,7 +290,7 @@ void main() { group('Navigation', () { testWidgets( - 'should navigate to signup page when create account is tapped', + 'should navigate to sign_up page when create account is tapped', (tester) async { await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); diff --git a/test/services/supabase/authentication/authenticator_test.dart b/test/services/supabase/authentication/authenticator_test.dart index 1f5a00e..772642d 100644 --- a/test/services/supabase/authentication/authenticator_test.dart +++ b/test/services/supabase/authentication/authenticator_test.dart @@ -894,7 +894,7 @@ void main(){ createdAt: '2024-01-01T00:00:00Z', ), ), - redirectType: "signup", + redirectType: "sign_up", ), ); From e9b11fc0776b173bfc306ba83ba1d7f6d1b49ae5 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 20:33:56 -0400 Subject: [PATCH 42/79] Separates start_machine --- lib/features/start_machine/controller.dart | 50 +++++++ lib/features/start_machine/start_machine.dart | 135 ++++++++++++++++++ .../start_machine}/widgets/qr_button.dart | 0 .../widgets/searching_dialog.dart} | 0 .../start_machine/widgets/tap_card.dart | 48 +++++++ lib/pages/start_machine_page.dart | 5 +- test/pages/start_machine_page_test.dart | 2 +- test/widgets/qr_button.dart | 2 +- test/widgets/show_searching_test.dart | 2 +- 9 files changed, 238 insertions(+), 6 deletions(-) create mode 100644 lib/features/start_machine/controller.dart create mode 100644 lib/features/start_machine/start_machine.dart rename lib/{ => features/start_machine}/widgets/qr_button.dart (100%) rename lib/{widgets/show_searching.dart => features/start_machine/widgets/searching_dialog.dart} (100%) create mode 100644 lib/features/start_machine/widgets/tap_card.dart diff --git a/lib/features/start_machine/controller.dart b/lib/features/start_machine/controller.dart new file mode 100644 index 0000000..fe2c43d --- /dev/null +++ b/lib/features/start_machine/controller.dart @@ -0,0 +1,50 @@ +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +const double minimumBalance = 20; + +class StartPageController extends ChangeNotifier { + final ProfileService profileService; + final AuthService authService; + final DoorUnlocker doorUnlocker; + + StartPageController({ + ProfileService? profileService, + AuthService? authService, + DoorUnlocker? doorUnlocker, + }) : profileService = profileService ?? GetIt.instance(), + authService = authService ?? GetIt.instance(), + doorUnlocker = doorUnlocker ?? DoorUnlocker(); + + Map? balance; + bool cancelSearch = false; + + double? get balanceValue => balance?['balance'] as double?; + + bool get hasSufficientBalance { + final bal = balanceValue; + return bal != null && bal >= minimumBalance; + } + + Future loadUserData() async { + final userId = authService.getCurrentUserId; + if (userId == null) return; + + final fetched = await profileService.getUserBalanceById(userId); + balance = fetched; + notifyListeners(); + } + + Future unlockDoor() async { + cancelSearch = false; + return doorUnlocker.unlockNearestDoor(); + } + + void cancelUnlock() { + cancelSearch = true; + doorUnlocker.cancelUnlockingDoor(); + } +} \ No newline at end of file diff --git a/lib/features/start_machine/start_machine.dart b/lib/features/start_machine/start_machine.dart new file mode 100644 index 0000000..9b04764 --- /dev/null +++ b/lib/features/start_machine/start_machine.dart @@ -0,0 +1,135 @@ +import 'controller.dart'; +import 'widgets/qr_button.dart'; +import 'widgets/searching_dialog.dart'; +import 'widgets/tap_card.dart'; +import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; +import 'package:clean_stream_laundry_app/widgets/base_page.dart'; +import 'package:clean_stream_laundry_app/widgets/section_banner.dart'; +import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class StartPage extends StatefulWidget { + final DoorUnlocker? doorUnlocker; + + const StartPage({super.key, this.doorUnlocker}); + + @override + State createState() => _StartPageState(); +} + +class _StartPageState extends State { + late final StartPageController _controller; + + @override + void initState() { + super.initState(); + _controller = StartPageController(doorUnlocker: widget.doorUnlocker); + _controller.addListener(() { + if (mounted) setState(() {}); + }); + _controller.loadUserData(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _onUnlockPressed() async { + if (!_controller.hasSufficientBalance) { + _showLowBalanceDialog(); + return; + } + await _processUnlocking(); + } + + Future _processUnlocking() async { + showSearchingDialog(context, _controller.cancelUnlock); + + final success = await _controller.unlockDoor(); + + if (!mounted) return; + if (_controller.cancelSearch) return; + + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + + statusDialog( + context, + title: success ? 'Door Unlocked!' : 'No Nearby Doors Found', + message: success + ? 'The nearest door has been unlocked successfully' + : "We couldn't detect any nearby doors", + isSuccess: success, + ); + } + + void _showLowBalanceDialog() { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: const Text('Low Balance'), + content: Text( + 'You need at least ${minimumBalance.toStringAsFixed(2)} to unlock a door', + ), + icon: const Icon(Icons.error), + actions: [ + TextButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + context.go('/startPage'); + }, + child: const Text('OK'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return BasePage( + body: Padding( + padding: const EdgeInsets.all(20.0), + child: SingleChildScrollView( + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SectionHeader(title: 'Payment Options'), + const TapToPayCard(), + const SizedBox(height: 10), + SizedBox( + height: 160, + child: QRButton( + headLineText: 'Scan QR code', + descriptionText: 'Scan QR code on the machine', + icon: Icons.qr_code_scanner, + onPressed: () => context.go('/scanner'), + ), + ), + const SizedBox(height: 10), + const SectionHeader(title: 'After Hours'), + SizedBox( + height: 160, + child: QRButton( + headLineText: 'Unlock Door', + descriptionText: 'Unlock doors after hours', + icon: Icons.lock_open_rounded, + onPressed: _onUnlockPressed, + ), + ), + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/qr_button.dart b/lib/features/start_machine/widgets/qr_button.dart similarity index 100% rename from lib/widgets/qr_button.dart rename to lib/features/start_machine/widgets/qr_button.dart diff --git a/lib/widgets/show_searching.dart b/lib/features/start_machine/widgets/searching_dialog.dart similarity index 100% rename from lib/widgets/show_searching.dart rename to lib/features/start_machine/widgets/searching_dialog.dart diff --git a/lib/features/start_machine/widgets/tap_card.dart b/lib/features/start_machine/widgets/tap_card.dart new file mode 100644 index 0000000..aad9953 --- /dev/null +++ b/lib/features/start_machine/widgets/tap_card.dart @@ -0,0 +1,48 @@ +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; + +class TapToPayCard extends StatelessWidget { + const TapToPayCard({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + height: 160, + margin: const EdgeInsets.symmetric(horizontal: 23, vertical: 10), + padding: const EdgeInsets.all(30), + decoration: BoxDecoration( + border: Border.all(color: Colors.blue, width: 3), + borderRadius: BorderRadius.circular(14), + color: Colors.transparent, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Tap To Pay', + style: TextStyle( + color: Theme.of(context).colorScheme.fontInverted, + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Tap phone to machine to pay', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontSize: 16, + ), + ), + ], + ), + const Icon(Icons.tap_and_play, color: Colors.blue, size: 40), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/pages/start_machine_page.dart b/lib/pages/start_machine_page.dart index c70b5c2..1321804 100644 --- a/lib/pages/start_machine_page.dart +++ b/lib/pages/start_machine_page.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/widgets/qr_button.dart'; +import 'package:clean_stream_laundry_app/features/start_machine/widgets/qr_button.dart'; import 'package:flutter/material.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; import 'package:get_it/get_it.dart'; @@ -6,10 +6,9 @@ import 'package:go_router/go_router.dart'; import 'package:clean_stream_laundry_app/widgets/base_page.dart'; import 'package:clean_stream_laundry_app/widgets/section_banner.dart'; import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; -import 'package:clean_stream_laundry_app/widgets/show_searching.dart'; +import 'package:clean_stream_laundry_app/features/start_machine/widgets/searching_dialog.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/widgets/show_searching.dart'; import '../widgets/status_dialog_box.dart'; const double minimumBalance = 20; diff --git a/test/pages/start_machine_page_test.dart b/test/pages/start_machine_page_test.dart index a97cf34..a5bab86 100644 --- a/test/pages/start_machine_page_test.dart +++ b/test/pages/start_machine_page_test.dart @@ -3,7 +3,7 @@ import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; import 'package:clean_stream_laundry_app/pages/start_machine_page.dart'; import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; -import 'package:clean_stream_laundry_app/widgets/qr_button.dart'; +import 'package:clean_stream_laundry_app/features/start_machine/widgets/qr_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; diff --git a/test/widgets/qr_button.dart b/test/widgets/qr_button.dart index a6d547e..7f078fa 100644 --- a/test/widgets/qr_button.dart +++ b/test/widgets/qr_button.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/widgets/qr_button.dart'; +import 'package:clean_stream_laundry_app/features/start_machine/widgets/qr_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/widgets/show_searching_test.dart b/test/widgets/show_searching_test.dart index 78762f2..086eb3c 100644 --- a/test/widgets/show_searching_test.dart +++ b/test/widgets/show_searching_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:clean_stream_laundry_app/widgets/show_searching.dart'; +import 'package:clean_stream_laundry_app/features/start_machine/widgets/searching_dialog.dart'; void main() { From 07d2f66d6e26f3f852d211f22e49242402bcb0cf Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 20:38:38 -0400 Subject: [PATCH 43/79] Adds tests for start_machine --- .../start_machine/controller_test.dart | 164 ++++++++++++ test/features/start_machine/mocks.dart | 8 + .../start_machine/start_machine_test.dart | 234 ++++++++++++++++++ .../widgets/qr_button_test.dart} | 0 .../widgets/searching_dialog_test.dart} | 0 .../start_machine/widgets/tap_card_test.dart | 39 +++ 6 files changed, 445 insertions(+) create mode 100644 test/features/start_machine/controller_test.dart create mode 100644 test/features/start_machine/mocks.dart create mode 100644 test/features/start_machine/start_machine_test.dart rename test/{widgets/qr_button.dart => features/start_machine/widgets/qr_button_test.dart} (100%) rename test/{widgets/show_searching_test.dart => features/start_machine/widgets/searching_dialog_test.dart} (100%) create mode 100644 test/features/start_machine/widgets/tap_card_test.dart diff --git a/test/features/start_machine/controller_test.dart b/test/features/start_machine/controller_test.dart new file mode 100644 index 0000000..4d0f088 --- /dev/null +++ b/test/features/start_machine/controller_test.dart @@ -0,0 +1,164 @@ +import 'package:clean_stream_laundry_app/features/start_machine/controller.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockDoorUnlocker mockUnlocker; + late MockProfileService mockProfileService; + late MockAuthService mockAuthService; + + setUp(() { + mockUnlocker = MockDoorUnlocker(); + mockProfileService = MockProfileService(); + mockAuthService = MockAuthService(); + }); + + StartPageController buildController() => StartPageController( + profileService: mockProfileService, + authService: mockAuthService, + doorUnlocker: mockUnlocker, + ); + + group('loadUserData', () { + test('sets balance when userId and service return data', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user1'); + when(() => mockProfileService.getUserBalanceById('user1')) + .thenAnswer((_) async => {'balance': 42.0}); + + final controller = buildController(); + await controller.loadUserData(); + + expect(controller.balance, {'balance': 42.0}); + expect(controller.balanceValue, 42.0); + }); + + test('does not call getUserBalanceById when userId is null', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn(null); + + final controller = buildController(); + await controller.loadUserData(); + + verifyNever(() => mockProfileService.getUserBalanceById(any())); + expect(controller.balance, isNull); + }); + + test('notifies listeners after loading', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user1'); + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': 10.0}); + + final controller = buildController(); + var notified = false; + controller.addListener(() => notified = true); + + await controller.loadUserData(); + + expect(notified, isTrue); + }); + }); + + group('hasSufficientBalance', () { + test('returns false when balance is null', () { + final controller = buildController(); + expect(controller.hasSufficientBalance, isFalse); + }); + + test('returns false when balance is below minimum', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user1'); + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': 15.0}); + + final controller = buildController(); + await controller.loadUserData(); + + expect(controller.hasSufficientBalance, isFalse); + }); + + test('returns true when balance equals minimum', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user1'); + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': minimumBalance}); + + final controller = buildController(); + await controller.loadUserData(); + + expect(controller.hasSufficientBalance, isTrue); + }); + + test('returns true when balance exceeds minimum', () async { + when(() => mockAuthService.getCurrentUserId).thenReturn('user1'); + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': 50.0}); + + final controller = buildController(); + await controller.loadUserData(); + + expect(controller.hasSufficientBalance, isTrue); + }); + }); + + group('unlockDoor', () { + test('resets cancelSearch to false before unlocking', () async { + when(() => mockUnlocker.unlockNearestDoor()) + .thenAnswer((_) async => true); + + final controller = buildController(); + controller.cancelSearch = true; + + await controller.unlockDoor(); + + expect(controller.cancelSearch, isFalse); + }); + + test('returns true when unlockNearestDoor succeeds', () async { + when(() => mockUnlocker.unlockNearestDoor()) + .thenAnswer((_) async => true); + + final controller = buildController(); + final result = await controller.unlockDoor(); + + expect(result, isTrue); + }); + + test('returns false when unlockNearestDoor fails', () async { + when(() => mockUnlocker.unlockNearestDoor()) + .thenAnswer((_) async => false); + + final controller = buildController(); + final result = await controller.unlockDoor(); + + expect(result, isFalse); + }); + + test('calls unlockNearestDoor on the doorUnlocker', () async { + when(() => mockUnlocker.unlockNearestDoor()) + .thenAnswer((_) async => true); + + final controller = buildController(); + await controller.unlockDoor(); + + verify(() => mockUnlocker.unlockNearestDoor()).called(1); + }); + }); + + group('cancelUnlock', () { + test('sets cancelSearch to true', () { + when(() => mockUnlocker.cancelUnlockingDoor()).thenReturn(null); + + final controller = buildController(); + controller.cancelUnlock(); + + expect(controller.cancelSearch, isTrue); + }); + + test('calls cancelUnlockingDoor on doorUnlocker', () { + when(() => mockUnlocker.cancelUnlockingDoor()).thenReturn(null); + + final controller = buildController(); + controller.cancelUnlock(); + + verify(() => mockUnlocker.cancelUnlockingDoor()).called(1); + }); + }); +} \ No newline at end of file diff --git a/test/features/start_machine/mocks.dart b/test/features/start_machine/mocks.dart new file mode 100644 index 0000000..d852777 --- /dev/null +++ b/test/features/start_machine/mocks.dart @@ -0,0 +1,8 @@ +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockDoorUnlocker extends Mock implements DoorUnlocker {} +class MockProfileService extends Mock implements ProfileService {} +class MockAuthService extends Mock implements AuthService {} \ No newline at end of file diff --git a/test/features/start_machine/start_machine_test.dart b/test/features/start_machine/start_machine_test.dart new file mode 100644 index 0000000..b602a8f --- /dev/null +++ b/test/features/start_machine/start_machine_test.dart @@ -0,0 +1,234 @@ +import 'package:clean_stream_laundry_app/features/start_machine/start_machine.dart'; +import 'package:clean_stream_laundry_app/features/start_machine/widgets/qr_button.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockDoorUnlocker mockUnlocker; + late MockProfileService mockProfileService; + late MockAuthService mockAuthService; + + setUp(() async { + mockUnlocker = MockDoorUnlocker(); + mockProfileService = MockProfileService(); + mockAuthService = MockAuthService(); + + await GetIt.instance.reset(); + GetIt.instance.registerSingleton(mockProfileService); + GetIt.instance.registerSingleton(mockAuthService); + + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + when(() => mockProfileService.getUserBalanceById('user123')) + .thenAnswer((_) async => {'balance': 50.0}); + }); + + tearDown(() async { + await GetIt.instance.reset(); + }); + + Widget createWidget() { + return MaterialApp.router( + routerConfig: GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, __) => StartPage(doorUnlocker: mockUnlocker), + ), + GoRoute( + path: '/scanner', + builder: (_, __) => + const Scaffold(body: Text('Scanner Page')), + ), + GoRoute( + path: '/startPage', + builder: (_, __) => StartPage(doorUnlocker: mockUnlocker), + ), + ], + ), + ); + } + + Future scrollToUnlockButton(WidgetTester tester) async { + final scrollViewFinder = find.descendant( + of: find.byType(StartPage), + matching: find.byType(SingleChildScrollView), + ); + expect(scrollViewFinder, findsOneWidget); + await tester.drag(scrollViewFinder, const Offset(0, -500)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + } + + group('Static UI', () { + testWidgets('displays Payment Options section header', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Payment Options'), findsOneWidget); + }); + + testWidgets('displays Tap To Pay card', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Tap To Pay'), findsOneWidget); + expect(find.byIcon(Icons.tap_and_play), findsOneWidget); + }); + + testWidgets('displays Scan QR code button', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(QRButton, 'Scan QR code'), findsOneWidget); + }); + + testWidgets('displays After Hours section header', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('After Hours'), findsOneWidget); + }); + + testWidgets('displays Unlock Door button', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Unlock Door'), findsOneWidget); + }); + }); + + group('Navigation', () { + testWidgets('tapping Scan QR code navigates to /scanner', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(QRButton, 'Scan QR code')); + await tester.pumpAndSettle(); + + expect(find.text('Scanner Page'), findsOneWidget); + }); + }); + + group('Door unlocking', () { + testWidgets('shows searching dialog when Unlock Door is tapped', + (tester) async { + when(() => mockUnlocker.unlockNearestDoor()).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 50)); + return true; + }); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + await scrollToUnlockButton(tester); + + await tester.tap(find.text('Unlock Door')); + await tester.pump(const Duration(milliseconds: 20)); + + expect(find.byType(Dialog), findsOneWidget); + expect(find.textContaining('Finding Nearby Doors'), findsOneWidget); + await tester.pumpAndSettle(); + }); + + testWidgets('shows success dialog after successful unlock', (tester) async { + when(() => mockUnlocker.unlockNearestDoor()) + .thenAnswer((_) async => true); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + await scrollToUnlockButton(tester); + + await tester.tap(find.text('Unlock Door')); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(seconds: 2)); + + expect(find.text('Door Unlocked!'), findsOneWidget); + }); + + testWidgets('shows failure dialog when unlock fails', (tester) async { + when(() => mockUnlocker.unlockNearestDoor()) + .thenAnswer((_) async => false); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + await scrollToUnlockButton(tester); + + await tester.tap(find.text('Unlock Door')); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(seconds: 2)); + + expect(find.text('No Nearby Doors Found'), findsOneWidget); + }); + + testWidgets('allows unlocking when balance is exactly 20', (tester) async { + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': 20.0}); + when(() => mockUnlocker.unlockNearestDoor()) + .thenAnswer((_) async => true); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + await scrollToUnlockButton(tester); + + await tester.tap(find.text('Unlock Door')); + await tester.pumpAndSettle(); + + verify(() => mockUnlocker.unlockNearestDoor()).called(1); + }); + + testWidgets('allows unlocking when balance is above 20', (tester) async { + when(() => mockUnlocker.unlockNearestDoor()) + .thenAnswer((_) async => true); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + await scrollToUnlockButton(tester); + + await tester.tap(find.text('Unlock Door')); + await tester.pumpAndSettle(); + + verify(() => mockUnlocker.unlockNearestDoor()).called(1); + }); + }); + + group('Low balance', () { + testWidgets('shows low balance dialog when balance is below 20', + (tester) async { + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => {'balance': 15.0}); + + await tester.pumpWidget(createWidget()); + await tester.pump(); + await tester.pumpAndSettle(); + await scrollToUnlockButton(tester); + + await tester.tap(find.text('Unlock Door')); + await tester.pumpAndSettle(); + + expect(find.text('Low Balance'), findsOneWidget); + expect(find.textContaining('at least 20.00'), findsOneWidget); + verifyNever(() => mockUnlocker.unlockNearestDoor()); + }); + + testWidgets('shows low balance dialog when balance is null', (tester) async { + when(() => mockProfileService.getUserBalanceById(any())) + .thenAnswer((_) async => null); + + await tester.pumpWidget(createWidget()); + await tester.pump(); + await tester.pumpAndSettle(); + await scrollToUnlockButton(tester); + + await tester.tap(find.text('Unlock Door')); + await tester.pumpAndSettle(); + + expect(find.text('Low Balance'), findsOneWidget); + verifyNever(() => mockUnlocker.unlockNearestDoor()); + }); + }); +} \ No newline at end of file diff --git a/test/widgets/qr_button.dart b/test/features/start_machine/widgets/qr_button_test.dart similarity index 100% rename from test/widgets/qr_button.dart rename to test/features/start_machine/widgets/qr_button_test.dart diff --git a/test/widgets/show_searching_test.dart b/test/features/start_machine/widgets/searching_dialog_test.dart similarity index 100% rename from test/widgets/show_searching_test.dart rename to test/features/start_machine/widgets/searching_dialog_test.dart diff --git a/test/features/start_machine/widgets/tap_card_test.dart b/test/features/start_machine/widgets/tap_card_test.dart new file mode 100644 index 0000000..17cd0c6 --- /dev/null +++ b/test/features/start_machine/widgets/tap_card_test.dart @@ -0,0 +1,39 @@ +import 'package:clean_stream_laundry_app/features/start_machine/widgets/tap_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('TapToPayCard', () { + Widget buildWidget() => const MaterialApp( + home: Scaffold(body: TapToPayCard()), + ); + + testWidgets('displays Tap To Pay heading', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.text('Tap To Pay'), findsOneWidget); + }); + + testWidgets('displays description text', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.text('Tap phone to machine to pay'), findsOneWidget); + }); + + testWidgets('displays tap_and_play icon', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byIcon(Icons.tap_and_play), findsOneWidget); + }); + + testWidgets('has blue border', (tester) async { + await tester.pumpWidget(buildWidget()); + final container = tester.widget( + find.ancestor( + of: find.text('Tap To Pay'), + matching: find.byType(Container), + ).first, + ); + final decoration = container.decoration as BoxDecoration; + final border = decoration.border as Border?; + expect(border?.top.color, Colors.blue); + }); + }); +} \ No newline at end of file From 4aeb4383c31744c7bfeaafb60b42d3bd2b32576c Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 20:51:06 -0400 Subject: [PATCH 44/79] Separates verification code --- lib/features/verify_code/controller.dart | 80 +++++++++ lib/features/verify_code/verify_code.dart | 154 ++++++++++++++++++ .../verify_code/widgets/code_field.dart | 49 ++++++ 3 files changed, 283 insertions(+) create mode 100644 lib/features/verify_code/controller.dart create mode 100644 lib/features/verify_code/verify_code.dart create mode 100644 lib/features/verify_code/widgets/code_field.dart diff --git a/lib/features/verify_code/controller.dart b/lib/features/verify_code/controller.dart new file mode 100644 index 0000000..6dea917 --- /dev/null +++ b/lib/features/verify_code/controller.dart @@ -0,0 +1,80 @@ +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +enum VerifyResult { success, invalid, error } + +enum ResendResult { success, failed, error } + +class CodeVerificationController extends ChangeNotifier { + final AuthService authService; + + CodeVerificationController({AuthService? authService}) + : authService = authService ?? GetIt.instance(); + + final TextEditingController codeController = TextEditingController(); + + bool isLoading = false; + String? error; + + void disposeController() { + codeController.dispose(); + } + + void clearError() { + if (error != null) { + error = null; + notifyListeners(); + } + } + + Future verifyCode(String email) async { + final code = codeController.text.trim(); + + if (code.length != 6) { + error = 'Please enter the 6-digit code'; + notifyListeners(); + return VerifyResult.invalid; + } + + isLoading = true; + error = null; + notifyListeners(); + + try { + final response = await authService.verifyCode( + email: email, + code: code, + ); + + if (response == AuthenticationResponses.success) { + return VerifyResult.success; + } else { + error = 'Invalid or expired code'; + notifyListeners(); + return VerifyResult.invalid; + } + } catch (_) { + error = 'Something went wrong. Try again'; + notifyListeners(); + return VerifyResult.error; + } finally { + isLoading = false; + notifyListeners(); + } + } + + Future sendResetEmail(String email) async { + try { + final response = await authService.resetPassword(email); + if (response == AuthenticationResponses.success) { + return ResendResult.success; + } else { + return ResendResult.failed; + } + } catch (_) { + return ResendResult.error; + } + } +} \ No newline at end of file diff --git a/lib/features/verify_code/verify_code.dart b/lib/features/verify_code/verify_code.dart new file mode 100644 index 0000000..e5f3372 --- /dev/null +++ b/lib/features/verify_code/verify_code.dart @@ -0,0 +1,154 @@ +import 'controller.dart'; +import 'widgets/code_field.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class CodeVerificationPage extends StatefulWidget { + final String email; + + const CodeVerificationPage({super.key, required this.email}); + + @override + State createState() => + _CodeVerificationPageState(); +} + +class _CodeVerificationPageState extends State { + late final CodeVerificationController _controller; + + @override + void initState() { + super.initState(); + _controller = CodeVerificationController(); + _controller.addListener(() { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _controller.disposeController(); + _controller.dispose(); + super.dispose(); + } + + void _showMessage(String msg) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(msg))); + } + + Future _onVerifyPressed() async { + final result = await _controller.verifyCode(widget.email); + if (!mounted) return; + + if (result == VerifyResult.success) { + context.go('/reset-protected'); + } + } + + Future _onResendPressed() async { + final result = await _controller.sendResetEmail(widget.email); + if (!mounted) return; + + switch (result) { + case ResendResult.success: + _showMessage('Password reset email sent! Check your email.'); + break; + case ResendResult.failed: + _showMessage('Failed to send reset email.'); + break; + case ResendResult.error: + _showMessage('Error sending reset email.'); + break; + } + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: scheme.surface, + appBar: AppBar( + backgroundColor: scheme.surface, + foregroundColor: scheme.fontInverted, + title: const Text('Verify Code'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + const SizedBox(height: 40), + Text( + 'Enter Verification Code', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: scheme.fontInverted, + ), + ), + const SizedBox(height: 12), + Text( + 'We sent a 6-digit code to', + style: TextStyle( + color: scheme.fontInverted.withOpacity(0.7)), + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + widget.email, + style: TextStyle( + fontWeight: FontWeight.w600, + color: scheme.primary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + VerificationCodeField( + controller: _controller.codeController, + error: _controller.error, + onChanged: (_) => _controller.clearError(), + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: + _controller.isLoading ? null : _onVerifyPressed, + style: ElevatedButton.styleFrom( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + padding: + const EdgeInsets.symmetric(vertical: 14), + ), + child: _controller.isLoading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : const Text('Verify'), + ), + ), + const SizedBox(height: 16), + if (_controller.error != null) + Text( + _controller.error!, + style: const TextStyle(color: Colors.red), + ), + const SizedBox(height: 16), + TextButton( + onPressed: _onResendPressed, + child: Text( + 'Resend code', + style: TextStyle(color: scheme.primary), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/verify_code/widgets/code_field.dart b/lib/features/verify_code/widgets/code_field.dart new file mode 100644 index 0000000..b07e908 --- /dev/null +++ b/lib/features/verify_code/widgets/code_field.dart @@ -0,0 +1,49 @@ +import 'package:clean_stream_laundry_app/Logic/Theme/theme.dart'; +import 'package:flutter/material.dart'; + +class VerificationCodeField extends StatelessWidget { + final TextEditingController controller; + final String? error; + final ValueChanged? onChanged; + + const VerificationCodeField({ + super.key, + required this.controller, + this.error, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final hasError = error != null; + + return TextField( + controller: controller, + keyboardType: TextInputType.number, + maxLength: 6, + style: TextStyle( + color: scheme.fontInverted, + letterSpacing: 8, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + onChanged: onChanged, + decoration: InputDecoration( + labelText: hasError ? error : '6-digit code', + labelStyle: TextStyle( + color: hasError ? Colors.red : scheme.primary, + ), + counterText: '', + prefixIcon: Icon( + Icons.lock, + color: hasError ? Colors.red : scheme.primary, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ); + } +} \ No newline at end of file From c73ace2206e114c798ddea5ab4dd400e64c7b068 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 20:54:22 -0400 Subject: [PATCH 45/79] Adds tests for code verification --- .../features/verify_code/controller_test.dart | 236 ++++++++++++++++ test/features/verify_code/mocks.dart | 4 + .../verify_code/verify_code_test.dart | 257 ++++++++++++++++++ .../verify_code/widgets/code_field_test.dart | 85 ++++++ 4 files changed, 582 insertions(+) create mode 100644 test/features/verify_code/controller_test.dart create mode 100644 test/features/verify_code/mocks.dart create mode 100644 test/features/verify_code/verify_code_test.dart create mode 100644 test/features/verify_code/widgets/code_field_test.dart diff --git a/test/features/verify_code/controller_test.dart b/test/features/verify_code/controller_test.dart new file mode 100644 index 0000000..4e542b4 --- /dev/null +++ b/test/features/verify_code/controller_test.dart @@ -0,0 +1,236 @@ +import 'dart:async'; + +import 'package:clean_stream_laundry_app/features/verify_code/controller.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + + setUp(() { + mockAuthService = MockAuthService(); + }); + + CodeVerificationController buildController() => + CodeVerificationController(authService: mockAuthService); + + // --------------------------------------------------------------------------- + // clearError + // --------------------------------------------------------------------------- + + group('clearError', () { + test('clears error and notifies when error is set', () { + final c = buildController(); + c.error = 'Some error'; + var notified = false; + c.addListener(() => notified = true); + + c.clearError(); + + expect(c.error, isNull); + expect(notified, isTrue); + }); + + test('does nothing when error is already null', () { + final c = buildController(); + var notified = false; + c.addListener(() => notified = true); + + c.clearError(); + + expect(notified, isFalse); + }); + }); + + // --------------------------------------------------------------------------- + // verifyCode + // --------------------------------------------------------------------------- + + group('verifyCode', () { + test('returns invalid and sets error when code is shorter than 6 digits', + () async { + final c = buildController(); + c.codeController.text = '1234'; + + final result = await c.verifyCode('test@example.com'); + + expect(result, VerifyResult.invalid); + expect(c.error, 'Please enter the 6-digit code'); + verifyNever(() => mockAuthService.verifyCode( + email: any(named: 'email'), + code: any(named: 'code'), + )); + }); + + test('returns invalid and sets error when code is longer than 6 digits', + () async { + final c = buildController(); + c.codeController.text = '1234567'; + + final result = await c.verifyCode('test@example.com'); + + expect(result, VerifyResult.invalid); + expect(c.error, 'Please enter the 6-digit code'); + }); + + test('returns success on AuthenticationResponses.success', () async { + when(() => mockAuthService.verifyCode( + email: any(named: 'email'), + code: any(named: 'code'), + )).thenAnswer((_) async => AuthenticationResponses.success); + + final c = buildController(); + c.codeController.text = '123456'; + + final result = await c.verifyCode('test@example.com'); + + expect(result, VerifyResult.success); + expect(c.error, isNull); + }); + + test('calls verifyCode with correct email and code', () async { + when(() => mockAuthService.verifyCode( + email: any(named: 'email'), + code: any(named: 'code'), + )).thenAnswer((_) async => AuthenticationResponses.success); + + final c = buildController(); + c.codeController.text = '654321'; + + await c.verifyCode('user@example.com'); + + verify(() => mockAuthService.verifyCode( + email: 'user@example.com', + code: '654321', + )).called(1); + }); + + test('returns invalid and sets error on failure response', () async { + when(() => mockAuthService.verifyCode( + email: any(named: 'email'), + code: any(named: 'code'), + )).thenAnswer((_) async => AuthenticationResponses.failure); + + final c = buildController(); + c.codeController.text = '123456'; + + final result = await c.verifyCode('test@example.com'); + + expect(result, VerifyResult.invalid); + expect(c.error, 'Invalid or expired code'); + }); + + test('returns error and sets error on exception', () async { + when(() => mockAuthService.verifyCode( + email: any(named: 'email'), + code: any(named: 'code'), + )).thenThrow(Exception('network')); + + final c = buildController(); + c.codeController.text = '123456'; + + final result = await c.verifyCode('test@example.com'); + + expect(result, VerifyResult.error); + expect(c.error, 'Something went wrong. Try again'); + }); + + test('sets and clears isLoading around service call', () async { + final completer = Completer(); + when(() => mockAuthService.verifyCode( + email: any(named: 'email'), + code: any(named: 'code'), + )).thenAnswer((_) => completer.future); + + final c = buildController(); + c.codeController.text = '123456'; + + final future = c.verifyCode('test@example.com'); + expect(c.isLoading, isTrue); + + completer.complete(AuthenticationResponses.success); + await future; + expect(c.isLoading, isFalse); + }); + + test('clears isLoading even when service throws', () async { + when(() => mockAuthService.verifyCode( + email: any(named: 'email'), + code: any(named: 'code'), + )).thenThrow(Exception()); + + final c = buildController(); + c.codeController.text = '123456'; + + await c.verifyCode('test@example.com'); + expect(c.isLoading, isFalse); + }); + + test('notifies listeners on loading state transitions', () async { + when(() => mockAuthService.verifyCode( + email: any(named: 'email'), + code: any(named: 'code'), + )).thenAnswer((_) async => AuthenticationResponses.success); + + final c = buildController(); + c.codeController.text = '123456'; + + var notifyCount = 0; + c.addListener(() => notifyCount++); + + await c.verifyCode('test@example.com'); + + // At minimum: isLoading=true, then isLoading=false + expect(notifyCount, greaterThanOrEqualTo(2)); + }); + }); + + // --------------------------------------------------------------------------- + // sendResetEmail + // --------------------------------------------------------------------------- + + group('sendResetEmail', () { + test('returns success on AuthenticationResponses.success', () async { + when(() => mockAuthService.resetPassword(any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + final c = buildController(); + final result = await c.sendResetEmail('test@example.com'); + + expect(result, ResendResult.success); + }); + + test('returns failed on non-success response', () async { + when(() => mockAuthService.resetPassword(any())) + .thenAnswer((_) async => AuthenticationResponses.failure); + + final c = buildController(); + final result = await c.sendResetEmail('test@example.com'); + + expect(result, ResendResult.failed); + }); + + test('returns error on thrown exception', () async { + when(() => mockAuthService.resetPassword(any())) + .thenThrow(Exception('network')); + + final c = buildController(); + final result = await c.sendResetEmail('test@example.com'); + + expect(result, ResendResult.error); + }); + + test('calls resetPassword with correct email', () async { + when(() => mockAuthService.resetPassword(any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + final c = buildController(); + await c.sendResetEmail('user@example.com'); + + verify(() => mockAuthService.resetPassword('user@example.com')) + .called(1); + }); + }); +} \ No newline at end of file diff --git a/test/features/verify_code/mocks.dart b/test/features/verify_code/mocks.dart new file mode 100644 index 0000000..5227aca --- /dev/null +++ b/test/features/verify_code/mocks.dart @@ -0,0 +1,4 @@ +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthService extends Mock implements AuthService {} \ No newline at end of file diff --git a/test/features/verify_code/verify_code_test.dart b/test/features/verify_code/verify_code_test.dart new file mode 100644 index 0000000..46f36a7 --- /dev/null +++ b/test/features/verify_code/verify_code_test.dart @@ -0,0 +1,257 @@ +import 'dart:async'; + +import 'package:clean_stream_laundry_app/features/verify_code/verify_code.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + + setUp(() async { + mockAuthService = MockAuthService(); + await GetIt.instance.reset(); + GetIt.instance.registerSingleton(mockAuthService); + }); + + tearDown(() async { + await GetIt.instance.reset(); + }); + + Widget createWidget() { + return MaterialApp.router( + routerConfig: GoRouter( + initialLocation: '/verify-code', + routes: [ + GoRoute( + path: '/verify-code', + builder: (_, __) => + const CodeVerificationPage(email: 'testEmail'), + ), + GoRoute( + path: '/reset-protected', + builder: (_, __) => + const Scaffold(body: Text('Reset Password')), + ), + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // UI elements + // --------------------------------------------------------------------------- + + group('UI elements render correctly', () { + testWidgets('displays Verify Code appbar title', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Verify Code'), findsOneWidget); + }); + + testWidgets('displays Enter Verification Code subheading', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Enter Verification Code'), findsOneWidget); + }); + + testWidgets('displays instruction text', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('We sent a 6-digit code to'), findsOneWidget); + }); + + testWidgets('displays email address', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('testEmail'), findsOneWidget); + }); + + testWidgets('displays TextField', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsOneWidget); + }); + + testWidgets('displays Verify button', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.byType(ElevatedButton), findsOneWidget); + expect(find.text('Verify'), findsOneWidget); + }); + + testWidgets('displays Resend code button', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.byType(TextButton), findsOneWidget); + expect(find.text('Resend code'), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // Logic + // --------------------------------------------------------------------------- + + group('Logic tests', () { + testWidgets('shows error when code is too short', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).at(0), '1234'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(find.text('Please enter the 6-digit code'), findsWidgets); + }); + + testWidgets('navigates to /reset-protected on valid code', (tester) async { + when(() => mockAuthService.verifyCode( + email: any(named: 'email'), + code: any(named: 'code'), + )).thenAnswer((_) async => AuthenticationResponses.success); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '123456'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(find.text('Reset Password'), findsWidgets); + }); + + testWidgets('shows error when code verification fails', (tester) async { + when(() => mockAuthService.verifyCode( + email: any(named: 'email'), + code: any(named: 'code'), + )).thenAnswer((_) async => AuthenticationResponses.failure); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '123456'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(find.text('Invalid or expired code'), findsWidgets); + }); + + testWidgets('shows error when exception is thrown during verification', + (tester) async { + when(() => mockAuthService.verifyCode( + email: any(named: 'email'), + code: any(named: 'code'), + )).thenThrow(Exception()); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '123456'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(find.text('Something went wrong. Try again'), findsWidgets); + }); + + testWidgets('shows success snackbar when resend succeeds', (tester) async { + when(() => mockAuthService.resetPassword(any())) + .thenAnswer((_) async => AuthenticationResponses.success); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + + expect( + find.text('Password reset email sent! Check your email.'), + findsWidgets, + ); + }); + + testWidgets('shows failure snackbar when resend fails', (tester) async { + when(() => mockAuthService.resetPassword(any())) + .thenAnswer((_) async => AuthenticationResponses.failure); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + + expect(find.text('Failed to send reset email.'), findsWidgets); + }); + + testWidgets('clears error when user starts typing after error', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + // Trigger error + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + expect(find.text('Please enter the 6-digit code'), findsWidgets); + + // Start typing — error should clear + await tester.enterText(find.byType(TextField), '1'); + await tester.pump(); + + expect(find.text('Please enter the 6-digit code'), findsNothing); + }); + + testWidgets('shows loading indicator while verifying', (tester) async { + final completer = Completer(); + when(() => mockAuthService.verifyCode( + email: any(named: 'email'), + code: any(named: 'code'), + )).thenAnswer((_) => completer.future); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '123456'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Verify'), findsNothing); + + completer.complete(AuthenticationResponses.success); + await tester.pumpAndSettle(); + }); + + testWidgets('disables Verify button while loading', (tester) async { + final completer = Completer(); + when(() => mockAuthService.verifyCode( + email: any(named: 'email'), + code: any(named: 'code'), + )).thenAnswer((_) => completer.future); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '123456'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + + final button = + tester.widget(find.byType(ElevatedButton)); + expect(button.onPressed, isNull); + + completer.complete(AuthenticationResponses.success); + await tester.pumpAndSettle(); + }); + }); +} \ No newline at end of file diff --git a/test/features/verify_code/widgets/code_field_test.dart b/test/features/verify_code/widgets/code_field_test.dart new file mode 100644 index 0000000..ca98acc --- /dev/null +++ b/test/features/verify_code/widgets/code_field_test.dart @@ -0,0 +1,85 @@ +import 'package:clean_stream_laundry_app/features/verify_code/widgets/code_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildWidget({ + TextEditingController? controller, + String? error, + ValueChanged? onChanged, + }) { + return MaterialApp( + home: Scaffold( + body: VerificationCodeField( + controller: controller ?? TextEditingController(), + error: error, + onChanged: onChanged, + ), + ), + ); + } + + group('VerificationCodeField', () { + group('Rendering', () { + testWidgets('displays default label when no error', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.text('6-digit code'), findsOneWidget); + }); + + testWidgets('displays error text as label when error is set', + (tester) async { + await tester.pumpWidget( + buildWidget(error: 'Please enter the 6-digit code')); + expect(find.text('Please enter the 6-digit code'), findsOneWidget); + }); + + testWidgets('displays lock icon', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byIcon(Icons.lock), findsOneWidget); + }); + + testWidgets('is centered text', (tester) async { + await tester.pumpWidget(buildWidget()); + final field = tester.widget(find.byType(TextField)); + expect(field.textAlign, TextAlign.center); + }); + + testWidgets('has max length of 6', (tester) async { + await tester.pumpWidget(buildWidget()); + final field = tester.widget(find.byType(TextField)); + expect(field.maxLength, 6); + }); + + testWidgets('uses numeric keyboard', (tester) async { + await tester.pumpWidget(buildWidget()); + final field = tester.widget(find.byType(TextField)); + expect(field.keyboardType, TextInputType.number); + }); + + testWidgets('lock icon is red when error is set', (tester) async { + await tester.pumpWidget(buildWidget(error: 'Some error')); + final icon = tester.widget(find.byIcon(Icons.lock)); + expect(icon.color, Colors.red); + }); + }); + + group('Interaction', () { + testWidgets('accepts text input via controller', (tester) async { + final controller = TextEditingController(); + await tester.pumpWidget(buildWidget(controller: controller)); + + await tester.enterText(find.byType(TextField), '123456'); + expect(controller.text, '123456'); + }); + + testWidgets('calls onChanged when text is entered', (tester) async { + String? changed; + await tester.pumpWidget( + buildWidget(onChanged: (v) => changed = v)); + + await tester.enterText(find.byType(TextField), '1'); + expect(changed, '1'); + }); + }); + }); +} \ No newline at end of file From d00de02fe5e2aac71331269ca35cc475422c5922 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 21:01:09 -0400 Subject: [PATCH 46/79] Fixes routes in tests --- test/features/login/login_test.dart | 2 +- test/features/login/widgets/links_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/features/login/login_test.dart b/test/features/login/login_test.dart index 49b8395..e5b26dd 100644 --- a/test/features/login/login_test.dart +++ b/test/features/login/login_test.dart @@ -54,7 +54,7 @@ void main() { const Scaffold(body: Text('Email Verification')), ), GoRoute( - path: '/sign_up', + path: '/signup', builder: (_, __) => const Scaffold(body: Text('Sign Up Page')), ), GoRoute( diff --git a/test/features/login/widgets/links_test.dart b/test/features/login/widgets/links_test.dart index 583dd9f..5826de7 100644 --- a/test/features/login/widgets/links_test.dart +++ b/test/features/login/widgets/links_test.dart @@ -10,7 +10,7 @@ void main() { routes: [ GoRoute(path: '/', builder: (_, __) => const Scaffold(body: LoginLinks())), GoRoute( - path: '/sign_up', + path: '/signup', builder: (_, __) => const Scaffold(body: Text('Sign Up Page')), ), GoRoute( From 85896c6b6c07a19a7e773dd3cd599287dcaa4010 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 21:06:43 -0400 Subject: [PATCH 47/79] Replaces pages in router with features --- lib/middleware/app_router.dart | 50 +++++++++++++++++----------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/lib/middleware/app_router.dart b/lib/middleware/app_router.dart index 29da117..d7d40fa 100644 --- a/lib/middleware/app_router.dart +++ b/lib/middleware/app_router.dart @@ -1,24 +1,24 @@ import 'package:app_links/app_links.dart'; -import 'package:clean_stream_laundry_app/pages/change_email_verification.dart'; -import 'package:clean_stream_laundry_app/pages/edit_profile_page.dart'; -import 'package:clean_stream_laundry_app/pages/verify_code_page.dart'; -import 'package:go_router/go_router.dart'; +import 'package:clean_stream_laundry_app/features/change_email_verification/change_email_verification.dart'; +import 'package:clean_stream_laundry_app/features/edit_profile/edit_profile.dart'; +import 'package:clean_stream_laundry_app/features/verify_code/verify_code.dart'; +import 'package:clean_stream_laundry_app/features/email_verification/email_verification.dart'; +import 'package:clean_stream_laundry_app/features/home/home.dart'; +import 'package:clean_stream_laundry_app/features/loading/loading.dart'; +import 'package:clean_stream_laundry_app/features/loyalty/loyalty.dart'; +import 'package:clean_stream_laundry_app/features/scanner/scanner.dart'; +import 'package:clean_stream_laundry_app/features/sign_up/sign_up.dart'; +import 'package:clean_stream_laundry_app/features/login/login.dart'; +import 'package:clean_stream_laundry_app/features/not_found/not_found.dart'; +import 'package:clean_stream_laundry_app/features/settings/settings.dart'; +import 'package:clean_stream_laundry_app/features/start_machine/start_machine.dart'; +import 'package:clean_stream_laundry_app/features/machine_payment/machine_payment.dart'; +import 'package:clean_stream_laundry_app/features/monthly_report/monthly_report.dart'; +import 'package:clean_stream_laundry_app/features/refund_request/refund_request.dart'; +import 'package:clean_stream_laundry_app/features/password_reset/password_reset.dart'; +import 'package:clean_stream_laundry_app/features/reset_protected/reset_protected.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/pages/email_verification_page.dart'; -import 'package:clean_stream_laundry_app/pages/home_page.dart'; -import 'package:clean_stream_laundry_app/pages/loading_page.dart'; -import 'package:clean_stream_laundry_app/pages/loyalty_card_page.dart'; -import 'package:clean_stream_laundry_app/pages/scanner_widget.dart'; -import 'package:clean_stream_laundry_app/pages/sign_up_screen.dart'; -import 'package:clean_stream_laundry_app/pages/login_page.dart'; -import 'package:clean_stream_laundry_app/pages/not_found_page.dart'; -import 'package:clean_stream_laundry_app/pages/settings.dart'; -import 'package:clean_stream_laundry_app/pages/start_machine_page.dart'; -import 'package:clean_stream_laundry_app/pages/payment_page.dart'; -import 'package:clean_stream_laundry_app/pages/monthly_transaction_history.dart'; -import 'package:clean_stream_laundry_app/pages/refund_page.dart'; -import 'package:clean_stream_laundry_app/pages/password_reset.dart'; -import 'package:clean_stream_laundry_app/pages/reset_protected_page.dart'; +import 'package:go_router/go_router.dart'; class RouterService { GoRouter createRouter(AuthService authenticator) => GoRouter( @@ -28,7 +28,7 @@ class RouterService { path: '/login', pageBuilder: (context, state) => CustomTransitionPage( key: state.pageKey, - child: LoginScreen(appLinks: AppLinks()), + child: Login(appLinks: AppLinks()), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, transitionsBuilder: (_, _, _, child) => child, @@ -38,7 +38,7 @@ class RouterService { path: '/signup', pageBuilder: (context, state) => CustomTransitionPage( key: state.pageKey, - child: SignUpScreen(), + child: SignUpPage(), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, transitionsBuilder: (_, _, _, child) => child, @@ -48,7 +48,7 @@ class RouterService { path: '/scanner', pageBuilder: (context, state) => CustomTransitionPage( key: state.pageKey, - child: ScannerWidget(), + child: ScannerPage(), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, transitionsBuilder: (_, _, _, child) => child, @@ -81,7 +81,7 @@ class RouterService { final machineId = state.uri.queryParameters['machineId'] ?? ''; return CustomTransitionPage( key: state.pageKey, - child: PaymentPage(machineId: machineId), + child: MachinePayment(machineId: machineId), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, transitionsBuilder: (_, _, _, child) => child, @@ -134,7 +134,7 @@ class RouterService { final transactions = state.extra as List>? ?? []; return CustomTransitionPage( key: state.pageKey, - child: MonthlyTransactionHistory(transactions: transactions), + child: MonthlyReport(transactions: transactions), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, transitionsBuilder: (_, _, _, child) => child, @@ -202,7 +202,7 @@ class RouterService { if (uri.scheme == 'clean-stream' && uri.host == 'reset-protected') { return ResetProtectedPage(); } - return const NotFoundScreen(); + return const NotFound(); }, redirect: (context, state) { final uri = state.uri; From 8ef0bced7840ad78fa36b846fdade6be071228de Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 21:18:06 -0400 Subject: [PATCH 48/79] Fixes missing colon --- lib/features/edit_profile/edit_profile.dart | 4 ++-- test/features/edit_profile/edit_profile_test.dart | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/features/edit_profile/edit_profile.dart b/lib/features/edit_profile/edit_profile.dart index 26dd964..199b483 100644 --- a/lib/features/edit_profile/edit_profile.dart +++ b/lib/features/edit_profile/edit_profile.dart @@ -289,7 +289,7 @@ class _EditProfilePageState extends State { const SectionHeader(title: 'Full Name'), const SizedBox(height: 12), InfoCard( - label: 'Current', + label: 'Current:', value: _controller.currentName.isNotEmpty ? _controller.currentName : 'Not set', @@ -304,7 +304,7 @@ class _EditProfilePageState extends State { const SectionHeader(title: 'Email Address'), const SizedBox(height: 12), InfoCard( - label: 'Current', + label: 'Current:', value: _controller.currentEmail.isNotEmpty ? _controller.currentEmail : 'Not set', diff --git a/test/features/edit_profile/edit_profile_test.dart b/test/features/edit_profile/edit_profile_test.dart index 99e6cc3..f485747 100644 --- a/test/features/edit_profile/edit_profile_test.dart +++ b/test/features/edit_profile/edit_profile_test.dart @@ -82,7 +82,7 @@ void main() { expect(find.text('Full Name'), findsOneWidget); expect(find.text('Email Address'), findsOneWidget); - expect(find.text('Current'), findsNWidgets(2)); + expect(find.text('Current:'), findsNWidgets(2)); expect(find.text('John Doe'), findsNWidgets(2)); expect(find.text('test@example.com'), findsNWidgets(2)); expect(find.text('Save Changes'), findsOneWidget); From 7682ef1ed15e4f0b55b462fc0e80e9786fceefe8 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 21:29:46 -0400 Subject: [PATCH 49/79] Fixes qr scanner --- lib/features/scanner/controller.dart | 13 ++++++++----- lib/features/scanner/scanner.dart | 12 +++++++++++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/lib/features/scanner/controller.dart b/lib/features/scanner/controller.dart index afe8b43..71c144b 100644 --- a/lib/features/scanner/controller.dart +++ b/lib/features/scanner/controller.dart @@ -19,17 +19,20 @@ class ScannerController extends ChangeNotifier { cameraController.dispose(); } - void handleQRCode(BarcodeCapture capture) { + void handleQRCode( + BarcodeCapture capture, { + required void Function(String route) onNavigate, + required void Function(String title, String message) onError, + }) async { for (final barcode in capture.barcodes) { if (barcode.rawValue != null) { scannedCode = barcode.rawValue; notifyListeners(); - final parser = QrScannerParser(scannedCode!); - processNayaxCode( + await processNayaxCode( parser.getNayaxDeviceID(), - onNavigate: (_) {}, - onError: (_, __) {}, + onNavigate: onNavigate, + onError: onError, ); break; } diff --git a/lib/features/scanner/scanner.dart b/lib/features/scanner/scanner.dart index d8baef6..8939387 100644 --- a/lib/features/scanner/scanner.dart +++ b/lib/features/scanner/scanner.dart @@ -50,7 +50,17 @@ class _ScannerPageState extends State { children: [ MobileScanner( controller: _controller.cameraController, - onDetect: (capture) => _controller.handleQRCode(capture), + onDetect: (capture) => _controller.handleQRCode( + capture, + onNavigate: (route) { + if (mounted) context.go(route); + }, + onError: (title, message) { + if (mounted) { + statusDialog(context, title: title, message: message, isSuccess: false); + } + }, + ), ), ScannerOverlay( onCancel: () => context.go('/startPage'), From 0d0a7bd2331293d3830ce81258a989a4fe14b791 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sun, 22 Mar 2026 21:41:17 -0400 Subject: [PATCH 50/79] Removes pages/ replaces pages with features --- lib/pages/change_email_verification.dart | 149 --- lib/pages/edit_profile_page.dart | 728 -------------- lib/pages/email_verification_page.dart | 154 --- lib/pages/home_page.dart | 545 ----------- lib/pages/loading_page.dart | 141 --- lib/pages/login_page.dart | 340 ------- lib/pages/loyalty_card_page.dart | 488 ---------- lib/pages/monthly_transaction_history.dart | 276 ------ lib/pages/not_found_page.dart | 46 - lib/pages/password_reset.dart | 179 ---- lib/pages/payment_page.dart | 413 -------- lib/pages/refund_page.dart | 429 --------- lib/pages/reset_protected_page.dart | 316 ------- lib/pages/scanner_widget.dart | 126 --- lib/pages/settings.dart | 228 ----- lib/pages/sign_up_screen.dart | 431 --------- lib/pages/start_machine_page.dart | 210 ----- lib/pages/verify_code_page.dart | 237 ----- .../change_email_verification_test.dart | 6 +- .../email_verification_test.dart | 2 +- test/features/not_found/not_found_test.dart | 4 +- test/mocks.dart | 6 + .../pages/change_email_verification_test.dart | 331 ------- test/pages/edit_profile_page_test.dart | 411 -------- test/pages/email_verification_page_test.dart | 361 ------- test/pages/home_page_test.dart | 308 ------ test/pages/loading_page_test.dart | 387 -------- test/pages/login_page_test.dart | 452 --------- test/pages/loyalty_card_page_test.dart | 888 ------------------ test/pages/mocks.dart | 64 -- .../monthly_transaction_history_test.dart | 634 ------------- test/pages/not_found_page_test.dart | 154 --- test/pages/password_reset_test.dart | 136 --- test/pages/payment_page_test.dart | 391 -------- test/pages/refund_page_test.dart | 732 --------------- test/pages/reset_protected_page_test.dart | 136 --- test/pages/scanner_widget_test.dart | 158 ---- test/pages/settings_test.dart | 304 ------ test/pages/sign_up_screen_test.dart | 408 -------- test/pages/start_machine_page_test.dart | 192 ---- test/pages/verify_code_page_test.dart | 225 ----- test/root_app_test.dart | 2 +- 42 files changed, 14 insertions(+), 12114 deletions(-) delete mode 100644 lib/pages/change_email_verification.dart delete mode 100644 lib/pages/edit_profile_page.dart delete mode 100644 lib/pages/email_verification_page.dart delete mode 100644 lib/pages/home_page.dart delete mode 100644 lib/pages/loading_page.dart delete mode 100644 lib/pages/login_page.dart delete mode 100644 lib/pages/loyalty_card_page.dart delete mode 100644 lib/pages/monthly_transaction_history.dart delete mode 100644 lib/pages/not_found_page.dart delete mode 100644 lib/pages/password_reset.dart delete mode 100644 lib/pages/payment_page.dart delete mode 100644 lib/pages/refund_page.dart delete mode 100644 lib/pages/reset_protected_page.dart delete mode 100644 lib/pages/scanner_widget.dart delete mode 100644 lib/pages/settings.dart delete mode 100644 lib/pages/sign_up_screen.dart delete mode 100644 lib/pages/start_machine_page.dart delete mode 100644 lib/pages/verify_code_page.dart create mode 100644 test/mocks.dart delete mode 100644 test/pages/change_email_verification_test.dart delete mode 100644 test/pages/edit_profile_page_test.dart delete mode 100644 test/pages/email_verification_page_test.dart delete mode 100644 test/pages/home_page_test.dart delete mode 100644 test/pages/loading_page_test.dart delete mode 100644 test/pages/login_page_test.dart delete mode 100644 test/pages/loyalty_card_page_test.dart delete mode 100644 test/pages/mocks.dart delete mode 100644 test/pages/monthly_transaction_history_test.dart delete mode 100644 test/pages/not_found_page_test.dart delete mode 100644 test/pages/password_reset_test.dart delete mode 100644 test/pages/payment_page_test.dart delete mode 100644 test/pages/refund_page_test.dart delete mode 100644 test/pages/reset_protected_page_test.dart delete mode 100644 test/pages/scanner_widget_test.dart delete mode 100644 test/pages/settings_test.dart delete mode 100644 test/pages/sign_up_screen_test.dart delete mode 100644 test/pages/start_machine_page_test.dart delete mode 100644 test/pages/verify_code_page_test.dart diff --git a/lib/pages/change_email_verification.dart b/lib/pages/change_email_verification.dart deleted file mode 100644 index e357efc..0000000 --- a/lib/pages/change_email_verification.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'dart:async'; -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:flutter/material.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:app_links/app_links.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; - -class ChangeEmailVerificationPage extends StatefulWidget { - - final AppLinks appLinks; - - const ChangeEmailVerificationPage({super.key,required this.appLinks}); - - @override - State createState() => - _ChangeEmailVerificationPageState(); -} - -class _ChangeEmailVerificationPageState - extends State { - late final StreamSubscription? _linkSub; - final authService = GetIt.instance(); - - @override - void initState() { - super.initState(); - - _linkSub = widget.appLinks.uriLinkStream.listen((Uri? uri) async { - if (uri != null && - uri.scheme == 'clean-stream' && - uri.host == 'change-email') { - await authService.refreshSession(); - await authService.getCurrentUser(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - context.go('/editProfile'); - } - }); - } - }); - } - - @override - void dispose() { - _linkSub?.cancel(); - super.dispose(); - } - - Widget resendVerification = const Text( - 'Resend Verification', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.blue, decoration: TextDecoration.underline), - ); - - bool resent = false; - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - - body: Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.email, size: 80, color: Colors.blueAccent), - const SizedBox(height: 24), - Text( - 'Please verify your new email address', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 18, - color: Theme.of(context).colorScheme.fontInverted, - ), - ), - const SizedBox(height: 16), - Text( - 'Check your new email\'s inbox and click the verification link.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.fontSecondary, - ), - ), - const SizedBox(height: 24), - InkWell( - onTap: () async { - if (resent == false) { - final result = await authService.resendVerification(); - - setState(() { - if (result == AuthenticationResponses.success) { - resendVerification = Icon( - Icons.check_circle, - size: 40, - color: Colors.green, - ); - resent = true; - } else { - resendVerification = Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - child: const Center( - child: Icon( - Icons.close, - color: Colors.white, - size: 40, - ), - ), - ), - const SizedBox(height: 16), - Text( - 'Please resend verification again at another time.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 16, - color: Theme.of( - context, - ).colorScheme.fontPrimary, - ), - ), - ], - ), - ); - } - }); - } - }, - child: resendVerification, - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/pages/edit_profile_page.dart b/lib/pages/edit_profile_page.dart deleted file mode 100644 index 959fd78..0000000 --- a/lib/pages/edit_profile_page.dart +++ /dev/null @@ -1,728 +0,0 @@ -import 'dart:async'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; -import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:flutter/services.dart'; - -class EditProfilePage extends StatefulWidget { - const EditProfilePage({super.key}); - - @override - State createState() => _EditProfilePageState(); -} - -class _EditProfilePageState extends State { - final _formKey = GlobalKey(); - late final StreamSubscription _authSub; - - final TextEditingController _nameController = TextEditingController(text: ''); - final TextEditingController _emailController = TextEditingController( - text: '', - ); - final profileService = GetIt.instance(); - final authService = GetIt.instance(); - final edgeFunctionService = GetIt.instance(); - - String currentName = ''; - String currentEmail = ''; - bool _isLoading = true; - bool _isSaving = false; - - @override - void initState() { - super.initState(); - _loadUserData(); - _authSub = authService.onAuthChange.listen((_) { - _loadUserData(); - }); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _loadUserData(); - } - - void _loadUserData() async { - try { - final userId = await authService.getCurrentUserId; - - if (userId == null) { - if (mounted) { - _showErrorDialog( - 'Unable to load user data. Please try logging in again.', - ); - context.go('/login'); - } - return; - } - - final username = await profileService.getUserNameById(userId); - final email = await authService.getCurrentUserEmail(); - - if (!mounted) return; - - if (username == null || email == null) { - _showErrorDialog( - 'Unable to load profile information. Some data may be missing.', - ); - } - - setState(() { - currentName = username ?? ''; - currentEmail = email ?? ''; - _nameController.text = currentName; - _emailController.text = currentEmail; - _isLoading = false; - }); - } catch (e) { - if (mounted) { - setState(() { - _isLoading = false; - }); - _showErrorDialog('Failed to load profile data: ${e.toString()}'); - } - } - } - - @override - void dispose() { - _nameController.dispose(); - _emailController.dispose(); - _authSub.cancel(); - super.dispose(); - } - - void _onSavePressed() async { - if (_isSaving) return; - final newName = _nameController.text.trim(); - final newEmail = _emailController.text.trim(); - - final nameChanged = newName != currentName; - final emailChanged = newEmail != currentEmail; - - if (!nameChanged && !emailChanged) { - statusDialog( - context, - title: "No Changes", - message: "You haven't changed anything.", - isSuccess: false, - ); - return; - } - - final confirmed = await _confirmSaveChanges(); - if (!confirmed) return; - - if (!_formKey.currentState!.validate()) return; - - setState(() => _isSaving = true); - - try { - await authService.updateUserAttributes( - email: emailChanged ? newEmail : null, - data: nameChanged ? {'full_name': newName} : null, - ); - - if (!mounted) return; - - // EMAIL CHANGE - if (emailChanged) { - context.go('/change-email-verification'); - return; - } - - // NAME ONLY - setState(() { - currentName = newName; - currentEmail = newEmail; - }); - - statusDialog( - context, - title: "Profile Updated", - message: "Your information has been updated successfully.", - isSuccess: true, - ); - } catch (e) { - if (!mounted) return; - - statusDialog( - context, - title: "Update Failed", - message: e.toString(), - isSuccess: false, - ); - } finally { - if (mounted) { - setState(() => _isSaving = false); - } - } - } - - void _showErrorDialog(String message) { - showDialog( - context: context, - builder: (context) => AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - title: Text( - 'Error', - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - fontWeight: FontWeight.bold, - ), - ), - content: Text( - message, - style: TextStyle(color: Theme.of(context).colorScheme.fontSecondary), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text( - 'OK', - style: TextStyle(color: Theme.of(context).colorScheme.primary), - ), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Colors.transparent, - flexibleSpace: Container( - decoration: BoxDecoration( - gradient: Theme.of(context).colorScheme.primaryGradient, - ), - ), - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () { - context.go("/settings"); - }, - ), - title: const Text( - "Edit Profile", - style: TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w600, - ), - ), - centerTitle: true, - elevation: 0, - ), - body: _isLoading - ? Center( - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.primary, - ), - ) - : SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 24, - ), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Name Section - _buildSectionHeader('Full Name'), - const SizedBox(height: 12), - - _buildInfoCard( - label: 'Current', - value: currentName.isNotEmpty ? currentName : 'Not set', - icon: Icons.badge_outlined, - ), - - const SizedBox(height: 16), - - TextFormField( - controller: _nameController, - enabled: !_isSaving, - inputFormatters: [ - LengthLimitingTextInputFormatter(36), - FilteringTextInputFormatter.allow( - RegExp(r'[a-zA-Z0-9 ]'), - ), - ], - maxLength: 36, - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - fontSize: 16, - ), - decoration: InputDecoration( - labelText: 'New Full Name', - hintText: 'Enter your full name', - hintStyle: TextStyle( - color: Theme.of( - context, - ).colorScheme.fontSecondary.withValues(alpha: 0.5), - ), - labelStyle: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontSize: 14, - ), - counterStyle: TextStyle( - color: Theme.of( - context, - ).colorScheme.fontSecondary.withValues(alpha: 0.6), - ), - filled: true, - fillColor: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.03), - contentPadding: const EdgeInsets.symmetric( - vertical: 16, - horizontal: 16, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, - width: 2.0, - ), - borderRadius: BorderRadius.circular(14), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontSecondary - .withValues(alpha: 0.2), - ), - borderRadius: BorderRadius.circular(14), - ), - prefixIcon: Icon( - Icons.person_outline, - color: Theme.of(context).colorScheme.primary, - ), - ), - textInputAction: TextInputAction.next, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Name cannot be empty'; - } - return null; - }, - ), - - const SizedBox(height: 10), - - // Email Section - _buildSectionHeader('Email Address'), - const SizedBox(height: 12), - - _buildInfoCard( - label: 'Current', - value: currentEmail.isNotEmpty - ? currentEmail - : 'Not set', - icon: Icons.email_outlined, - ), - - const SizedBox(height: 16), - - TextFormField( - controller: _emailController, - enabled: !_isSaving, - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - fontSize: 16, - ), - decoration: InputDecoration( - labelText: 'New Email', - hintText: 'Enter your email address', - hintStyle: TextStyle( - color: Theme.of( - context, - ).colorScheme.fontSecondary.withValues(alpha: 0.5), - ), - labelStyle: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontSize: 14, - ), - filled: true, - fillColor: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.03), - contentPadding: const EdgeInsets.symmetric( - vertical: 16, - horizontal: 16, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, - width: 2.0, - ), - borderRadius: BorderRadius.circular(14), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontSecondary - .withValues(alpha: 0.2), - ), - borderRadius: BorderRadius.circular(14), - ), - prefixIcon: Icon( - Icons.email_outlined, - color: Theme.of(context).colorScheme.primary, - ), - ), - keyboardType: TextInputType.emailAddress, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Email cannot be empty'; - } - if (!value.trim().contains("@")) { - return 'Please enter a valid email'; - } - return null; - }, - ), - - const SizedBox(height: 20), - - // Save Button - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of( - context, - ).colorScheme.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - onPressed: _isSaving ? null : _onSavePressed, - child: _isSaving - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.check_circle_outline, size: 20), - SizedBox(width: 8), - Text( - 'Save Changes', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - - const SizedBox(height: 30), - - // Delete Account Section - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: Colors.red.withValues(alpha: 0.2), - width: 1, - ), - ), - child: Column( - children: [ - Row( - children: [ - Icon( - Icons.warning_amber_rounded, - color: Colors.red, - size: 20, - ), - const SizedBox(width: 8), - Text( - 'Danger Zone', - style: TextStyle( - color: Colors.red, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - const SizedBox(height: 12), - Text( - 'Once you delete your account, there is no going back. Any loyalty points will be permanently lost.', - style: TextStyle( - color: Theme.of(context) - .colorScheme - .fontSecondary - .withValues(alpha: 0.7), - fontSize: 13, - ), - ), - const SizedBox(height: 16), - OutlinedButton( - onPressed: _isSaving ? null : _deleteAccount, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.red, - side: const BorderSide( - color: Colors.red, - width: 1.5, - ), - padding: const EdgeInsets.symmetric( - vertical: 12, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - child: _isSaving - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.red, - ), - ) - : Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: const [ - Icon(Icons.delete_outline, size: 18), - SizedBox(width: 8), - Text( - 'Delete Account', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), - ), - - const SizedBox(height: 24), - ], - ), - ), - ), - ), - ); - } - - Widget _buildSectionHeader(String title) { - return Text( - title, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.fontSecondary, - ), - ); - } - - Widget _buildInfoCard({ - required String label, - required String value, - required IconData icon, - }) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2), - width: 1, - ), - ), - child: Row( - children: [ - Icon(icon, color: Theme.of(context).colorScheme.primary, size: 20), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.fontSecondary.withValues(alpha: 0.6), - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - value, - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.fontSecondary, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ], - ), - ); - } - - void _deleteAccount() async { - bool confirm = await _confirmDeleteAccount(); - if (confirm) { - String? userId = authService.getCurrentUserId; - final response = await edgeFunctionService.runEdgeFunction( - name: "delete-account", - body: {"user_id": userId}, - ); - if (response!.status == 200) { - statusDialog( - context, - title: "Account Deleted", - message: "Your account has been deleted successfully.", - isSuccess: true, - ); - await authService.logout(); - context.go("/login"); - } else { - statusDialog( - context, - title: "Error", - message: "An error occurred, please try again later.", - isSuccess: false, - ); - return; - } - } else { - return; - } - } - - Future _confirmDeleteAccount() async { - return await showDialog( - context: context, - builder: (context) => AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - title: Row( - children: [ - Icon(Icons.warning_amber_rounded, color: Colors.red), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Delete Account?', - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - content: Text( - 'Are you sure you want to delete your account? Any money on your loyalty card will be lost. This action cannot be undone.', - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text( - 'Cancel', - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - ), - ), - ), - ElevatedButton( - onPressed: () => Navigator.of(context).pop(true), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - ), - child: const Text('Delete'), - ), - ], - ), - ) ?? - false; - } - - Future _confirmSaveChanges() async { - return await showDialog( - context: context, - builder: (context) => AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - title: Text( - 'Confirm Changes', - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - fontWeight: FontWeight.bold, - ), - ), - content: Text( - 'Are you sure you want to save these changes to your profile?', - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text( - 'Cancel', - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - ), - ), - ), - ElevatedButton( - onPressed: () => Navigator.of(context).pop(true), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Colors.white, - ), - child: const Text('Save'), - ), - ], - ), - ) ?? - false; - } -} diff --git a/lib/pages/email_verification_page.dart b/lib/pages/email_verification_page.dart deleted file mode 100644 index 407bb45..0000000 --- a/lib/pages/email_verification_page.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'dart:async'; -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:flutter/material.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:app_links/app_links.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; - -class EmailVerificationPage extends StatefulWidget { - - final AppLinks appLinks; - - EmailVerificationPage({super.key,required this.appLinks}) {} - - @override - State createState() => _EmailVerificationPageState(); -} - -class _EmailVerificationPageState extends State { - late final StreamSubscription? _linkSub; - final authService = GetIt.instance(); - - @override - void initState() { - super.initState(); - - //Checks for if application has been updated - authService.onAuthChange.listen((isLoggedIn) { - if (isLoggedIn && authService.isEmailVerified()) { - context.go("/homePage"); - } - }); - - // Handles app links - _linkSub = widget.appLinks.uriLinkStream.listen((Uri? uri) async { - if (uri != null && - uri.scheme == 'clean-stream' && - uri.host == 'email-verification') { - await authService.getSessionFromURI(uri); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - context.go('/homePage'); - } - }); - } - }); - } - - @override - void dispose() { - _linkSub?.cancel(); - super.dispose(); - } - - Widget resendVerification = const Text( - 'Resend Verification', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.blue, decoration: TextDecoration.underline), - ); - - bool resent = false; - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - - body: Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.email, size: 80, color: Colors.blueAccent), - const SizedBox(height: 24), - Text( - 'Please verify your email address', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 18, - color: Theme.of(context).colorScheme.fontInverted, - ), - ), - const SizedBox(height: 16), - Text( - 'Check your inbox and click the verification link.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.fontSecondary, - ), - ), - const SizedBox(height: 24), - InkWell( - onTap: () async { - if (resent == false) { - final result = await authService.resendVerification(); - - setState(() { - if (result == AuthenticationResponses.success) { - resendVerification = Icon( - Icons.check_circle, - size: 40, - color: Colors.green, - ); - resent = true; - } else { - resendVerification = Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - child: const Center( - child: Icon( - Icons.close, - color: Colors.white, - size: 40, - ), - ), - ), - const SizedBox(height: 16), - Text( - 'Please resend verification again at another time.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 16, - color: Theme.of( - context, - ).colorScheme.fontPrimary, - ), - ), - ], - ), - ); - } - }); - } - }, - child: resendVerification, - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart deleted file mode 100644 index e3a59eb..0000000 --- a/lib/pages/home_page.dart +++ /dev/null @@ -1,545 +0,0 @@ -import 'dart:async'; -import 'package:clean_stream_laundry_app/logic/parsing/location_parser.dart'; -import 'package:clean_stream_laundry_app/widgets/base_page.dart'; -import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; -import 'package:clean_stream_laundry_app/middleware/storage_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class HomePage extends StatefulWidget { - const HomePage({super.key}); - static const pageKey = Key("home_page"); - - @override - HomePageState createState() => HomePageState(); -} - -class HomePageState extends State { - String? selectedName; - String? username; - Map? balance; - late final Map locationID = {}; - late final Map locationCoordinates = {}; - bool locationSelected = false; - late int? locationIDSelected; - late StorageService storage; - late final MapController _mapController; - - final authService = GetIt.instance(); - final profileService = GetIt.instance(); - - @override - void initState() { - super.initState(); - _mapController = MapController(); - _initStorage(); - _loadUserData(); - } - - @override - void dispose() { - _mapController.dispose(); - super.dispose(); - } - - Future _initStorage() async { - storage = StorageService(); - await storage.init(); - - String? lastVal = await storage.getValue("lastSelectedLocation"); - setState(() { - selectedName = lastVal; - }); - } - - void _zoomToLocation(String locationName) { - if (locationCoordinates.containsKey(locationName)) { - final coords = locationCoordinates[locationName]!; - _mapController.move(coords, 15.0); - } - } - - Future _openDirectionsFromAddress(String? address) async { - if (address == null) return; - - final encodedAddress = Uri.encodeComponent(address); - Uri uri; - - if (kIsWeb) { - // Web fallback - uri = Uri.parse( - 'https://www.google.com/maps/dir/?api=1&destination=$encodedAddress', - ); - } else { - switch (defaultTargetPlatform) { - case TargetPlatform.android: - uri = Uri.parse('google.navigation:q=$encodedAddress'); - break; - case TargetPlatform.iOS: - uri = Uri.parse( - 'http://maps.apple.com/?daddr=$encodedAddress&dirflg=d', - ); - break; - default: - uri = Uri.parse( - 'https://www.google.com/maps/dir/?api=1&destination=$encodedAddress', - ); - } - } - - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } else { - throw 'Could not open maps'; - } - } - - void _loadUserData() async { - final userId = authService.getCurrentUserId; - if (userId == null) return; - - final loadedUsername = await profileService.getUserNameById(userId); - final loadedBalance = await profileService.getUserBalanceById(userId); - - if (mounted) { - setState(() { - username = loadedUsername; - balance = loadedBalance; - }); - } - } - - final machineService = GetIt.instance(); - final locationService = GetIt.instance(); - final locationParser = LocationParser(); - - @override - Widget build(BuildContext context) { - return BasePage( - key: HomePage.pageKey, - body: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 2.0), - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - username == null ? "Welcome!" : "Welcome $username!", - - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 28, - color: Theme.of(context).colorScheme.fontInverted, - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: Text( - 'Current balance: \$${balance?["balance"] != null ? (balance!["balance"] as num).toStringAsFixed(2) : 'Loading...'}', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, - color: Theme.of(context).colorScheme.fontInverted, - ), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 12), - InkWell( - onTap: () async { - final locations = await locationService.getLocations(); - final nearest = await locationParser.getNearestLocation( - locations, - ); - - if (nearest != null) { - final address = nearest["Address"] as String; - setState(() { - selectedName = address; - locationSelected = true; - locationIDSelected = locationID[address]; - }); - storage.setValue("lastSelectedLocation", address); - _zoomToLocation(address); - } - }, - borderRadius: BorderRadius.circular(14), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "Nearest Location", - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - decoration: TextDecoration.underline, - decorationColor: Theme.of( - context, - ).colorScheme.primary, - ), - ), - const SizedBox(width: 6), - SvgPicture.asset( - "assets/locationPin.svg", - width: 24, - height: 24, - colorFilter: ColorFilter.mode( - Theme.of(context).colorScheme.primary, - BlendMode.srcIn, - ), - ), - ], - ), - ), - ), - ], - ), - const SizedBox(height: 10), - - FutureBuilder( - future: locationService.getLocations(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return Container( - height: 300, - width: 400, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: Colors.grey.shade400, - width: 1, - ), - ), - child: Center(child: CircularProgressIndicator()), - ); - } - - final locations = snapshot.data ?? []; - final markers = LocationParser.parseLocations(locations); - - for (var location in locations) { - if (location["Address"] != null && - location["Latitude"] != null && - location["Longitude"] != null) { - locationCoordinates[location["Address"]] = LatLng( - location["Latitude"], - location["Longitude"], - ); - } - } - - LatLng initialCenter = LatLng(40.273502, -86.126976); - double initialZoom = 7.2; - - return Container( - height: 300, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all(color: Colors.grey.shade400, width: 1), - ), - clipBehavior: Clip.antiAlias, - child: FlutterMap( - mapController: _mapController, - options: MapOptions( - initialCenter: initialCenter, - initialZoom: initialZoom, - keepAlive: true, - maxZoom: 15, - ), - children: [ - TileLayer( - urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: - 'https://cleanstreamlaundry.com/', - tileProvider: NetworkTileProvider(), - ), - MarkerLayer(markers: markers), - ], - ), - ); - }, - ), - Container( - margin: EdgeInsets.only(top: 12), - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all(color: Colors.grey.shade400, width: 1), - color: Theme.of(context).colorScheme.cardSecondary, - ), - child: Row( - children: [ - Icon(Icons.location_on, color: Colors.blue, size: 24), - SizedBox(width: 8), - Expanded( - child: FutureBuilder( - future: locationService.getLocations(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const SizedBox( - height: 24, - child: Center(child: CircularProgressIndicator(strokeWidth: 2)), - ); - } - - final data = snapshot.data!; - for (var item in data) { - locationID[item["Address"]] = item["id"]; - } - - if (selectedName != null && - locationID.containsKey(selectedName!) && - !locationSelected) { - WidgetsBinding.instance.addPostFrameCallback((_) { - setState(() { - locationSelected = true; - locationIDSelected = locationID[selectedName!]; - }); - _zoomToLocation(selectedName!); - }); - } - - return GestureDetector( - onTap: () { - showModalBottomSheet( - context: context, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (_) => ListView.separated( - padding: EdgeInsets.symmetric(vertical: 12), - itemCount: data.length, - separatorBuilder: (_, __) => Divider(height: 1), - itemBuilder: (_, index) { - final item = data[index]; - return ListTile( - title: Text( - item["Address"], - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.fontInverted, - ), - ), - onTap: () { - setState(() { - selectedName = item["Address"]; - locationSelected = true; - locationIDSelected = item["id"]; - }); - storage.setValue("lastSelectedLocation", selectedName!); - _zoomToLocation(selectedName!); - Navigator.pop(context); - }, - ); - }, - ), - ); - }, - child: Container( - padding: EdgeInsets.symmetric(vertical: 4), - child: Text( - selectedName ?? "Select Location", - style: TextStyle( - fontSize: 16, - color: selectedName == null - ? Colors.grey - : Theme.of(context).colorScheme.fontInverted, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ); - }, - ), - ), - IconButton( - onPressed: () async { - if (selectedName != null) { - await _openDirectionsFromAddress(selectedName); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Please select a location to get directions!")), - ); - } - }, - icon: Icon(Icons.navigation, color: Theme.of(context).primaryColor, size: 24), - padding: EdgeInsets.zero, // remove extra padding - constraints: BoxConstraints(), - ), - ], - ), - ), - - SizedBox(height: 14), - - if (locationSelected) - FutureBuilder( - future: Future.wait([ - machineService.getWasherCountByLocation( - locationIDSelected.toString(), - ), - machineService.getIdleWasherCountByLocation( - locationIDSelected.toString(), - ), - machineService.getDryerCountByLocation( - locationIDSelected.toString(), - ), - machineService.getIdleDryerCountByLocation( - locationIDSelected.toString(), - ), - ]), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const CircularProgressIndicator(); - } - - final totalWashers = snapshot.data![0]; - final idleWashers = snapshot.data![1]; - final totalDryers = snapshot.data![2]; - final idleDryers = snapshot.data![3]; - - return Container( - width: double.infinity, - decoration: BoxDecoration( - border: Border.all(color: Colors.blue, width: 3), - borderRadius: BorderRadius.circular(14), - color: Colors.transparent, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - height: 45, - alignment: Alignment.center, - decoration: const BoxDecoration( - border: Border( - bottom: BorderSide( - color: Colors.blue, - width: 2, - ), - ), - ), - child: Text( - "Availability", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.fontSecondary, - fontSize: 22, - fontWeight: FontWeight.bold, - ), - ), - ), - SizedBox( - height: 80, - child: Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4.0, - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Flexible( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - "$idleWashers/$totalWashers Washers", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.fontSecondary, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - const SizedBox(width: 8), - const Icon( - Icons.local_laundry_service, - color: Colors.blue, - size: 36, - ), - ], - ), - ), - ), - - Container(width: 2, color: Colors.blue), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4.0, - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Flexible( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - "$idleDryers/$totalDryers Dryers", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.fontSecondary, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - const SizedBox(width: 8), - const Icon( - Icons.local_laundry_service, - color: Colors.blue, - size: 36, - ), - ], - ), - ), - ), - ], - ), - ), - ], - ), - ); - }, - ), - - SizedBox(height: 12,) - - ], - ), - ), - ), - ); - } -} \ No newline at end of file diff --git a/lib/pages/loading_page.dart b/lib/pages/loading_page.dart deleted file mode 100644 index 74d96a2..0000000 --- a/lib/pages/loading_page.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:app_links/app_links.dart'; - -class LoadingPage extends StatefulWidget { - const LoadingPage({super.key}); - - @override - State createState() => _LoadingPageState(); -} - -class _LoadingPageState extends State { - String? _error; - double begin = 0.95; - double end = 1.05; - - final authService = GetIt.instance(); - - @override - void initState() { - super.initState(); - _automaticLogIn(); - _coldStartRedirect(); - } - - Future _coldStartRedirect() async { - try { - final AppLinks appLinks = AppLinks(); - final Uri? initialUri = await appLinks.getInitialAppLink(); - - if (initialUri != null && - initialUri.scheme == 'clean-stream' && - initialUri.host == 'reset-protected') { - context.go('/reset-protected', extra: initialUri); - } - - if (initialUri != null && - initialUri.scheme == 'clean-stream' && - initialUri.host == 'email-verification') { - context.go("/homePage"); - } else if (initialUri != null && initialUri.host == 'change-email') { - - context.go("/email-verification"); - } else if (initialUri != null && - initialUri.scheme == 'clean-stream' && - initialUri.host == 'oauth') { - await authService.getSessionFromURI(initialUri); - if (await authService.isLoggedIn() == AuthenticationResponses.success) { - context.go("/homePage"); - } else { - context.go("/login"); - } - } - } catch (e) {} - } - - @override - void dispose() { - super.dispose(); - } - - Future _automaticLogIn() async { - await Future.delayed(Duration.zero); - - try { - if (await authService.isLoggedIn() == AuthenticationResponses.success) { - if (!mounted) return; - context.go("/homePage"); - } else { - if (!mounted) return; - context.go("/login"); - } - } catch (e) { - if (!mounted) return; - setState(() => _error = e.toString()); - } - } - - @override - Widget build(BuildContext context) { - return Center( - child: _error != null - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, color: Colors.redAccent, size: 80), - const SizedBox(height: 20), - Text( - 'Authentication Failed', - style: const TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - Text( - _error!, - style: TextStyle( - color: Colors.redAccent.withValues(alpha: 0.8), - fontSize: 14, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 26), - ElevatedButton.icon( - onPressed: () => context.go("/login"), - icon: const Icon(Icons.login), - label: const Text('Return to Login'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, - ), - ), - ), - ], - ) - : TweenAnimationBuilder( - tween: Tween(begin: begin, end: end), - duration: const Duration(seconds: 1), - curve: Curves.easeInOut, - builder: (context, scale, child) { - return Transform.scale(scale: scale, child: child); - }, - child: Image.asset("assets/Logo.png", height: 250), - onEnd: () { - setState(() { - double temp = begin; - begin = end; - end = temp; - }); - }, - ), - ); - } -} \ No newline at end of file diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart deleted file mode 100644 index 0b3980e..0000000 --- a/lib/pages/login_page.dart +++ /dev/null @@ -1,340 +0,0 @@ -import 'dart:async'; - -import 'package:app_links/app_links.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; - -class LoginScreen extends StatefulWidget { - final AppLinks appLinks; - - const LoginScreen({super.key, required this.appLinks}); - - @override - State createState() => _LoginScreenState(); -} - -class _LoginScreenState extends State { - final TextEditingController _emailCtrl = TextEditingController(); - final TextEditingController _passwordCtrl = TextEditingController(); - final ScrollController _scrollCtrl = ScrollController(); - var passwordText = "Password"; - var emailText = "Email"; - var iconColor = Colors.blue; - var enabledBorderColor = Colors.grey; - var focusedBorderColor = Colors.blue; - var borderColor = Colors.blue; - var labelColor = Colors.blue; - - bool _obscurePassword = true; - - final authService = GetIt.instance(); - final profileService = GetIt.instance(); - late final StreamSubscription _listener; - - final FocusNode _focusNode = FocusNode(); - - @override - void initState() { - super.initState(); - //Sets up a listener for when the app is already running - //final appLinks = AppLinks(); - _listener = widget.appLinks.uriLinkStream.listen((Uri? uri) async { - if (uri == null) return; - - if (uri.scheme == 'clean-stream' && uri.host == 'email-verification') { - context.go("/homePage"); - } else if (uri.scheme == 'clean-stream' && uri.host == 'oauth') { - await authService.getSessionFromURI(uri); - - if (await authService.isLoggedIn() == AuthenticationResponses.success) { - if (!mounted) return; - final currentUser = authService.getCurrentUser(); - - if (currentUser != null) { - final userId = currentUser.id; - final name = - currentUser.userMetadata?['full_name'] ?? - currentUser.userMetadata?['name'] ?? - currentUser.userMetadata?['given_name']; - - await profileService.createAccount(id: userId, name: name); - } - context.go("/homePage"); - } else { - if (!mounted) return; - context.go("/login"); - } - } - }); - } - - @override - void dispose() { - _listener.cancel(); - _emailCtrl.dispose(); - _passwordCtrl.dispose(); - _scrollCtrl.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - Future _handleLogin() async { - final email = _emailCtrl.text.trim(); - final password = _passwordCtrl.text; - - if (email.isEmpty || password.isEmpty) { - _showMessage('Please fill in both fields.'); - return; - } - - _showMessage('Logging in as $email...'); - final authResponse = await authService.login(email, password); - if (!mounted) return; - - if (authResponse == AuthenticationResponses.success) { - _showMessage('Logged in as $email'); - context.go("/homePage"); - } else if (authResponse == AuthenticationResponses.emailNotVerified) { - context.go("/email-Verification"); - } else { - _changeColors(); - } - } - - void _showMessage(String text) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); - } - - void _changeColors() { - setState(() { - passwordText = "Invalid Password or Email"; - emailText = "Invalid Password or Email"; - iconColor = Colors.red; - enabledBorderColor = Colors.red; - focusedBorderColor = Colors.red; - borderColor = Colors.red; - labelColor = Colors.red; - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - body: KeyboardListener( - focusNode: _focusNode, - autofocus: kIsWeb, - onKeyEvent: (keyEvent) { - if (keyEvent is KeyDownEvent && - keyEvent.logicalKey == LogicalKeyboardKey.enter) { - _handleLogin(); - } - }, - child: ScrollbarTheme( - data: ScrollbarThemeData( - thumbColor: WidgetStateProperty.all(Colors.blue), - ), - child: Scrollbar( - controller: _scrollCtrl, - interactive: true, - thickness: 6, - radius: const Radius.circular(8), - child: SingleChildScrollView( - controller: _scrollCtrl, - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Image.asset( - "assets/Logo.png", - height: 250, - width: 250, - key: const Key('app_logo'), - ), - TextField( - controller: _emailCtrl, - style: TextStyle( - color: Theme.of(context).colorScheme.fontInverted, - ), - decoration: InputDecoration( - labelText: emailText, - labelStyle: TextStyle(color: labelColor), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: focusedBorderColor, - width: 2.0, - ), - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: enabledBorderColor), - borderRadius: BorderRadius.circular(12), - ), - prefixIcon: Icon(Icons.email, color: iconColor), - ), - ), - - const SizedBox(height: 16), - - TextField( - controller: _passwordCtrl, - style: TextStyle( - color: Theme.of(context).colorScheme.fontInverted, - ), - decoration: InputDecoration( - labelText: passwordText, - labelStyle: TextStyle(color: labelColor), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: focusedBorderColor, - width: 2.0, - ), - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: enabledBorderColor), - borderRadius: BorderRadius.circular(12), - ), - prefixIcon: Icon(Icons.lock, color: iconColor), - suffixIcon: IconButton( - icon: Icon( - _obscurePassword - ? Icons.visibility_off - : Icons.visibility, - color: Colors.blue, - ), - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, - ), - ), - obscureText: _obscurePassword, - ), - - const SizedBox(height: 24), - - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _handleLogin, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - ), - child: const Text('Log In'), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: SizedBox( - width: double.infinity, - height: 36, - child: ElevatedButton( - onPressed: () => authService.googleSignIn(), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.grey, - padding: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset( - "assets/Google.png", - width: 16, - height: 16, - key: const Key('google_logo'), - ), - const SizedBox(width: 8), - const Text( - "Sign in with Google", - style: TextStyle(fontSize: 14), - ), - ], - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: SizedBox( - width: double.infinity, - height: 36, - child: ElevatedButton( - onPressed: () => authService.appleSignIn(), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.black, - foregroundColor: Colors.white, - padding: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon(Icons.apple, size: 16), - SizedBox(width: 8), - Text( - "Sign in with Apple", - style: TextStyle(fontSize: 14), - ), - ], - ), - ), - ), - ), - - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: InkWell( - onTap: () => context.go("/signup"), - child: const Text( - 'Create Account', - style: TextStyle( - color: Colors.blue, - decoration: TextDecoration.underline, - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: InkWell( - onTap: () => context.go("/password-reset"), - child: const Text( - 'Reset Password', - style: TextStyle( - color: Colors.blue, - decoration: TextDecoration.underline, - ), - ), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/loyalty_card_page.dart b/lib/pages/loyalty_card_page.dart deleted file mode 100644 index 535c5bf..0000000 --- a/lib/pages/loyalty_card_page.dart +++ /dev/null @@ -1,488 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; -import 'package:flutter/material.dart'; -import 'package:clean_stream_laundry_app/widgets/base_page.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import '../Logic/Theme/theme.dart'; -import 'package:clean_stream_laundry_app/features/loyalty/widgets/credit_card.dart'; -import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; -import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; - -class LoyaltyPage extends StatefulWidget { - const LoyaltyPage({super.key}); - - @override - State createState() => LoyaltyCardPage(); -} - -class LoyaltyCardPage extends State { - final viewModel = GetIt.instance(); - - @override - void initState() { - super.initState(); - viewModel.initialize(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: viewModel, - builder: (_, __) { - if (viewModel.isLoading) { - const Center(child: CircularProgressIndicator()); - } - if (viewModel.errorMessage != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _showErrorDialog(context, viewModel.errorMessage); - }); - } - - return BasePage(body: _buildContent(context)); - }, - ); - } - - Widget _buildContent(BuildContext context) { - return Column( - children: [ - const SizedBox(height: 10), - CreditCard(username: viewModel.userName ?? 'John Doe'), - const SizedBox(height: 17), - Text( - 'Loyalty Balance: \$${viewModel.userBalance?.toStringAsFixed(2) ?? '0.00'}', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 26, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.fontSecondary, - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '\$${(20 - (viewModel.userReward ?? 0)).toStringAsFixed(2)} until next reward', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.fontSecondary, - ), - ), - const SizedBox(width: 4), - IconButton( - onPressed: () => _showRewardInfoDialog(context), - icon: const Icon(Icons.info_outline), - iconSize: 18, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - color: Colors.blue, - ), - ], - ), - const SizedBox(height: 7), - ElevatedButton( - onPressed: () => _loadCard(), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - disabledBackgroundColor: Colors.grey, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - elevation: 2, - ), - child: const Text( - "Load card", - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - const SizedBox(height: 15), - Expanded(child: _transactions()), - ], - ); - } - - Widget _transactions() { - if (viewModel.recentTransactions.isEmpty) { - return Text( - "No transactions found.", - style: TextStyle(color: Theme.of(context).colorScheme.fontSecondary), - ); - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: ListView( - cacheExtent: 1000, - physics: const AlwaysScrollableScrollPhysics(), - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Transactions', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.fontSecondary, - ), - ), - TextButton.icon( - onPressed: viewModel.toggleTransactionView, - icon: Icon( - viewModel.showPastTransactions - ? Icons.expand_less - : Icons.expand_more, - color: Colors.blue, - ), - label: Text( - viewModel.showPastTransactions ? 'Show Less' : 'Show More', - style: const TextStyle(color: Colors.blue), - ), - ), - ], - ), - const SizedBox(height: 9), - ...viewModel.recentTransactions.map((transaction) { - return Card( - margin: const EdgeInsets.symmetric( - horizontal: 4.0, - vertical: 6.0, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - elevation: 4, - color: Theme.of(context).colorScheme.cardPrimary, - child: ListTile( - leading: const Icon( - Icons.receipt_long, - color: Color(0xFF2073A9), - ), - title: Text( - transaction.toString(), - style: const TextStyle(fontSize: 14, color: Colors.black87), - ), - ), - ); - }), - ], - ), - ); - } - - void _showErrorDialog(BuildContext context, String? message) { - showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - title: const Text('Error'), - content: Text(message ?? ''), - icon: Icon(Icons.error), - actions: [ - TextButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - if (message == "Failed to fetch balance") { - context.go("/scanner"); - } else { - context.go("/login"); - } - }, - child: const Text('OK'), - ), - ], - ); - }, - ); - } - - void _loadCard() { - double selectedAmount = 1.0; - - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - backgroundColor: Theme.of(context).colorScheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - title: Center( - child: Text( - "Load Loyalty Card", - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Colors.blue, - ), - ), - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - OutlinedButton( - onPressed: selectedAmount > 1.0 - ? () { - setDialogState(() { - selectedAmount = (selectedAmount - 0.25) - .clamp(1.0, 500.0); - }); - } - : null, - style: OutlinedButton.styleFrom( - side: BorderSide( - color: selectedAmount > 1.0 - ? Colors.blue - : Colors.grey, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text( - "-25¢", - style: TextStyle( - color: selectedAmount > 1.0 - ? Colors.blue - : Colors.grey, - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - ), - const SizedBox(width: 12), - Text( - "\$${selectedAmount.toStringAsFixed(2)}", - style: TextStyle( - fontSize: 36, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.fontInverted, - ), - ), - const SizedBox(width: 12), - OutlinedButton( - onPressed: selectedAmount < 500.0 - ? () { - setDialogState(() { - selectedAmount = (selectedAmount + 0.25) - .clamp(1.0, 500.0); - }); - } - : null, - style: OutlinedButton.styleFrom( - side: BorderSide( - color: selectedAmount < 500.0 - ? Colors.blue - : Colors.grey, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text( - "+25¢", - style: TextStyle( - color: selectedAmount < 500.0 - ? Colors.blue - : Colors.grey, - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [10, 15, 25].map((amount) { - return ChoiceChip( - label: Text("\$$amount"), - labelStyle: TextStyle( - color: Theme.of(context).colorScheme.fontInverted, - ), - shape: StadiumBorder( - side: BorderSide( - color: Theme.of(context).colorScheme.primary, - width: 1.5, - ), - ), - selected: selectedAmount == amount.toDouble(), - onSelected: (_) { - setDialogState(() { - selectedAmount = amount.toDouble(); - }); - }, - ); - }).toList(), - ), - const SizedBox(height: 16), - SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 6, - activeTrackColor: Colors.blue, - inactiveTrackColor: Colors.blue.withAlpha(3), - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 12, - ), - overlayShape: const RoundSliderOverlayShape( - overlayRadius: 24, - ), - tickMarkShape: const RoundSliderTickMarkShape( - tickMarkRadius: 0, - ), - ), - child: SizedBox( - width: 650, - child: Row( - children: [ - Expanded( - child: Slider( - value: selectedAmount, - min: 1, - max: 500, - onChanged: (value) { - setDialogState(() { - selectedAmount = value.roundToDouble(); - }); - }, - ), - ), - ], - ), - ), - ), - Text( - "Select an amount to add to your card.", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.fontInverted.withValues(alpha: 0.7), - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - }, - child: Text( - "Cancel", - style: TextStyle(color: Colors.blue[700]), - ), - ), - ElevatedButton( - onPressed: () async { - Navigator.of(dialogContext).pop(); - _handlePayment(selectedAmount); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: const Text( - "Pay", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - ], - ); - }, - ); - }, - ); - } - - void _handlePayment(double amount) async { - final result = await viewModel.loadCard(amount); - - if (!mounted) { - return; - } - - if (result == PaymentResult.success) { - viewModel.fetchTransactions(); - - statusDialog( - context, - title: "Payment Successful!", - message: - "Thank you! Your payment of \$${amount.toStringAsFixed(2)} was processed successfully.", - isSuccess: true, - ); - } else if (result == PaymentResult.canceled) { - statusDialog( - context, - title: "Payment Canceled", - message: "Payment of \$${amount.toStringAsFixed(2)} was canceled.", - isSuccess: false, - ); - } else { - statusDialog( - context, - title: "Payment Failed", - message: - "An error occurred while processing your payment. Please try again.", - isSuccess: false, - ); - } - } - - void _showRewardInfoDialog(BuildContext context) { - showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - title: const Text('Rewards program'), - content: const Text( - 'For every \$20 you spend, you get an extra \$5 automatically added to your loyalty balance.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('Got it'), - ), - ], - ); - }, - ); - } -} diff --git a/lib/pages/monthly_transaction_history.dart b/lib/pages/monthly_transaction_history.dart deleted file mode 100644 index 4a9b75d..0000000 --- a/lib/pages/monthly_transaction_history.dart +++ /dev/null @@ -1,276 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:clean_stream_laundry_app/logic/parsing/transaction_parser.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; -import 'package:go_router/go_router.dart'; - -class MonthlyTransactionHistory extends StatefulWidget { - final List> transactions; - const MonthlyTransactionHistory({super.key, required this.transactions}); - - @override - State createState() => - _MonthlyTransactionHistoryState(); -} - -class _MonthlyTransactionHistoryState extends State { - int? _selectedYear; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final cardBackgroundColor = colorScheme.cardPrimary; - final cardTextColor = - ThemeData.estimateBrightnessForColor(cardBackgroundColor) == - Brightness.dark - ? Colors.white - : Colors.black; - - final monthlySums = TransactionParser.getMonthlySums(widget.transactions); - final sortedMonths = monthlySums.keys.toList() - ..sort((a, b) { - final dateA = DateFormat('MMM yyyy').parse(a); - final dateB = DateFormat('MMM yyyy').parse(b); - return dateB.compareTo(dateA); - }); - - final availableYears = - sortedMonths - .map((month) => DateFormat('MMM yyyy').parse(month).year) - .toSet() - .toList() - ..sort((a, b) => b.compareTo(a)); - - if (availableYears.isEmpty) { - availableYears.add(DateTime.now().year); - } - - final selectedYear = - (_selectedYear != null && availableYears.contains(_selectedYear)) - ? _selectedYear! - : availableYears.first; - - final filteredMonths = sortedMonths.where((month) { - final date = DateFormat('MMM yyyy').parse(month); - return date.year == selectedYear; - }).toList(); - - Future showYearPickerSheet() async { - await showModalBottomSheet( - context: context, - backgroundColor: cardBackgroundColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (sheetContext) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text( - 'Year: $selectedYear', - style: TextStyle( - fontWeight: FontWeight.bold, - color: cardTextColor, - ), - ), - ), - const Divider(height: 1), - ...availableYears.map( - (year) => ListTile( - key: ValueKey('year-option-$year'), - title: Text( - year.toString(), - style: TextStyle(color: cardTextColor), - ), - trailing: year == selectedYear - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () { - Navigator.of(sheetContext).pop(); - if (year == selectedYear) return; - setState(() { - _selectedYear = year; - }); - }, - ), - ), - ], - ), - ), - ); - }, - ); - } - - Widget buildMonthList(List visibleMonths) { - final ScrollController scrollController = ScrollController(); - - if (visibleMonths.isEmpty) { - return const Center( - child: Text( - 'No transactions found for this time range.', - style: TextStyle(fontSize: 16), - ), - ); - } - - return Theme( - data: Theme.of(context).copyWith( - scrollbarTheme: ScrollbarThemeData( - thumbColor: WidgetStateProperty.all(colorScheme.primary), - trackColor: WidgetStateProperty.all(Colors.transparent), - thickness: WidgetStateProperty.all(8), - radius: const Radius.circular(4), - ), - ), - child: Scrollbar( - controller: scrollController, - thumbVisibility: true, - interactive: true, - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.all(16), - itemCount: visibleMonths.length, - itemBuilder: (context, index) { - final month = visibleMonths[index]; - final data = monthlySums[month]!; - final total = - data['directWasher']! + - data['directDryer']! + - data['loyaltyCard']!; - if (total == 0 && - data['loyaltyWasher'] == 0 && - data['loyaltyDryer'] == 0) { - return const SizedBox(width: 0, height: 0); - } else { - return Card( - margin: const EdgeInsets.only(bottom: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - elevation: 2, - color: cardBackgroundColor, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - month, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: cardTextColor, - ), - ), - Text( - '\$${total.toStringAsFixed(2)}', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: cardTextColor, - ), - ), - ], - ), - const Divider(height: 24), - _buildTransactionRow( - 'Direct Washer Payments', - data['directWasher']!, - cardTextColor, - ), - const SizedBox(height: 8), - _buildTransactionRow( - 'Loyalty Washer Payments', - data['loyaltyWasher']!, - colorScheme.primary, - ), - const SizedBox(height: 8), - _buildTransactionRow( - 'Direct Dryer Payments', - data['directDryer']!, - cardTextColor, - ), - const SizedBox(height: 8), - _buildTransactionRow( - 'Loyalty Dryer Payments', - data['loyaltyDryer']!, - colorScheme.primary, - ), - const SizedBox(height: 8), - _buildTransactionRow( - 'Loyalty Card Loads', - data['loyaltyCard']!, - cardTextColor, - ), - ], - ), - ), - ); - } - }, - ), - ), - ); - } - - return Scaffold( - appBar: AppBar( - backgroundColor: Colors.transparent, - flexibleSpace: Container( - decoration: BoxDecoration(gradient: colorScheme.primaryGradient), - ), - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => context.pop(), - ), - title: const Text( - 'Monthly Transaction History', - style: TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w600, - ), - ), - centerTitle: true, - elevation: 2, - actions: [ - IconButton( - key: const ValueKey('year-filter-button'), - onPressed: showYearPickerSheet, - icon: const Icon(Icons.filter_list, color: Colors.white), - tooltip: 'Filter by year', - ), - const SizedBox(width: 8), - ], - ), - body: buildMonthList(filteredMonths), - ); - } - - Widget _buildTransactionRow(String label, double amount, Color color) { - return Row( - children: [ - const SizedBox(width: 12), - Expanded( - child: Text(label, style: TextStyle(fontSize: 16, color: color)), - ), - Text( - '\$${amount.toStringAsFixed(2)}', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: color, - ), - ), - ], - ); - } -} diff --git a/lib/pages/not_found_page.dart b/lib/pages/not_found_page.dart deleted file mode 100644 index 69d91cb..0000000 --- a/lib/pages/not_found_page.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; - -class NotFoundScreen extends StatelessWidget { - const NotFoundScreen({super.key}); - - @override - Widget build(BuildContext context) { - - final authService = GetIt.instance(); - - return Container( - color: Colors.white, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.error_outline, color: Colors.redAccent, size: 80), - const SizedBox(height: 20), - const Text( - '404 - Page Not Found', - style: TextStyle( - fontSize: 24, - color: Colors.blue, - ), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () async { - if (await authService.isLoggedIn() == AuthenticationResponses.success){ - context.go("/homePage"); - }else{ - context.go("/login"); - } - }, - child: const Text("Go to Home"), - ), - ], - ), - ), - ); - } -} \ No newline at end of file diff --git a/lib/pages/password_reset.dart b/lib/pages/password_reset.dart deleted file mode 100644 index 5a08f7c..0000000 --- a/lib/pages/password_reset.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:go_router/go_router.dart'; -import 'package:get_it/get_it.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; - -class PasswordResetPage extends StatefulWidget { - const PasswordResetPage({super.key}); - - @override - State createState() => _PasswordResetPageState(); -} - -class _PasswordResetPageState extends State { - final TextEditingController _emailController = TextEditingController(); - final _formKey = GlobalKey(); - bool _isLoading = false; - - final authService = GetIt.instance(); - - @override - void dispose() { - _emailController.dispose(); - super.dispose(); - } - - void _showMessage(String text) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); - } - - Future _sendResetEmail() async { - if (_formKey.currentState!.validate()) { - setState(() { - _isLoading = true; - }); - - try { - final response = await authService.resetPassword( - _emailController.text.trim(), - ); - - setState(() { - _isLoading = false; - }); - - if (response == AuthenticationResponses.success) { - _showMessage('Password reset email sent! Check your email.'); - context.go("/verify-code", extra: _emailController.text.trim()); - } else { - _showMessage('Failed to send reset email.'); - } - } catch (e) { - setState(() { - _isLoading = false; - }); - _showMessage('Error: $e'); - } - } - } - - String? _validateEmail(String? value) { - if (value == null || value.isEmpty) { - return 'Please enter your email'; - } - final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); - if (!emailRegex.hasMatch(value)) { - return 'Please enter a valid email address'; - } - return null; - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Scaffold( - backgroundColor: scheme.surface, - appBar: AppBar( - backgroundColor: scheme.surface, - foregroundColor: scheme.fontInverted, - title: const Text('Reset Password'), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.go("/login"), - ), - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 32), - Icon(Icons.lock_reset, size: 80, color: scheme.primary), - const SizedBox(height: 32), - Text( - 'Forgot your password?', - style: - Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: scheme.fontInverted, - ) ?? - TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: scheme.fontInverted, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - Text( - 'Enter your email address and we\'ll send you a reset link.', - style: - Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.fontSecondary, - ) ?? - TextStyle(color: scheme.fontSecondary), - textAlign: TextAlign.center, - ), - const SizedBox(height: 40), - TextFormField( - controller: _emailController, - style: TextStyle(color: scheme.fontInverted), - keyboardType: TextInputType.emailAddress, - decoration: InputDecoration( - labelText: 'Email', - labelStyle: TextStyle(color: scheme.primary), - hintText: 'Enter your email address', - hintStyle: TextStyle(color: scheme.fontInverted.withOpacity(0.6)), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: scheme.primary, width: 2.0), - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: scheme.fontSecondary), - borderRadius: BorderRadius.circular(12), - ), - filled: true, - fillColor: scheme.surface, - prefixIcon: Icon(Icons.email, color: scheme.primary), - ), - validator: _validateEmail, - enabled: !_isLoading, - ), - const SizedBox(height: 24), - _isLoading - ? const Center(child: CircularProgressIndicator()) - : SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () { - _sendResetEmail(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: scheme.primary, - foregroundColor: scheme.onPrimary, - ), - child: const Text('Send Reset Link'), - ), - ), - const SizedBox(height: 16), - TextButton( - onPressed: _isLoading ? null : () => context.go('/login'), - child: Text( - 'Back to Login', - style: TextStyle(color: scheme.primary), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/pages/payment_page.dart b/lib/pages/payment_page.dart deleted file mode 100644 index 3b76d93..0000000 --- a/lib/pages/payment_page.dart +++ /dev/null @@ -1,413 +0,0 @@ -import 'package:clean_stream_laundry_app/widgets/base_page.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; -import 'package:flutter/material.dart'; -import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; -import 'package:get_it/get_it.dart'; -import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; -import 'package:clean_stream_laundry_app/logic/parsing/machine_parser.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; -import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; -import 'package:clean_stream_laundry_app/services/notification_service.dart'; -import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; -import 'package:go_router/go_router.dart'; -import 'package:clean_stream_laundry_app/features/machine_payment/widgets/washer_controls_card.dart'; -import 'package:clean_stream_laundry_app/features/machine_payment/widgets/dryer_controls_card.dart'; - -class PaymentPage extends StatefulWidget { - final String machineId; - - const PaymentPage({super.key, required this.machineId}); - - @override - State createState() => _PaymentPageState(); -} - -class _PaymentPageState extends State { - final bool _isConfirmed = false; - bool _paymentCompleted = false; - double? _price; - String? _machineName; - double? _userBalance; - bool _isLoading = true; - - double _basePrice = 0; - double _addedWasherCost = 0; - int _dryerMinutes = 5; - - final machineService = GetIt.instance(); - final profileService = GetIt.instance(); - final authService = GetIt.instance(); - final transactionService = GetIt.instance(); - final machineCommunicator = GetIt.instance(); - final notificationService = GetIt.instance(); - final paymentProcessor = GetIt.instance(); - - bool get _isDryer => - _machineName != null && - _machineName!.toLowerCase().contains('dryer'); - - @override - void initState() { - super.initState(); - _fetchMachineInfo(); - } - - Future _fetchMachineInfo() async { - final data = await machineService.getMachineById(widget.machineId); - final userId = authService.getCurrentUserId; - - if (userId == null) return; - - final balance = await profileService.getUserBalanceById(userId); - - if (data != null && balance != null) { - final name = data['Name'] as String?; - _basePrice = (data['Price'] as num).toDouble(); - - setState(() { - _userBalance = (balance['balance'] as num).toDouble(); - _machineName = name; - _price = _basePrice; - _isLoading = false; - }); - } else { - setState(() { - _userBalance = 0; - _machineName = 'Unknown'; - _price = 0; - _isLoading = false; - }); - } - } - - void _onDryerChanged(double price, int minutes) { - setState(() { - _price = price; - _dryerMinutes = minutes; - }); - } - - void _onWasherCycleChanged(double addedCost) { - setState(() { - _addedWasherCost = addedCost; - _price = _basePrice + _addedWasherCost; - }); - } - - Future makeNotification(String name) async { - final notificationService = GetIt.instance(); - await notificationService.scheduleEarlyMachineNotification( - id: 1, - machineTime: _isDryer - ? Duration(minutes: _dryerMinutes) - : const Duration(minutes: 5, seconds: 5), - machineName: name, - ); - } - - @override - Widget build(BuildContext context) { - return BasePage( - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : Column( - children: [ - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _buildAmountCard(), - - const SizedBox(height: 20), - - if (_isDryer) - DryerControlsCard(onChanged: _onDryerChanged) - else - WasherControlsCard( - onCycleChanged: _onWasherCycleChanged, - ), - const SizedBox(height: 30), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.all(24.0), - child: _paymentCompleted - ? _buildBackToHomeButton(context) - : _buildPaymentButtons(context), - ), - ], - ), - ); - } - - - Widget _buildAmountCard() { - return Container( - padding: const EdgeInsets.all(30), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.greyCard, - borderRadius: BorderRadius.circular(20), - ), - child: Column( - children: [ - Icon( - Icons.local_laundry_service, - size: 80, - color: Color(0xFF2073A9), - ), - const SizedBox(height: 20), - Text( - 'Machine $_machineName', - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ), - Text( - _paymentCompleted ? 'Payment Complete' : 'Amount Due', - style: TextStyle(fontSize: 16, color: Colors.black87), - ), - Text( - '\$${_price?.toStringAsFixed(2) ?? '0.00'}', - style: TextStyle( - fontSize: 48, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ), - ], - ), - ); - } - - Widget _buildBackToHomeButton(BuildContext context) { - return SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () => context.go('/homePage'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue[700], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - elevation: 2, - padding: const EdgeInsets.symmetric(vertical: 16), - ), - child: const Text( - 'Back to Home', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ); - } - - Widget _buildPaymentButtons(BuildContext context) { - return Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: (_isConfirmed || _price == null || _price == 0) - ? null - : () async { - final success = await paymentProcessor.processPayment( - _price!, - MachineFormatter.formatMachineType( - _machineName.toString()), - ); - - if (success == PaymentResult.success) { - showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext dialogContext) => - const Center(child: CircularProgressIndicator()), - ); - - final deviceAuthorized = - await machineCommunicator.wakeDevice( - widget.machineId); - - Navigator.of(context, rootNavigator: true).pop(); - - if (deviceAuthorized) { - setState(() => _paymentCompleted = true); - await makeNotification(_machineName.toString()); - statusDialog( - context, - title: "Payment Processed! Machine Ready!", - message: "Machine $_machineName is now active.", - isSuccess: true, - ); - } else { - statusDialog( - context, - title: "Machine Error", - message: - "Payment succeeded but machine did not wake up.", - isSuccess: false, - ); - } - } else { - statusDialog( - context, - title: "Payment Failed", - message: "Your payment could not be processed.", - isSuccess: false, - ); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: - (_isConfirmed || _price == null || _price == 0) - ? Colors.grey - : Colors.blue[700], - disabledBackgroundColor: Colors.grey, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - elevation: 2, - padding: const EdgeInsets.symmetric(vertical: 16), - ), - child: _isConfirmed - ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2.5, - ), - ) - : Text( - _price != null && _price! > 0 - ? 'Pay \$${_price!.toStringAsFixed(2)}' - : 'Pay', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ), - - const SizedBox(width: 16), - - Expanded( - child: ElevatedButton( - onPressed: (_isConfirmed || - _price == null || - _price == 0 || - (_userBalance ?? 0) < (_price ?? 0)) - ? null - : () => _processLoyaltyPayment(context), - style: ElevatedButton.styleFrom( - backgroundColor: (_isConfirmed || - _price == null || - _price == 0 || - (_userBalance ?? 0) < (_price ?? 0)) - ? Colors.grey - : Colors.green[700], - disabledBackgroundColor: Colors.grey, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - elevation: 2, - padding: const EdgeInsets.symmetric(vertical: 16), - ), - child: const Text( - 'Pay with Loyalty', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ), - ], - ); - } - - void _processLoyaltyPayment(BuildContext context) async { - final userId = authService.getCurrentUserId; - - final updatedBalance = _userBalance! - _price!; - setState(() => _userBalance = updatedBalance); - - showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext dialogContext) => - const Center(child: CircularProgressIndicator()), - ); - - final deviceAuthorized = - await machineCommunicator.wakeDevice(widget.machineId); - - Navigator.of(context, rootNavigator: true).pop(); - - - if (!deviceAuthorized) { - statusDialog( - context, - title: "Machine Error", - message: "Machine did not respond. Your balance has not been charged. Please contact support.", - isSuccess: false, - ); - return; - } - - await profileService.updateBalanceById(userId!, updatedBalance); - - setState(() { - _userBalance = updatedBalance; - _paymentCompleted = true; - }); - - await transactionService.recordTransaction( - amount: _price!, - description: - "Loyalty payment on ${MachineFormatter.formatMachineType(_machineName.toString())}", - type: "laundry", - ); - - if (deviceAuthorized) { - await makeNotification(_machineName.toString()); - setState(() => _paymentCompleted = true); - statusDialog( - context, - title: "Machine Ready!", - message: "Machine $_machineName is now active.", - isSuccess: true, - ); - } else { - statusDialog( - context, - title: "Machine Error", - message: - "Payment succeeded but machine did not wake up. Please contact support", - isSuccess: false, - ); - } - - statusDialog( - context, - title: "Payment Successful!", - message: - "Thank you! \$${_price?.toStringAsFixed(2)} was taken from your Loyalty Card.", - isSuccess: true, - ); - } -} \ No newline at end of file diff --git a/lib/pages/refund_page.dart b/lib/pages/refund_page.dart deleted file mode 100644 index b6cf94b..0000000 --- a/lib/pages/refund_page.dart +++ /dev/null @@ -1,429 +0,0 @@ -import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/widgets/transactions_search_sheet.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; -import 'package:clean_stream_laundry_app/logic/parsing/transaction_parser.dart'; -import 'package:flutter/services.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; - -class RefundPage extends StatefulWidget { - const RefundPage({super.key}); - - @override - State createState() => RefundPageState(); -} - -class RefundPageState extends State { - String? selectedTransaction; - int? selectedTransactionIndex; - List recentTransactions = []; - List recentTransactionIDs = []; - final descriptionController = TextEditingController(); - final transactionService = GetIt.instance(); - final edgeFunctionService = GetIt.instance(); - final profileService = GetIt.instance(); - final authService = GetIt.instance(); - bool _attemptedSubmit = false; - final FocusNode _focusNode = FocusNode(); - bool _isLoading = false; - bool _isFetchingTransactions = true; - - @override - void initState() { - super.initState(); - _fetchTransactions(); - - descriptionController.addListener(() { - setState(() {}); - }); - } - - @override - void dispose() { - super.dispose(); - _focusNode.dispose(); - } - - Future _fetchTransactions() async { - try { - final result = await transactionService - .getRefundableTransactionsForUser(); - setState(() { - recentTransactions = result.transactions; - recentTransactionIDs = result.ids; - }); - } catch (e) { - print(e.toString()); - } finally { - if (mounted) setState(() => _isFetchingTransactions = false); - } - - //Filters out loyalty transactions - recentTransactions.removeWhere( - (transaction) => transaction.contains("added to Loyalty Card"), - ); - } - - String getTransactionID() { - return recentTransactionIDs[selectedTransactionIndex!].toString(); - } - - Future getUserName() async { - String? userId = authService.getCurrentUserId; - return profileService.getUserNameById(userId!); - } - - bool isFormValid() { - return selectedTransaction != null && - descriptionController.text.trim().isNotEmpty; - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Scaffold( - appBar: AppBar( - leading: IconButton( - onPressed: () => context.pop(), - icon: Icon(Icons.arrow_back, color: Colors.white), - ), - backgroundColor: Colors.transparent, - title: Text("Request Refund", style: TextStyle(color: Colors.white)), - centerTitle: true, - flexibleSpace: Container( - decoration: BoxDecoration( - gradient: Theme.of(context).colorScheme.primaryGradient, - ), - ), - ), - body: KeyboardListener( - focusNode: _focusNode, - autofocus: kIsWeb, - onKeyEvent: (keyEvent) { - if (keyEvent is KeyDownEvent && - keyEvent.logicalKey == LogicalKeyboardKey.enter) { - _handleRefund(); - } - }, - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header card - Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: colorScheme.primary.withOpacity(0.08), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: colorScheme.primary.withOpacity(0.2), - ), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.primary.withOpacity(0.15), - borderRadius: BorderRadius.circular(14), - ), - child: Icon( - Icons.receipt_long_rounded, - color: colorScheme.primary, - size: 28, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Submit a Refund Request", - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: colorScheme.fontInverted, - ), - ), - const SizedBox(height: 4), - Text( - "Select a transaction and describe your issue. Our team will review it shortly.", - style: TextStyle( - fontSize: 13, - color: colorScheme.fontSecondary, - ), - ), - ], - ), - ), - ], - ), - ), - - const SizedBox(height: 28), - - // Form card - Card( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildLabel("Select a Transaction"), - const SizedBox(height: 8), - _isFetchingTransactions - ? const Center(child: CircularProgressIndicator()) - : GestureDetector( - onTap: () async { - final selected = - await showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => TransactionSearchSheet( - transactions: recentTransactions, - ), - ); - - if (selected != null) { - setState(() { - selectedTransaction = selected; - selectedTransactionIndex = - recentTransactions.indexOf(selected); - }); - } - }, - child: AbsorbPointer( - child: TextFormField( - decoration: _inputDecoration(context) - .copyWith( - hintText: 'Select a transaction', - hintStyle: TextStyle( - color: colorScheme.fontSecondary, - ), - ), - controller: TextEditingController( - text: selectedTransaction, - ), - style: TextStyle( - color: colorScheme.fontInverted, - ), - ), - ), - ), - - const SizedBox(height: 24), - - _buildLabel("Reason for Refund"), - const SizedBox(height: 8), - TextField( - controller: descriptionController, - minLines: 4, - maxLines: null, - keyboardType: TextInputType.multiline, - style: TextStyle(color: colorScheme.fontInverted), - decoration: _inputDecoration(context).copyWith( - hintText: - 'Describe the issue with your transaction...', - hintStyle: TextStyle( - color: colorScheme.fontSecondary, - ), - ), - ), - - if (_attemptedSubmit && !isFormValid()) - Padding( - padding: const EdgeInsets.only(top: 12), - child: Row( - children: const [ - Icon( - Icons.info_outline, - color: Colors.red, - size: 16, - ), - SizedBox(width: 6), - Text( - 'Please fill in all fields', - style: TextStyle( - color: Colors.red, - fontSize: 13, - ), - ), - ], - ), - ), - ], - ), - ), - ), - - const SizedBox(height: 24), - - SizedBox( - width: double.infinity, - height: 52, - child: ElevatedButton( - onPressed: _isLoading - ? null - : () { - setState(() => _attemptedSubmit = true); - if (!isFormValid()) return; - _handleRefund(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: isFormValid() - ? colorScheme.primary - : Colors.grey, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - elevation: isFormValid() ? 2 : 0, - ), - child: _isLoading - ? const SizedBox( - height: 22, - width: 22, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2.5, - ), - ) - : const Text( - "Submit Refund Request", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - SizedBox(height: 12,), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: const Color(0xFFFFFDE7).withOpacity(0.8), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: const Color(0xFFF9A825).withOpacity(0.4), - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.info_outline_rounded, - size: 16, - color: Colors.black, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - "Refund requests are reviewed within 3–5 business days. " - "Approved refunds will be returned to your loyalty card balance. " - "We reserve the right to deny requests that do not meet our refund policy criteria.", - style: TextStyle( - fontSize: 12, - color: Colors.black, - height: 1.5, - ), - ), - ), - ], - ), - ), - SizedBox(height: 4,) - ], - ), - ), - ), - ); - } - - Widget _buildLabel(String text) { - return Text( - text, - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - color: Theme.of(context).colorScheme.fontInverted, - ), - ); - } - - InputDecoration _inputDecoration(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: colorScheme.fontSecondary, width: 1.5), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: colorScheme.fontSecondary, width: 1.5), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Colors.blue, width: 2), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - ); - } - - void _handleRefund() async { - setState(() => _isLoading = true); - final transactionId = getTransactionID(); - final description = descriptionController.text; - final userId = authService.getCurrentUserId; - - if (userId == null) { - print("Error: No user is logged in."); - return; - } - - final username = await getUserName(); - - final amount = await transactionService.recordRefundRequest( - transaction_id: transactionId, - description: description, - ); - - await edgeFunctionService.runEdgeFunction( - name: 'refund-email', - body: { - 'username': username, - 'user_id': userId, - 'transaction_id': transactionId, - 'amount': amount, - 'description': description, - }, - ); - - _showRefundDialog(); - if (mounted) setState(() => _isLoading = false); - } - - void _showRefundDialog() { - statusDialog( - context, - title: "Success", - message: 'Your refund request has been submitted', - isSuccess: true, - ).then((_) { - context.go("/settings"); - }); - } -} diff --git a/lib/pages/reset_protected_page.dart b/lib/pages/reset_protected_page.dart deleted file mode 100644 index 8c94797..0000000 --- a/lib/pages/reset_protected_page.dart +++ /dev/null @@ -1,316 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; - -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/parsing/password_parser.dart'; - -import '../Logic/Theme/theme.dart'; - -class ResetProtectedPage extends StatefulWidget { - const ResetProtectedPage({super.key}); - - @override - State createState() => _ResetProtectedPageState(); -} - -class _ResetProtectedPageState extends State { - - final _passwordCtrl = TextEditingController(); - final _confirmCtrl = TextEditingController(); - - final authService = GetIt.instance(); - - bool _obscurePassword = true; - bool _obscureConfirm = true; - bool _isLoading = false; - - var passwordText = "New Password"; - var confirmText = "Confirm Password"; - var iconColor; - var labelColor; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - iconColor = Theme.of(context).colorScheme.primary; - labelColor = Theme.of(context).colorScheme.primary; - - } - - void _changeColorsToRed(String reason) { - setState(() { - passwordText = reason; - confirmText = reason; - iconColor = Colors.red; - labelColor = Colors.red; - }); - } - - void _resetColors() { - setState(() { - passwordText = "New Password"; - confirmText = "Confirm Password"; - iconColor = Colors.blue; - labelColor = Colors.blue; - }); - } - - void _showMessage(String msg) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(msg)), - ); - } - - Future _submit() async { - final password = _passwordCtrl.text.trim(); - final confirm = _confirmCtrl.text.trim(); - - if (password.isEmpty || confirm.isEmpty) { - _showMessage("Please fill in all fields"); - return; - } - - if (password != confirm) { - _changeColorsToRed("Passwords don't match"); - return; - } - - final requirementError = PasswordParser.process(password); - if (requirementError != null) { - _changeColorsToRed(requirementError); - return; - } - - setState(() => _isLoading = true); - - try { - await authService.updatePassword(password); - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Password reset successful')), - ); - - context.go("/login"); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to reset password')), - ); - } finally { - if (!mounted) return; - setState(() => _isLoading = false); - } - } - - @override - void dispose() { - _passwordCtrl.dispose(); - _confirmCtrl.dispose(); - super.dispose(); - } - - InputDecoration _inputDecoration({ - required String label, - required IconData icon, - required bool obscure, - required VoidCallback toggle, - }) { - return InputDecoration( - labelText: label, - labelStyle: TextStyle(color: labelColor), - - contentPadding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ), - - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - - focusedBorder: OutlineInputBorder( - borderSide: const BorderSide(color: Colors.blue, width: 2.0), - borderRadius: BorderRadius.circular(12), - ), - - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontSecondary, - ), - borderRadius: BorderRadius.circular(12), - ), - - prefixIcon: Icon(icon, color: iconColor), - - suffixIcon: IconButton( - icon: Icon( - obscure ? Icons.visibility_off : Icons.visibility, - color: Colors.blue, - ), - onPressed: toggle, - ), - ); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Scaffold( - backgroundColor: theme.colorScheme.surface, - body: SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 20), - - //Clean Stream Logo - Image.asset( - "assets/Logo.png", - height: 250, - width: 250, - key: const Key('app_logo'), - ), - - // Title - Text( - "Reset Password", - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - - const SizedBox(height: 8), - - Text( - "Enter your new password below", - style: theme.textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - - const SizedBox(height: 30), - - // Password requirements (same style as signup) - ValueListenableBuilder( - valueListenable: _passwordCtrl, - builder: (context, value, _) { - final requirement = PasswordParser.process(value.text); - - if (requirement == null) { - return const SizedBox.shrink(); - } - - return Container( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 10, - ), - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey), - ), - child: Text( - requirement, - textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.w600, - color: Colors.grey, - ), - ), - ); - }, - ), - - // Password field - TextField( - controller: _passwordCtrl, - obscureText: _obscurePassword, - decoration: _inputDecoration( - label: passwordText, - icon: Icons.lock, - obscure: _obscurePassword, - toggle: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, - ), - onChanged: (_) { - if (iconColor == Colors.red) _resetColors(); - }, - ), - - const SizedBox(height: 16), - - // Confirm field - TextField( - controller: _confirmCtrl, - obscureText: _obscureConfirm, - decoration: _inputDecoration( - label: confirmText, - icon: Icons.lock, - obscure: _obscureConfirm, - toggle: () { - setState(() { - _obscureConfirm = !_obscureConfirm; - }); - }, - ), - onChanged: (_) { - if (_passwordCtrl.text != _confirmCtrl.text) { - _changeColorsToRed("Passwords don't match"); - } else { - _resetColors(); - } - }, - ), - - const SizedBox(height: 24), - - // Button - SizedBox( - height: 50, - child: ElevatedButton( - onPressed: _isLoading ? null : _submit, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: _isLoading - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Text( - "Reset Password", - style: TextStyle(fontSize: 16), - ), - ), - ), - - const SizedBox(height: 20), - ], - ), - ), - ), - ), - ), - ); - } -} \ No newline at end of file diff --git a/lib/pages/scanner_widget.dart b/lib/pages/scanner_widget.dart deleted file mode 100644 index da3a8aa..0000000 --- a/lib/pages/scanner_widget.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'package:clean_stream_laundry_app/widgets/base_page.dart'; -import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; -import 'package:clean_stream_laundry_app/logic/parsing/qr_parser.dart'; -import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; - -class ScannerWidget extends StatefulWidget { - const ScannerWidget({super.key}); - - @override - State createState() => _ScannerWidgetState(); -} - -class _ScannerWidgetState extends State { - - final MobileScannerController cameraController = MobileScannerController(); - final machineCommunicator = GetIt.instance(); - String? _scannedCode; - - @override - void dispose() { - cameraController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return BasePage( - body: _buildScannerCamera() - ); - } - - //---------- UI Builders ----------// - - Widget _buildScannerCamera() { - return Stack( - children: [ - MobileScanner( - controller: cameraController, - onDetect: _handleQRCode, - ), - - // Top overlay text - Positioned( - top: 0, - left: 0, - right: 0, - child: Container( - color: Colors.black54, - padding: const EdgeInsets.all(16), - child: Text( - 'Point camera at nayax QR code', - style: TextStyle( - color: Colors.grey, - fontSize: 18, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), - ), - - // Cancel button - Positioned( - bottom: 32, - left: 0, - right: 0, - child: Center( - child: FloatingActionButton.extended( - onPressed: () { - context.go("/startPage"); - }, - icon: const Icon(Icons.close), - label: const Text('Cancel'), - backgroundColor: Colors.red, - ), - ), - ), - - // Center frame - Center( - child: Container( - width: 250, - height: 250, - decoration: BoxDecoration( - border: Border.all(color: Colors.white, width: 3), - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ], - ); - } - - //---------- QR logic ----------// - - void _handleQRCode(BarcodeCapture capture) { - final List barcodes = capture.barcodes; - - for (final barcode in barcodes) { - if (barcode.rawValue != null) { - setState(() { - _scannedCode = barcode.rawValue; - }); - - QrScannerParser qrScannerController = QrScannerParser(_scannedCode!); - processNayaxCode(qrScannerController.getNayaxDeviceID()); - break; - } - } - } - - Future processNayaxCode(String? code) async { - cameraController.stop(); - String result = await machineCommunicator.checkAvailability(code!); - if (result == "pass") { - context.go('/paymentPage?machineId=$code'); - } else { - statusDialog(context, title: "Machine Unavailable", message: result, isSuccess: false); - cameraController.start(); - } - } -} \ No newline at end of file diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart deleted file mode 100644 index 4a1e0ca..0000000 --- a/lib/pages/settings.dart +++ /dev/null @@ -1,228 +0,0 @@ -import 'package:clean_stream_laundry_app/widgets/base_page.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme_manager.dart'; -import 'package:provider/provider.dart'; -import 'package:get_it/get_it.dart'; -import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; -import 'package:clean_stream_laundry_app/features/settings/widgets/settings_card.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; - -class Settings extends StatefulWidget { - static const int maxNotificationLeadTime = 30; - - @override - State createState() => _SettingsState(); -} - -class _SettingsState extends State { - final transactionService = GetIt.instance(); - final authService = GetIt.instance(); - final profileService = GetIt.instance(); - - int notificationLeadTime = 5; - bool _loadingDelay = true; - - @override - void initState() { - super.initState(); - _loadNotificationLeadTime(); - } - - Future _loadNotificationLeadTime() async { - final value = await profileService.getNotificationLeadTime(); - setState(() { - notificationLeadTime = value; - _loadingDelay = false; - }); - } - - Future _updateLeadTime(int newLeadTimeValue) async { - setState(() => notificationLeadTime = newLeadTimeValue); - await profileService.setNotificationLeadTime(newLeadTimeValue); - } - - Future _showSignOutConfirmation() async { - final shouldSignOut = await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: Text('Sign Out', - style: TextStyle(color: Theme.of(context).colorScheme.fontInverted), - ), - - content: Text('Are you sure you want to sign out?', - style: TextStyle(color: Theme.of(context).colorScheme.fontInverted), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(false); // User cancelled - }, - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () { - Navigator.of(context).pop(true); // User confirmed - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - ), - child: const Text('Sign Out'), - ), - ], - ); - }, - ); - - - if (shouldSignOut == true) { - authService.logout(); - if (mounted) { - context.go('/login'); - } - } - } - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, themeManager, child) { - return BasePage( - body: SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 24.0), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Image.asset('assets/Logo.png', width: 230, height: 230), - SettingsCard( - icon: Icons.lightbulb, - title: Theme.of(context).colorScheme.modeChangerText, - onTap: () { - themeManager.toggleTheme(); - }, - ), - const SizedBox(height: 14), - SettingsCard( - icon: Icons.money, - title: "Monthly Report", - onTap: () async { - final transactions = - await transactionService.getTransactionsForUser(); - context.push( - '/monthlyTransactionHistory', - extra: transactions, - ); - }, - ), - const SizedBox(height: 14), - SettingsCard( - icon: Icons.request_page, - title: "Request Refund", - onTap: () { - context.push('/refundPage'); - }, - ), - const SizedBox(height: 14), - SettingsCard( - icon: Icons.person, - title: "Edit Profile", - onTap: () { - context.go('/editProfile'); - }, - ), - const SizedBox(height: 14), - SettingsCard( - icon: Icons.timer, - title: "Notify Before Finish", - subtitle: - "Minutes you’re notified before machine finish", - trailing: _loadingDelay - ? const SizedBox( - height: 110, - width: 110, - child: CircularProgressIndicator(strokeWidth: 4), - ) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: - Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(6), - ), - child: IconButton( - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - icon: const Icon(Icons.add, - color: Colors.white, size: 20), - onPressed: () async { - if (notificationLeadTime < Settings.maxNotificationLeadTime) { - final newLeadTime = notificationLeadTime + 1; - await _updateLeadTime(newLeadTime); - } - }, - ), - ), - SizedBox( - width: 40, - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - (" " + "$notificationLeadTime"), - style: TextStyle( - fontSize: 18, - color: Theme.of(context).colorScheme.fontSecondary, - ), - ), - ), - ), - const SizedBox(width: 12), - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: - Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(6), - ), - child: IconButton( - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - icon: const Icon(Icons.remove, - color: Colors.white, size: 20), - onPressed: () async { - if (notificationLeadTime > 0) { - final newLeadTime = notificationLeadTime - 1; - await _updateLeadTime(newLeadTime); - } - }, - ), - ), - ], - ), - ), - const SizedBox(height: 14), - SettingsCard( - icon: Icons.logout, - title: "Sign Out", - onTap: () { - _showSignOutConfirmation(); - }, - ), - ], - ), - ), - ), - ); - }, - ); - } -} \ No newline at end of file diff --git a/lib/pages/sign_up_screen.dart b/lib/pages/sign_up_screen.dart deleted file mode 100644 index 1cff025..0000000 --- a/lib/pages/sign_up_screen.dart +++ /dev/null @@ -1,431 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; -import 'package:clean_stream_laundry_app/logic/parsing/password_parser.dart'; - -class SignUpScreen extends StatefulWidget { - @override - SignUpScreenState createState() => SignUpScreenState(); -} - -class SignUpScreenState extends State { - final TextEditingController _nameCtrl = TextEditingController(); - final TextEditingController _emailCtrl = TextEditingController(); - final TextEditingController _passwordCtrl = TextEditingController(); - final TextEditingController _passwordConfirmCtrl = TextEditingController(); - final ScrollController _scrollCtrl = ScrollController(); - bool _obscurePassword = true; - bool _obscureConfirmPassword = true; - var passwordText = "Password"; - var confirmPasswordText = "Confirm Password"; - var iconColor = Colors.blue; - var enabledBorderColor = Colors.grey; - var focusedBorderColor = Colors.blue; - var borderColor = Colors.blue; - var labelColor = Colors.blue; - bool _isLoading = false; - final authService = GetIt.instance(); - final profileService = GetIt.instance(); - - final _focusNode = FocusNode(); - - void _showMessage(String text) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); - } - - void _changeColorsToRed(String reason) { - setState(() { - passwordText = reason; - confirmPasswordText = reason; - iconColor = Colors.red; - enabledBorderColor = Colors.red; - focusedBorderColor = Colors.red; - borderColor = Colors.red; - labelColor = Colors.red; - }); - } - - void _changeColorsToDefault() { - setState(() { - passwordText = "Password"; - confirmPasswordText = "Confirm Password"; - iconColor = Colors.blue; - enabledBorderColor = Colors.grey; - focusedBorderColor = Colors.blue; - borderColor = Colors.blue; - labelColor = Colors.blue; - }); - } - - @override - void dispose() { - _emailCtrl.dispose(); - _passwordCtrl.dispose(); - _passwordConfirmCtrl.dispose(); - _scrollCtrl.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - Future _handleSignUp() async { - final name = _nameCtrl.text.trim(); - final email = _emailCtrl.text.trim(); - final password = _passwordCtrl.text; - final confirm = _passwordConfirmCtrl.text; - - if (name.isEmpty || email.isEmpty || password.isEmpty || confirm.isEmpty) { - _showMessage('Please fill in all fields.'); - return; - } - if (password != confirm) { - _showMessage('Passwords do not match.'); - return; - } - - setState(() => _isLoading = true); - - try { - final authResponse = await authService.signUp(email, password, name); - if (authResponse == AuthenticationResponses.success) { - _showMessage('Account created successfully.'); - context.go('/email-verification'); - } else if (authResponse == AuthenticationResponses.noDigit) { - _changeColorsToRed('Please include a digit'); - } else if (authResponse == AuthenticationResponses.lessThanMinLength) { - _changeColorsToRed("Password length is too short"); - } else if (authResponse == AuthenticationResponses.noSpecialCharacter) { - _changeColorsToRed("Please include a special character"); - } else if (authResponse == AuthenticationResponses.noUppercase) { - _changeColorsToRed("Please include an uppercase letter"); - } else if (authResponse == - AuthenticationResponses.invalidSpecialCharacter) { - _changeColorsToRed("Please use a different special character"); - } else { - _showMessage('Sign-up failed. Try again.'); - } - } catch (e) { - _showMessage('Error: $e'); - } finally { - setState(() => _isLoading = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - body: KeyboardListener( - focusNode: _focusNode, - autofocus: kIsWeb, - onKeyEvent: (keyEvent) { - if (keyEvent is KeyDownEvent && - keyEvent.logicalKey == LogicalKeyboardKey.enter) { - _handleSignUp(); - } - }, - child: ScrollbarTheme( - data: ScrollbarThemeData( - thumbColor: MaterialStateProperty.all(Colors.blue), - ), - child: Scrollbar( - controller: _scrollCtrl, - thumbVisibility: true, - interactive: true, - child: SingleChildScrollView( - controller: _scrollCtrl, - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Image.asset("assets/Slogan.png", height: 150, width: 250), - - Container( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 10, - ), - decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.blue), - ), - child: Text( - "Enter your info to create your account.\n After submitting, you will receive a confirmation email.", - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.blue, - ), - ), - ), - const SizedBox(height: 10), - TextField( - controller: _nameCtrl, - inputFormatters: [ - LengthLimitingTextInputFormatter(36) - ], - maxLength: 36, - style: TextStyle( - color: Theme.of(context).colorScheme.fontInverted, - ), - decoration: InputDecoration( - labelText: 'Name', - labelStyle: TextStyle(color: Colors.blue), - - contentPadding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ), - - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.blue, width: 2.0), - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontSecondary, - ), - borderRadius: BorderRadius.circular(12), - ), - prefixIcon: Icon(Icons.person, color: Colors.blue), - ), - ), - const SizedBox(height: 10), - - TextField( - controller: _emailCtrl, - style: TextStyle( - color: Theme.of(context).colorScheme.fontInverted, - ), - decoration: InputDecoration( - labelText: 'Email', - labelStyle: const TextStyle(color: Colors.blue), - - contentPadding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ), - - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - focusedBorder: OutlineInputBorder( - borderSide: const BorderSide( - color: Colors.blue, - width: 2.0, - ), - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontSecondary, - ), - borderRadius: BorderRadius.circular(12), - ), - prefixIcon: const Icon(Icons.email, color: Colors.blue), - ), - ), - const SizedBox(height: 12), - - ValueListenableBuilder( - valueListenable: _passwordCtrl, - builder: (context, value, _) { - final requirementText = PasswordParser.process( - value.text, - ); - - if (requirementText == null) { - return const SizedBox.shrink(); - } - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(), - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 4, - horizontal: 10, - ), - decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey), - ), - child: Text( - requirementText, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.grey, - ), - ), - ), - ), - - const SizedBox(height: 10), - ], - ); - }, - ), - - TextField( - controller: _passwordCtrl, - style: TextStyle( - color: Theme.of(context).colorScheme.fontInverted, - ), - decoration: InputDecoration( - labelText: passwordText, - labelStyle: TextStyle(color: labelColor), - - contentPadding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ), - - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - focusedBorder: OutlineInputBorder( - borderSide: const BorderSide( - color: Colors.blue, - width: 2.0, - ), - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontSecondary, - ), - borderRadius: BorderRadius.circular(12), - ), - prefixIcon: Icon(Icons.lock, color: iconColor), - suffixIcon: IconButton( - icon: Icon( - _obscurePassword - ? Icons.visibility_off - : Icons.visibility, - color: Colors.blue, - ), - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, - ), - ), - obscureText: _obscurePassword, - onChanged: (_) { - if (iconColor == Colors.red) { - _changeColorsToDefault(); - } - }, - ), - const SizedBox(height: 10), - - TextField( - controller: _passwordConfirmCtrl, - style: TextStyle( - color: Theme.of(context).colorScheme.fontInverted, - ), - decoration: InputDecoration( - labelText: confirmPasswordText, - labelStyle: TextStyle(color: labelColor), - - contentPadding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ), - - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - focusedBorder: OutlineInputBorder( - borderSide: const BorderSide( - color: Colors.blue, - width: 2.0, - ), - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontSecondary, - ), - borderRadius: BorderRadius.circular(12), - ), - prefixIcon: Icon(Icons.lock, color: iconColor), - suffixIcon: IconButton( - icon: Icon( - _obscureConfirmPassword - ? Icons.visibility_off - : Icons.visibility, - color: Colors.blue, - ), - onPressed: () { - setState(() { - _obscureConfirmPassword = !_obscureConfirmPassword; - }); - }, - ), - ), - obscureText: _obscureConfirmPassword, - onChanged: (value) { - if ((_passwordCtrl.text.trim() != - _passwordConfirmCtrl.text.trim())) { - if (iconColor != Colors.red) { - _changeColorsToRed("Passwords don't match"); - } - } else { - _changeColorsToDefault(); - } - }, - ), - const SizedBox(height: 24), - - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _isLoading ? null : _handleSignUp, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - ), - child: _isLoading - ? const CircularProgressIndicator() - : const Text('Create Account'), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 15.0), - child: InkWell( - onTap: () => context.go("/login"), - child: const Text( - 'Already have an account? Login', - style: TextStyle( - color: Colors.blue, - decoration: TextDecoration.underline, - ), - ), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/start_machine_page.dart b/lib/pages/start_machine_page.dart deleted file mode 100644 index 1321804..0000000 --- a/lib/pages/start_machine_page.dart +++ /dev/null @@ -1,210 +0,0 @@ -import 'package:clean_stream_laundry_app/features/start_machine/widgets/qr_button.dart'; -import 'package:flutter/material.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:clean_stream_laundry_app/widgets/base_page.dart'; -import 'package:clean_stream_laundry_app/widgets/section_banner.dart'; -import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; -import 'package:clean_stream_laundry_app/features/start_machine/widgets/searching_dialog.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import '../widgets/status_dialog_box.dart'; - -const double minimumBalance = 20; - -class StartPage extends StatefulWidget { - final DoorUnlocker doorUnlocker; - - StartPage({super.key, DoorUnlocker? doorUnlocker}) - : doorUnlocker = doorUnlocker ?? DoorUnlocker(); - - @override - State createState() => _StartPageState(); -} - -class _StartPageState extends State { - final profileService = GetIt.instance(); - final authService = GetIt.instance(); - - Map? balance; - - @override - void initState() { - super.initState(); - _loadUserData(); - } - - Future _loadUserData() async { - final userId = authService.getCurrentUserId; - if (userId == null) return; - - final fetchedBalance = await profileService.getUserBalanceById(userId); - - if (mounted) { - setState(() { - balance = fetchedBalance; - }); - } - } - - @override - Widget build(BuildContext context) { - return BasePage( - body: Padding( - padding: const EdgeInsets.all(20.0), - child: SingleChildScrollView( - child: IntrinsicHeight( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SectionHeader(title: "Payment Options"), - - Container( - height: 160, - margin: const EdgeInsets.symmetric( - horizontal: 23, vertical: 10), - padding: const EdgeInsets.all(30), - decoration: BoxDecoration( - border: Border.all(color: Colors.blue, width: 3), - borderRadius: BorderRadius.circular(14), - color: Colors.transparent, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "Tap To Pay", - style: TextStyle( - color: Theme - .of(context) - .colorScheme - .fontInverted, - fontSize: 28, - fontWeight: FontWeight.bold, - ), - ), - Text( - "Tap phone to machine to pay", - style: TextStyle( - color: Theme - .of(context) - .colorScheme - .fontSecondary, - fontSize: 16, - ), - ), - ], - ), - const Icon( - Icons.tap_and_play, - color: Colors.blue, - size: 40, - ), - ], - ), - ), - - const SizedBox(height: 10), - - SizedBox( - height: 160, - child: QRButton( - headLineText: "Scan QR code", - descriptionText: "Scan QR code on the machine", - icon: Icons.qr_code_scanner, - onPressed: () { - context.go("/scanner"); - }, - ), - ), - - const SizedBox(height: 10), - const SectionHeader(title: "After Hours"), - - SizedBox( - height: 160, - child: QRButton( - headLineText: "Unlock Door", - descriptionText: "Unlock doors after hours", - icon: Icons.lock_open_rounded, - onPressed: () async { - final bal = balance?["balance"]; - - if (bal == null || bal < minimumBalance) { - _showLowBalanceDialog(context); - return; - } - - await _processUnlocking(context); - }, - ), - ), - ], - ), - ), - ), - ), - ); - } - - Future _processUnlocking(BuildContext context) async { - cancelSearch = false; - - showSearchingDialog( - context, - () => widget.doorUnlocker.cancelUnlockingDoor(), - ); - - final success = await widget.doorUnlocker.unlockNearestDoor(); - - if (!context.mounted) return; - - if (cancelSearch) return; - - if (Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - } - - statusDialog( - context, - title: success ? "Door Unlocked!" : "No Nearby Doors Found", - message: success - ? "The nearest door has been unlocked successfully" - : "We couldn't detect any nearby doors", - isSuccess: success, - ); - } -} - -void _showLowBalanceDialog(BuildContext context) { - showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - title: const Text('Low Balance'), - content: Text( - 'You need at least ${minimumBalance.toStringAsFixed(2)} to unlock a door', - ), - icon: const Icon(Icons.error), - actions: [ - TextButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - context.go("/startPage"); - }, - child: const Text('OK'), - ), - ], - ); - }, - ); -} \ No newline at end of file diff --git a/lib/pages/verify_code_page.dart b/lib/pages/verify_code_page.dart deleted file mode 100644 index 97fdf0f..0000000 --- a/lib/pages/verify_code_page.dart +++ /dev/null @@ -1,237 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; - -import '../logic/services/auth_service.dart'; -import '../logic/theme/theme.dart'; - -class CodeVerificationPage extends StatefulWidget { - final String email; - - const CodeVerificationPage({Key? key, required this.email}) : super(key: key); - - @override - State createState() => _CodeVerificationPageState(); -} - -class _CodeVerificationPageState extends State { - final TextEditingController _codeController = TextEditingController(); - final authService = GetIt.instance(); - - bool _isLoading = false; - String? _error; - - void _showMessage(String msg) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(msg)), - ); - } - - Future _sendResetEmail() async { - - try { - final response = await authService.resetPassword( - widget.email, - ); - - setState(() { - _isLoading = false; - }); - - if (response == AuthenticationResponses.success) { - _showMessage('Password reset email sent! Check your email.'); - } else { - _showMessage('Failed to send reset email.'); - } - } catch (e) { - setState(() { - _isLoading = false; - }); - _showMessage('Error: $e'); - } - } - - void _verifyCode() async { - final code = _codeController.text.trim(); - - if (code.length != 6) { - setState(() { - _error = 'Please enter the 6-digit code'; - }); - return; - } - - setState(() { - _isLoading = true; - _error = null; - }); - - try { - final response = await authService.verifyCode( - email: widget.email, - code: code, - ); - - if (!mounted) return; - - if (response == AuthenticationResponses.success) { - context.go('/reset-protected'); - return; - } else { - setState(() { - _error = 'Invalid or expired code'; - }); - } - } catch (_) { - if (!mounted) return; - - setState(() { - _error = 'Something went wrong. Try again'; - }); - } finally { - if (!mounted) return; - - setState(() { - _isLoading = false; - }); - } - } - - @override - void dispose() { - _codeController.dispose(); - super.dispose(); - } - - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - - return Scaffold( - backgroundColor: scheme.surface, - appBar: AppBar( - backgroundColor: scheme.surface, - foregroundColor: scheme.fontInverted, - title: const Text('Verify Code'), - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - const SizedBox(height: 40), - - // Title - Text( - 'Enter Verification Code', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: scheme.fontInverted, - ), - ), - - const SizedBox(height: 12), - - // Subtitle - Text( - 'We sent a 6-digit code to', - style: TextStyle(color: scheme.fontInverted.withOpacity(0.7)), - textAlign: TextAlign.center, - ), - - const SizedBox(height: 6), - - // Email - Text( - widget.email, - style: TextStyle( - fontWeight: FontWeight.w600, - color: scheme.primary, - ), - textAlign: TextAlign.center, - ), - - const SizedBox(height: 32), - - // Code Input - TextField( - controller: _codeController, - keyboardType: TextInputType.number, - maxLength: 6, - style: TextStyle( - color: scheme.fontInverted, - letterSpacing: 8, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - decoration: InputDecoration( - labelText: _error ?? '6-digit code', - labelStyle: TextStyle( - color: _error != null ? Colors.red : scheme.primary, - ), - counterText: '', - prefixIcon: Icon( - Icons.lock, - color: _error != null ? Colors.red : scheme.primary, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - onChanged: (_) { - if (_error != null) { - setState(() => _error = null); - } - }, - ), - - const SizedBox(height: 32), - - // Verify Button - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _isLoading ? null : _verifyCode, - style: ElevatedButton.styleFrom( - backgroundColor: scheme.primary, - foregroundColor: scheme.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 14), - ), - child: _isLoading - ? const SizedBox( - height: 22, - width: 22, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Verify'), - ), - ), - - const SizedBox(height: 16), - - // Error Message (optional extra under field) - if (_error != null) - Text( - _error!, - style: const TextStyle(color: Colors.red), - ), - - const SizedBox(height: 16), - - // Resend - TextButton( - onPressed: _sendResetEmail, - child: Text( - 'Resend code', - style: TextStyle(color: scheme.primary), - ), - ), - ], - ), - ), - ); - } -} \ No newline at end of file diff --git a/test/features/change_email_verification/change_email_verification_test.dart b/test/features/change_email_verification/change_email_verification_test.dart index c4c44ba..b031de2 100644 --- a/test/features/change_email_verification/change_email_verification_test.dart +++ b/test/features/change_email_verification/change_email_verification_test.dart @@ -3,7 +3,7 @@ import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/features/change_email_verification/change_email_verification.dart'; -import 'package:clean_stream_laundry_app/pages/home_page.dart'; +import 'package:clean_stream_laundry_app/features/home/home.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; @@ -136,7 +136,9 @@ void main() { final context = tester.element(find.byType(ChangeEmailVerificationPage)); GoRouter.of(context).go('/homePage'); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 300)); expect(find.byType(ChangeEmailVerificationPage), findsNothing); }); diff --git a/test/features/email_verification/email_verification_test.dart b/test/features/email_verification/email_verification_test.dart index e452add..d8eb6e8 100644 --- a/test/features/email_verification/email_verification_test.dart +++ b/test/features/email_verification/email_verification_test.dart @@ -4,7 +4,7 @@ import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/pages/home_page.dart'; +import 'package:clean_stream_laundry_app/features/home/home.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; diff --git a/test/features/not_found/not_found_test.dart b/test/features/not_found/not_found_test.dart index fb0c8b0..ec019ee 100644 --- a/test/features/not_found/not_found_test.dart +++ b/test/features/not_found/not_found_test.dart @@ -1,6 +1,6 @@ import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/pages/not_found_page.dart'; +import 'package:clean_stream_laundry_app/features/not_found/not_found.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; @@ -49,7 +49,7 @@ void main() { Widget createWidgetUnderTest() { final router = GoRouter( routes: [ - GoRoute(path: '/', builder: (context, state) => const NotFoundScreen()), + GoRoute(path: '/', builder: (context, state) => const NotFound()), GoRoute( path: '/homePage', builder: (context, state) => const Scaffold(body: Text('Home Page')), diff --git a/test/mocks.dart b/test/mocks.dart new file mode 100644 index 0000000..5f814cc --- /dev/null +++ b/test/mocks.dart @@ -0,0 +1,6 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/middleware/app_router.dart'; + +class MockAuthService extends Mock implements AuthService {} +class MockRouterService extends Mock implements RouterService {} \ No newline at end of file diff --git a/test/pages/change_email_verification_test.dart b/test/pages/change_email_verification_test.dart deleted file mode 100644 index e307af8..0000000 --- a/test/pages/change_email_verification_test.dart +++ /dev/null @@ -1,331 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/pages/change_email_verification.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:go_router/go_router.dart'; -import 'mocks.dart'; -import 'package:clean_stream_laundry_app/pages/home_page.dart'; - -void main() { - late MockAuthService mockAuthService; - late FakeAppLinks fakeAppLinks; - late MockLocationService mockLocationService; - late MockMachineService mockMachineService; - late MockProfileService mockProfileService; - - setUpAll(() { - registerFallbackValue(FakeAuthService()); - registerFallbackValue(FakeUri()); - registerFallbackValue(''); - }); - - setUp(() { - mockAuthService = MockAuthService(); - fakeAppLinks = FakeAppLinks(); - mockLocationService = MockLocationService(); - mockMachineService = MockMachineService(); - mockProfileService = MockProfileService(); - - GetIt.instance.registerSingleton(mockAuthService); - GetIt.instance.registerSingleton(mockLocationService); - GetIt.instance.registerSingleton(mockMachineService); - GetIt.instance.registerSingleton(mockProfileService); - - // Mock HomePage dependencies - when( - () => mockLocationService.getLocations(), - ).thenAnswer((_) async => >[]); - when( - () => mockMachineService.getWasherCountByLocation(any()), - ).thenAnswer((_) async => 0); - when( - () => mockMachineService.getIdleWasherCountByLocation(any()), - ).thenAnswer((_) async => 0); - when( - () => mockMachineService.getDryerCountByLocation(any()), - ).thenAnswer((_) async => 0); - when( - () => mockMachineService.getIdleDryerCountByLocation(any()), - ).thenAnswer((_) async => 0); - - // Mock auth service methods for deep link handling - when(() => mockAuthService.refreshSession()).thenAnswer((_) async => {}); - when(() => mockAuthService.getCurrentUser()).thenAnswer((_) => null); - }); - - tearDown(() { - fakeAppLinks.dispose(); - GetIt.instance.reset(); - }); - - Widget createTestWidget() { - final router = GoRouter( - initialLocation: '/change-email-verification', - routes: [ - GoRoute( - path: '/change-email-verification', - builder: (context, state) => ChangeEmailVerificationPage(appLinks: fakeAppLinks,), - ), - GoRoute(path: '/homePage', builder: (context, state) => HomePage()), - ], - ); - - return MaterialApp.router(routerConfig: router); - } - - group('Initialization', () { - testWidgets('displays all required UI elements', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.email), findsOneWidget); - expect(find.text('Please verify your new email address'), findsOneWidget); - expect( - find.text( - 'Check your new email\'s inbox and click the verification link.', - ), - findsOneWidget, - ); - expect(find.text('Resend Verification'), findsOneWidget); - }); - - testWidgets('email icon has correct styling', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - final icon = tester.widget(find.byIcon(Icons.email)); - expect(icon.size, equals(80)); - expect(icon.color, equals(Colors.blueAccent)); - }); - - testWidgets('resend link has correct initial styling', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - final textWidget = tester.widget(find.text('Resend Verification')); - expect(textWidget.style?.color, equals(Colors.blue)); - expect(textWidget.style?.decoration, equals(TextDecoration.underline)); - }); - - testWidgets('text uses center alignment', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - final titleText = tester.widget( - find.text('Please verify your new email address'), - ); - final descText = tester.widget( - find.text( - 'Check your new email\'s inbox and click the verification link.', - ), - ); - - expect(titleText.textAlign, equals(TextAlign.center)); - expect(descText.textAlign, equals(TextAlign.center)); - }); - }); - - group('Resend Verification - Success', () { - testWidgets('calls resend service method', (tester) async { - when( - () => mockAuthService.resendVerification(), - ).thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Resend Verification')); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.resendVerification()).called(1); - }); - - testWidgets('shows success icon after resend', (tester) async { - when( - () => mockAuthService.resendVerification(), - ).thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Resend Verification')); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.check_circle), findsOneWidget); - expect(find.text('Resend Verification'), findsNothing); - }); - - testWidgets('success icon has correct styling', (tester) async { - when( - () => mockAuthService.resendVerification(), - ).thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Resend Verification')); - await tester.pumpAndSettle(); - - final icon = tester.widget(find.byIcon(Icons.check_circle)); - expect(icon.size, equals(40)); - expect(icon.color, equals(Colors.green)); - }); - - testWidgets('prevents multiple resend attempts', (tester) async { - when( - () => mockAuthService.resendVerification(), - ).thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Resend Verification')); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.resendVerification()).called(1); - - await tester.tap(find.byIcon(Icons.check_circle)); - await tester.pumpAndSettle(); - - verifyNever(() => mockAuthService.resendVerification()); - }); - }); - - group('Resend Verification - Failure', () { - testWidgets('shows error message on failure', (tester) async { - when( - () => mockAuthService.resendVerification(), - ).thenAnswer((_) async => AuthenticationResponses.error); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Resend Verification')); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.close), findsOneWidget); - expect( - find.text('Please resend verification again at another time.'), - findsOneWidget, - ); - }); - - testWidgets('error icon has correct styling', (tester) async { - when( - () => mockAuthService.resendVerification(), - ).thenAnswer((_) async => AuthenticationResponses.error); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Resend Verification')); - await tester.pumpAndSettle(); - - final container = tester.widget( - find - .ancestor( - of: find.byIcon(Icons.close), - matching: find.byType(Container), - ) - .first, - ); - - final decoration = container.decoration as BoxDecoration; - expect(decoration.color, equals(Colors.red)); - expect(decoration.shape, equals(BoxShape.circle)); - }); - - testWidgets('prevents retry after failure', (tester) async { - when( - () => mockAuthService.resendVerification(), - ).thenAnswer((_) async => AuthenticationResponses.error); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Resend Verification')); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.resendVerification()).called(1); - - await tester.tap(find.byIcon(Icons.close)); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.resendVerification()).called(1); - }); - }); - - group('InkWell Interaction', () { - testWidgets('resend link is tappable', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - final inkWell = find.ancestor( - of: find.text('Resend Verification'), - matching: find.byType(InkWell), - ); - - expect(inkWell, findsOneWidget); - }); - - testWidgets('InkWell triggers resend on tap', (tester) async { - when( - () => mockAuthService.resendVerification(), - ).thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - final inkWell = find.ancestor( - of: find.text('Resend Verification'), - matching: find.byType(InkWell), - ); - - await tester.tap(inkWell); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.resendVerification()).called(1); - }); - }); - - group('Widget Lifecycle', () { - testWidgets('properly disposes stream subscription', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - final context = tester.element(find.byType(ChangeEmailVerificationPage)); - GoRouter.of(context).go('/homePage'); - await tester.pumpAndSettle(); - - expect(find.byType(ChangeEmailVerificationPage), findsNothing); - }); - - testWidgets('uses theme background color', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - final scaffold = tester.widget(find.byType(Scaffold)); - expect(scaffold.backgroundColor, isNotNull); - }); - - testWidgets("Tests for an app link",(tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - fakeAppLinks.emit(Uri.parse('clean-stream://change-email')); - - await tester.pumpAndSettle(); - await tester.pump(); - - verify(() => mockAuthService.refreshSession()).called(1); - verify(() => mockAuthService.getCurrentUser()).called(1); - }); - - }); -} diff --git a/test/pages/edit_profile_page_test.dart b/test/pages/edit_profile_page_test.dart deleted file mode 100644 index 3091168..0000000 --- a/test/pages/edit_profile_page_test.dart +++ /dev/null @@ -1,411 +0,0 @@ -import 'dart:async'; -import 'package:clean_stream_laundry_app/pages/edit_profile_page.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mocktail/mocktail.dart'; -import 'mocks.dart'; - -void main() { - late MockAuthService authService; - late MockProfileService profileService; - late MockEdgeFunctionService edgeFunctionService; - late StreamController authController; - - setUp(() { - authService = MockAuthService(); - profileService = MockProfileService(); - edgeFunctionService = MockEdgeFunctionService(); - authController = StreamController.broadcast(); - - final getIt = GetIt.instance; - getIt.reset(); - - getIt.registerSingleton(authService); - getIt.registerSingleton(profileService); - getIt.registerSingleton(edgeFunctionService); - - when(() => authService.onAuthChange) - .thenAnswer((_) => authController.stream); - - when(() => authService.getCurrentUserId) - .thenAnswer((_) => 'user-id'); - - when(() => authService.getCurrentUserEmail()) - .thenAnswer((_) => 'test@example.com'); - - when(() => profileService.getUserNameById('user-id')) - .thenAnswer((_) async => 'John Doe'); - - when(() => authService.updateUserAttributes( - email: any(named: 'email'), - data: any(named: 'data'), - )).thenAnswer((_) async {}); - }); - - tearDown(() async { - await authController.close(); - GetIt.instance.reset(); - }); - - Widget createWidget() { - return MaterialApp.router( - routerConfig: GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (_, __) => const EditProfilePage(), - ), - GoRoute( - path: '/settings', - builder: (_, __) => const Scaffold(body: Text('Settings')), - ), - GoRoute( - path: '/change-email-verification', - builder: (_, __) => - const Scaffold(body: Text('Verify Email')), - ), - GoRoute( - path: '/login', - builder: (_, __) => const Scaffold(body: Text('Login')), - ), - ], - ), - ); - } - - testWidgets('displays page title', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - expect(find.text('Edit Profile'), findsOneWidget); - }); - - testWidgets('loads and displays user data in info cards', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - expect(find.text('Full Name'), findsOneWidget); - expect(find.text('Email Address'), findsOneWidget); - - expect(find.text('Current'), findsNWidgets(2)); - expect(find.text('John Doe'), findsNWidgets(2)); - expect(find.text('test@example.com'), findsNWidgets(2)); - - expect(find.text('Save Changes'), findsOneWidget); - expect(find.byIcon(Icons.check_circle_outline), findsOneWidget); - }); - - testWidgets('displays danger zone section with delete account button', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - expect(find.text('Danger Zone'), findsOneWidget); - expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); - expect(find.text('Delete Account'), findsOneWidget); - expect(find.byIcon(Icons.delete_outline), findsOneWidget); - }); - - testWidgets('shows No Changes dialog if nothing changed', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Save Changes')); - await tester.pumpAndSettle(); - - expect(find.text('No Changes'), findsOneWidget); - expect(find.text('You haven\'t changed anything.'), findsOneWidget); - - verifyNever(() => authService.updateUserAttributes( - email: any(named: 'email'), - data: any(named: 'data'), - )); - }); - - testWidgets('validates empty name', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - final nameField = find.widgetWithText(TextFormField, 'New Full Name'); - await tester.enterText(nameField, ''); - - await tester.tap(find.text('Save Changes')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - expect(find.text('Name cannot be empty'), findsOneWidget); - }); - - testWidgets('validates invalid email', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - final emailField = find.widgetWithText(TextFormField, 'New Email'); - await tester.enterText(emailField, 'invalid-email'); - - await tester.tap(find.text('Save Changes')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - expect(find.text('Please enter a valid email'), findsOneWidget); - }); - - testWidgets('updates name only', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - final nameField = find.widgetWithText(TextFormField, 'New Full Name'); - await tester.enterText(nameField, 'Jane Smith'); - - await tester.tap(find.text('Save Changes')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - verify(() => authService.updateUserAttributes( - email: null, - data: {'full_name': 'Jane Smith'}, - )).called(1); - - expect(find.text('Profile Updated'), findsOneWidget); - }); - - testWidgets('navigates to verification when email changes', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - final emailField = find.widgetWithText(TextFormField, 'New Email'); - await tester.enterText(emailField, 'new@email.com'); - - await tester.tap(find.text('Save Changes')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - verify(() => authService.updateUserAttributes( - email: 'new@email.com', - data: null, - )).called(1); - - expect(find.text('Verify Email'), findsOneWidget); - }); - - testWidgets('trims whitespace before saving', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - final nameField = find.widgetWithText(TextFormField, 'New Full Name'); - final emailField = find.widgetWithText(TextFormField, 'New Email'); - - await tester.enterText(nameField, ' Jane '); - await tester.enterText(emailField, ' jane@email.com '); - - await tester.tap(find.text('Save Changes')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - verify(() => authService.updateUserAttributes( - email: 'jane@email.com', - data: {'full_name': 'Jane'}, - )).called(1); - }); - - testWidgets('back button navigates to settings', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - await tester.tap(find.byIcon(Icons.arrow_back)); - await tester.pumpAndSettle(); - - expect(find.text('Settings'), findsOneWidget); - }); - - testWidgets('shows confirmation dialog before saving changes', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - final nameField = find.widgetWithText(TextFormField, 'New Full Name'); - await tester.enterText(nameField, 'New Name'); - - await tester.tap(find.text('Save Changes')); - await tester.pumpAndSettle(); - - expect(find.text('Confirm Changes'), findsOneWidget); - expect(find.text('Are you sure you want to save these changes to your profile?'), findsOneWidget); - expect(find.text('Cancel'), findsOneWidget); - expect(find.text('Save'), findsOneWidget); - }); - - testWidgets('cancels save when user clicks cancel in confirmation', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - final nameField = find.widgetWithText(TextFormField, 'New Full Name'); - await tester.enterText(nameField, 'New Name'); - - await tester.tap(find.text('Save Changes')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - - verifyNever(() => authService.updateUserAttributes( - email: any(named: 'email'), - data: any(named: 'data'), - )); - }); - - testWidgets('shows delete account confirmation dialog', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -500)); - await tester.pumpAndSettle(); - - final deleteButton = find.byType(OutlinedButton); - await tester.tap(deleteButton); - await tester.pumpAndSettle(); - - expect(find.text('Delete Account?'), findsOneWidget); - expect(find.text('Are you sure you want to delete your account? Any money on your loyalty card will be lost. This action cannot be undone.'), findsOneWidget); - expect(find.text('Cancel'), findsOneWidget); - expect(find.text('Delete'), findsOneWidget); - }); - - testWidgets('cancels delete when user clicks cancel', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -500)); - await tester.pumpAndSettle(); - - final deleteButton = find.byType(OutlinedButton); - await tester.tap(deleteButton); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - - verifyNever(() => edgeFunctionService.runEdgeFunction( - name: any(named: 'name'), - body: any(named: 'body'), - )); - }); - - testWidgets('input fields have proper hint text', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - expect(find.text('Enter your full name'), findsOneWidget); - expect(find.text('Enter your email address'), findsOneWidget); - }); - - testWidgets('displays loading indicator while fetching data', (tester) async { - final completer = Completer(); - - when(() => profileService.getUserNameById('user-id')) - .thenAnswer((_) => completer.future); - - await tester.pumpWidget(createWidget()); - await tester.pump(); - - expect(find.byType(CircularProgressIndicator), findsOneWidget); - - completer.complete('John Doe'); - await tester.pumpAndSettle(); - - expect(find.byType(CircularProgressIndicator), findsNothing); - expect(find.text('John Doe'), findsNWidgets(2)); - }); - - testWidgets('disables inputs while saving', (tester) async { - final completer = Completer(); - - when(() => authService.updateUserAttributes( - email: any(named: 'email'), - data: any(named: 'data'), - )).thenAnswer((_) => completer.future); - - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - final nameField = find.widgetWithText(TextFormField, 'New Full Name'); - await tester.enterText(nameField, 'New Name'); - - await tester.tap(find.text('Save Changes')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Save')); - await tester.pump(); - - final saveButton = tester.widget( - find.ancestor( - of: find.byType(CircularProgressIndicator), - matching: find.byType(ElevatedButton), - ).first, - ); - expect(saveButton.onPressed, isNull); - - completer.complete(); - await tester.pumpAndSettle(); - }); - - testWidgets('enforces name character limit', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - final nameField = find.widgetWithText(TextFormField, 'New Full Name'); - - const longName = 'This is a very long name that exceeds the limit'; - await tester.enterText(nameField, longName); - await tester.pump(); - - final textField = tester.widget(nameField); - final controller = textField.controller!; - - expect(controller.text.length, lessThanOrEqualTo(36)); - }); - - testWidgets('name field only allows alphanumeric and spaces', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - final nameField = find.widgetWithText(TextFormField, 'New Full Name'); - - await tester.enterText(nameField, 'Test@#\$%'); - await tester.pump(); - - final textField = tester.widget(nameField); - final controller = textField.controller!; - - expect(controller.text, 'Test'); - }); - - testWidgets('displays icon buttons in danger zone', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); - - expect(find.byIcon(Icons.delete_outline), findsOneWidget); - }); - - testWidgets('page is scrollable', (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - expect(find.byType(SingleChildScrollView), findsOneWidget); - }); -} \ No newline at end of file diff --git a/test/pages/email_verification_page_test.dart b/test/pages/email_verification_page_test.dart deleted file mode 100644 index bc9af57..0000000 --- a/test/pages/email_verification_page_test.dart +++ /dev/null @@ -1,361 +0,0 @@ -import 'dart:async'; -import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/pages/email_verification_page.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:go_router/go_router.dart'; -import 'mocks.dart'; -import 'package:clean_stream_laundry_app/pages/home_page.dart'; - -void main() { - late MockAuthService mockAuthService; - late StreamController authChangeController; - late MockMachineService mockMachineService; - late MockLocationService mockLocationService; - late MockProfileService mockProfileService; - late FakeAppLinks fakeAppLinks; - - setUpAll(() { - registerFallbackValue(FakeAuthService()); - }); - - setUp(() { - mockAuthService = MockAuthService(); - authChangeController = StreamController.broadcast(); - mockMachineService = MockMachineService(); - mockLocationService = MockLocationService(); - mockProfileService = MockProfileService(); - fakeAppLinks = FakeAppLinks(); - - GetIt.instance.registerSingleton(mockAuthService); - GetIt.instance.registerSingleton(mockMachineService); - GetIt.instance.registerSingleton(mockLocationService); - GetIt.instance.registerSingleton(mockProfileService); - - when( - () => mockAuthService.onAuthChange, - ).thenAnswer((_) => authChangeController.stream); - when(() => mockAuthService.isEmailVerified()).thenReturn(false); - when( - () => mockLocationService.getLocations(), - ).thenAnswer((_) async => >[]); - }); - - tearDown(() { - authChangeController.close(); - GetIt.instance.reset(); - }); - - Widget createTestWidget() { - final router = GoRouter( - initialLocation: '/email-verification', - routes: [ - GoRoute( - path: '/email-verification', - builder: (context, state) => EmailVerificationPage(appLinks: fakeAppLinks,), - ), - GoRoute(path: '/homePage', builder: (context, state) => HomePage()), - GoRoute( - path: '/scanner', - builder: (context, state) => Scaffold(body: Text('Scanner Page')), - ), - ], - ); - - return MaterialApp.router(routerConfig: router); - } - - group('Initialization', () { - testWidgets('displays all required UI elements', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.email), findsOneWidget); - expect(find.text('Please verify your email address'), findsOneWidget); - expect( - find.text('Check your inbox and click the verification link.'), - findsOneWidget, - ); - expect(find.text('Resend Verification'), findsOneWidget); - }); - - testWidgets('email icon has correct styling', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - final icon = tester.widget(find.byIcon(Icons.email)); - expect(icon.size, equals(80)); - expect(icon.color, equals(Colors.blueAccent)); - }); - - testWidgets('resend link has correct initial styling', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - final textWidget = tester.widget(find.text('Resend Verification')); - expect(textWidget.style?.color, equals(Colors.blue)); - expect(textWidget.style?.decoration, equals(TextDecoration.underline)); - }); - - testWidgets('sets up auth change listener', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.onAuthChange).called(1); - }); - - testWidgets('text uses center alignment', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - final titleText = tester.widget( - find.text('Please verify your email address'), - ); - final descText = tester.widget( - find.text('Check your inbox and click the verification link.'), - ); - - expect(titleText.textAlign, equals(TextAlign.center)); - expect(descText.textAlign, equals(TextAlign.center)); - }); - }); - - group('Navigation', () { - testWidgets('navigates to home page when email is verified', ( - tester, - ) async { - when(() => mockAuthService.isEmailVerified()).thenReturn(true); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - authChangeController.add(true); - await tester.pumpAndSettle(); - - expect(find.byKey(HomePage.pageKey), findsOneWidget); - }); - - testWidgets('stays on page when email not verified', (tester) async { - when(() => mockAuthService.isEmailVerified()).thenReturn(false); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - authChangeController.add(true); - await tester.pumpAndSettle(); - - expect(find.text('Please verify your email address'), findsOneWidget); - expect(find.text('Scanner Page'), findsNothing); - }); - - testWidgets('stays on page when user logs out', (tester) async { - when(() => mockAuthService.isEmailVerified()).thenReturn(true); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - authChangeController.add(false); - await tester.pumpAndSettle(); - - expect(find.text('Please verify your email address'), findsOneWidget); - expect(find.text('Scanner Page'), findsNothing); - }); - }); - - group('Resend Verification - Success', () { - testWidgets('calls resend service method', (tester) async { - when( - () => mockAuthService.resendVerification(), - ).thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Resend Verification')); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.resendVerification()).called(1); - }); - - testWidgets('shows success icon after resend', (tester) async { - when( - () => mockAuthService.resendVerification(), - ).thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Resend Verification')); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.check_circle), findsOneWidget); - expect(find.text('Resend Verification'), findsNothing); - }); - - testWidgets('success icon has correct styling', (tester) async { - when( - () => mockAuthService.resendVerification(), - ).thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Resend Verification')); - await tester.pumpAndSettle(); - - final icon = tester.widget(find.byIcon(Icons.check_circle)); - expect(icon.size, equals(40)); - expect(icon.color, equals(Colors.green)); - }); - - testWidgets('prevents multiple resend attempts', (tester) async { - when( - () => mockAuthService.resendVerification(), - ).thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Resend Verification')); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.resendVerification()).called(1); - - await tester.tap(find.byIcon(Icons.check_circle)); - await tester.pumpAndSettle(); - - verifyNever(() => mockAuthService.resendVerification()); - }); - }); - - group('Resend Verification - Failure', () { - testWidgets('shows error message on failure', (tester) async { - when( - () => mockAuthService.resendVerification(), - ).thenAnswer((_) async => AuthenticationResponses.error); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Resend Verification')); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.close), findsOneWidget); - expect( - find.text('Please resend verification again at another time.'), - findsOneWidget, - ); - }); - - testWidgets('error icon has correct styling', (tester) async { - when( - () => mockAuthService.resendVerification(), - ).thenAnswer((_) async => AuthenticationResponses.error); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Resend Verification')); - await tester.pumpAndSettle(); - - final container = tester.widget( - find - .ancestor( - of: find.byIcon(Icons.close), - matching: find.byType(Container), - ) - .first, - ); - - final decoration = container.decoration as BoxDecoration; - expect(decoration.color, equals(Colors.red)); - expect(decoration.shape, equals(BoxShape.circle)); - }); - - testWidgets('prevents retry after failure', (tester) async { - when( - () => mockAuthService.resendVerification(), - ).thenAnswer((_) async => AuthenticationResponses.error); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Resend Verification')); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.resendVerification()).called(1); - - await tester.tap(find.byIcon(Icons.close)); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.resendVerification()).called(1); - }); - }); - - group('InkWell Interaction', () { - testWidgets('resend link is tappable', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - final inkWell = find.ancestor( - of: find.text('Resend Verification'), - matching: find.byType(InkWell), - ); - - expect(inkWell, findsOneWidget); - }); - - testWidgets('InkWell triggers resend on tap', (tester) async { - when( - () => mockAuthService.resendVerification(), - ).thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - final inkWell = find.ancestor( - of: find.text('Resend Verification'), - matching: find.byType(InkWell), - ); - - await tester.tap(inkWell); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.resendVerification()).called(1); - }); - }); - - group('Widget Lifecycle', () { - testWidgets('properly disposes stream subscription', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - final context = tester.element(find.byType(EmailVerificationPage)); - GoRouter.of(context).go('/scanner'); - await tester.pumpAndSettle(); - - expect(find.byType(EmailVerificationPage), findsNothing); - }); - - testWidgets('uses theme background color', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - final scaffold = tester.widget(find.byType(Scaffold)); - expect(scaffold.backgroundColor, isNotNull); - }); - - testWidgets('test deepLink is handled correctly', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - fakeAppLinks.emit(Uri.parse('clean-stream://change-email')); - }); - }); -} diff --git a/test/pages/home_page_test.dart b/test/pages/home_page_test.dart deleted file mode 100644 index 88a0bee..0000000 --- a/test/pages/home_page_test.dart +++ /dev/null @@ -1,308 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/pages/home_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'mocks.dart'; - -void main() { - late MockLocationService mockLocationService; - late MockMachineService mockMachineService; - late MockProfileService mockProfileService; - late MockAuthService mockAuthService; - - setUp(() { - mockLocationService = MockLocationService(); - mockMachineService = MockMachineService(); - mockProfileService = MockProfileService(); - mockAuthService = MockAuthService(); - - final getIt = GetIt.instance; - if (getIt.isRegistered()) { - getIt.unregister(); - } - if (getIt.isRegistered()) { - getIt.unregister(); - } - if (getIt.isRegistered()) { - getIt.unregister(); - } - if (getIt.isRegistered()) { - getIt.unregister(); - } - - getIt.registerSingleton(mockLocationService); - getIt.registerSingleton(mockMachineService); - getIt.registerSingleton(mockAuthService); - getIt.registerSingleton(mockProfileService); - - SharedPreferences.setMockInitialValues({}); - }); - - tearDown(() => GetIt.instance.reset()); - - Widget createWidgetUnderTest() { - final router = GoRouter( - routes: [ - GoRoute(path: '/', builder: (context, state) => const HomePage()), - ], - ); - return MaterialApp.router(routerConfig: router); - } - - void mockLocations([List>? locations]) { - when(() => mockLocationService.getLocations()).thenAnswer( - (_) async => locations ?? [ - {"id": 1, "Address": "123 Main St"}, - {"id": 2, "Address": "456 Oak Ave"}, - ], - ); - } - - void mockMachineCounts(String locationId, { - int washers = 5, - int idleWashers = 3, - int dryers = 4, - int idleDryers = 2, - }) { - when(() => mockMachineService.getWasherCountByLocation(locationId)) - .thenAnswer((_) async => washers); - when(() => mockMachineService.getIdleWasherCountByLocation(locationId)) - .thenAnswer((_) async => idleWashers); - when(() => mockMachineService.getDryerCountByLocation(locationId)) - .thenAnswer((_) async => dryers); - when(() => mockMachineService.getIdleDryerCountByLocation(locationId)) - .thenAnswer((_) async => idleDryers); - } - - group('HomePage Widget Tests', () { - test('should create HomePageState', () { - const homePage = HomePage(); - final state = homePage.createState(); - expect(state, isA()); - }); - - group('UI Structure', () { - testWidgets('should have SingleChildScrollView for scrolling', (tester) async { - mockLocations([{"id": 1, "Address": "123 Main St"}]); - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect(find.byType(SingleChildScrollView), findsOneWidget); - }); - - testWidgets('should have proper layout structure', (tester) async { - mockLocations([{"id": 1, "Address": "123 Main St"}]); - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect(find.byType(Column), findsWidgets); - expect(find.byType(Padding), findsWidgets); - }); - }); - - group('location Dropdown', () { - testWidgets('should display location dropdown', (tester) async { - mockLocations(); - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect(find.text('Select Location'), findsOneWidget); - expect(find.byIcon(Icons.location_on), findsOneWidget); - }); - - testWidgets('should populate dropdown with locations', (tester) async { - mockLocations(); - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - // Find and tap the button that opens the BottomSheet - final openSheetButton = find.text('Select Location'); - expect(openSheetButton, findsOneWidget); - - await tester.tap(openSheetButton); - await tester.pumpAndSettle(); - - // Now the BottomSheet should be visible with your location items - final location1 = find.text('123 Main St'); - final location2 = find.text('456 Oak Ave'); - - expect(location1, findsOneWidget); - expect(location2, findsOneWidget); - }); - - testWidgets('should restore last selected location from storage', (tester) async { - SharedPreferences.setMockInitialValues({ - 'lastSelectedLocation': '123 Main St', - }); - mockLocations(); - mockMachineCounts('1'); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect(find.text('123 Main St'), findsOneWidget); - }); - }); - - group('Loading States', () { - testWidgets('should show loading indicator while fetching locations', (tester) async { - when(() => mockLocationService.getLocations()).thenAnswer((_) async { - await Future.delayed(const Duration(milliseconds: 100)); - return [{"id": 1, "Address": "123 Main St"}]; - }); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pump(); - - expect(find.byType(CircularProgressIndicator), findsNWidgets(2)); - - await tester.pumpAndSettle(); - expect(find.byType(CircularProgressIndicator), findsNothing); - }); - - }); - - group('Nearest Location Button', () { - testWidgets('should find and select nearest location when button is tapped', (tester) async { - final testLocations = [ - { - "id": 1, - "Address": "123 Main St", - "Latitude": 40.0, - "Longitude": -86.0, - }, - { - "id": 2, - "Address": "456 Oak Ave", - "Latitude": 40.5, - "Longitude": -86.5, - }, - ]; - - mockLocations(testLocations); - mockMachineCounts('1'); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final nearestLocationButton = find.ancestor( - of: find.text('Nearest Location'), - matching: find.byType(InkWell), - ); - expect(nearestLocationButton, findsOneWidget); - - await tester.tap(nearestLocationButton); - await tester.pumpAndSettle(); - - verify(() => mockLocationService.getLocations()).called(greaterThan(1)); - }); - - testWidgets('should display nearest location button with correct styling', (tester) async { - mockLocations([{"id": 1, "Address": "123 Main St"}]); - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final button = find.ancestor( - of: find.text('Nearest Location'), - matching: find.byType(InkWell), - ); - expect(button, findsOneWidget); - - final inkWell = tester.widget(button); - expect(inkWell.onTap, isNotNull); - - expect(find.text('Nearest Location'), findsOneWidget); - }); - - testWidgets('should update selected location after finding nearest', (tester) async { - final testLocations = [ - { - "id": 1, - "Address": "123 Main St", - "Latitude": 40.0, - "Longitude": -86.0, - }, - { - "id": 2, - "Address": "456 Oak Ave", - "Latitude": 40.5, - "Longitude": -86.5, - }, - ]; - - mockLocations(testLocations); - mockMachineCounts('1'); - mockMachineCounts('2'); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect(find.text('Select Location'), findsOneWidget); - - final nearestLocationButton = find.ancestor( - of: find.text('Nearest Location'), - matching: find.byType(InkWell), - ); - await tester.tap(nearestLocationButton); - await tester.pumpAndSettle(); - - }); - - testWidgets('should save selected location to storage', (tester) async { - final testLocations = [ - { - "id": 1, - "Address": "123 Main St", - "Latitude": 40.0, - "Longitude": -86.0, - }, - ]; - - mockLocations(testLocations); - mockMachineCounts('1'); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final nearestLocationButton = find.ancestor( - of: find.text('Nearest Location'), - matching: find.byType(InkWell), - ); - await tester.tap(nearestLocationButton); - await tester.pumpAndSettle(); - }); - }); - - group("Tests navigation button", (){ - - testWidgets('Tests that icon button is visible', (tester) async { - - final testLocations = [ - { - "id": 1, - "Address": "123 Main St", - "Latitude": 40.0, - "Longitude": -86.0, - }, - ]; - - mockLocations(testLocations); - mockMachineCounts('1'); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect(find.byType(IconButton), findsOneWidget); - }); - }); - - }); - -} \ No newline at end of file diff --git a/test/pages/loading_page_test.dart b/test/pages/loading_page_test.dart deleted file mode 100644 index f628510..0000000 --- a/test/pages/loading_page_test.dart +++ /dev/null @@ -1,387 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/pages/loading_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -import 'mocks.dart'; - -void main() { - late MockAuthService mockAuthService; - late MockProfileService mockProfileService; - - setUpAll(() { - registerFallbackValue(Uri.parse('clean-stream://fallback')); - }); - - setUp(() { - mockAuthService = MockAuthService(); - mockProfileService = MockProfileService(); - - final getIt = GetIt.instance; - if (getIt.isRegistered()) { - getIt.unregister(); - } - - if (getIt.isRegistered()) { - getIt.unregister(); - } - - getIt.registerSingleton(mockAuthService); - getIt.registerSingleton(mockProfileService); - }); - - tearDown(() { - GetIt.instance.reset(); - }); - - Widget createTestWidget(Widget child) { - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => child, - ), - GoRoute( - path: '/login', - builder: (context, state) => const Scaffold( - body: Text('Login Page'), - ), - ), - GoRoute( - path: '/homePage', - builder: (context, state) => const Scaffold( - body: Text('Home Page'), - ), - ), - ], - ); - - return MaterialApp.router( - routerConfig: router, - ); - } - - group('LoadingPage Widget Tests', () { - testWidgets('displays logo animation when no error', (WidgetTester tester) async { - when(() => mockAuthService.isLoggedIn()) - .thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pump(); - - expect(find.byType(Image), findsOneWidget); - expect(find.byType(TweenAnimationBuilder), findsOneWidget); - expect(find.byIcon(Icons.error_outline), findsNothing); - expect(find.text('authentication Failed'), findsNothing); - - await tester.pumpAndSettle(); - }); - - testWidgets('displays error UI when authentication fails', (WidgetTester tester) async { - when(() => mockAuthService.isLoggedIn()) - .thenThrow(Exception('Network error')); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.error_outline), findsOneWidget); - expect(find.text('Authentication Failed'), findsOneWidget); - expect(find.text('Exception: Network error'), findsOneWidget); - expect(find.text('Return to Login'), findsOneWidget); - }); - - testWidgets('error button navigates to login page', (WidgetTester tester) async { - when(() => mockAuthService.isLoggedIn()) - .thenThrow(Exception('Auth failed')); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Return to Login')); - await tester.pumpAndSettle(); - - expect(find.text('Login Page'), findsOneWidget); - }); - }); - - group('authentication logic Tests', () { - testWidgets('navigates to home page when user is logged in', (WidgetTester tester) async { - when(() => mockAuthService.isLoggedIn()) - .thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pumpAndSettle(); - - expect(find.text('Home Page'), findsOneWidget); - verify(() => mockAuthService.isLoggedIn()).called(1); - }); - - testWidgets('navigates to login page when user is not logged in', (WidgetTester tester) async { - when(() => mockAuthService.isLoggedIn()) - .thenAnswer((_) async => AuthenticationResponses.failure); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pumpAndSettle(); - - expect(find.text('Login Page'), findsOneWidget); - verify(() => mockAuthService.isLoggedIn()).called(1); - }); - - testWidgets('handles authentication service errors gracefully', (WidgetTester tester) async { - when(() => mockAuthService.isLoggedIn()) - .thenThrow(Exception('Service unavailable')); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pumpAndSettle(); - - expect(find.text('Authentication Failed'), findsOneWidget); - expect(find.text('Exception: Service unavailable'), findsOneWidget); - }); - }); - - group('Animation Tests', () { - testWidgets('logo animation is present and configured correctly', (WidgetTester tester) async { - when(() => mockAuthService.isLoggedIn()) - .thenAnswer((_) async { - await Future.delayed(const Duration(seconds: 3)); - return AuthenticationResponses.success; - }); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pump(); - - expect(find.byType(TweenAnimationBuilder), findsOneWidget); - - final animationBuilder = tester.widget>( - find.byType(TweenAnimationBuilder), - ); - - expect(animationBuilder.tween.begin, 0.95); - expect(animationBuilder.tween.end, 1.05); - expect(animationBuilder.duration, const Duration(seconds: 1)); - expect(find.byType(Transform), findsOneWidget); - - await tester.pumpAndSettle(); - }); - - testWidgets('animation does not show when error occurs', (WidgetTester tester) async { - when(() => mockAuthService.isLoggedIn()) - .thenThrow(Exception('Error')); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pumpAndSettle(); - - expect(find.byType(TweenAnimationBuilder), findsNothing); - expect(find.byType(Image), findsNothing); - }); - }); - - group('Cold Start Tests', () { - testWidgets('navigates correctly after authentication check', (WidgetTester tester) async { - when(() => mockAuthService.isLoggedIn()) - .thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.isLoggedIn()).called(1); - expect(find.text('Home Page'), findsOneWidget); - }); - - testWidgets('handles authentication during cold start', (WidgetTester tester) async { - when(() => mockAuthService.isLoggedIn()) - .thenAnswer((_) async => AuthenticationResponses.failure); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pumpAndSettle(); - - expect(find.text('Login Page'), findsOneWidget); - }); - }); - - group('Deep Link Tests', () { - testWidgets('navigates to home page on email verification deep link', (WidgetTester tester) async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('com.llfbandit.app_links/messages'), - (MethodCall methodCall) async { - if (methodCall.method == 'getInitialAppLink') { - return 'clean-stream://email-verification'; - } - return null; - }, - ); - - when(() => mockAuthService.isLoggedIn()) - .thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pumpAndSettle(); - - expect(find.text('Home Page'), findsOneWidget); - }); - - testWidgets('navigates to home page on oauth deep link', (WidgetTester tester) async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('com.llfbandit.app_links/messages'), - (MethodCall methodCall) async { - if (methodCall.method == 'getInitialAppLink') { - return 'clean-stream://oauth'; - } - return null; - }, - ); - - when(() => mockAuthService.isLoggedIn()) - .thenAnswer((_) async => AuthenticationResponses.success); - - when(() => mockAuthService.getCurrentUser()) - .thenReturn(User(id: '', appMetadata: {}, userMetadata: {'full_name':"Test Name"}, aud: '', createdAt: '')); - - when(() => mockProfileService.createAccount( - id: any(named: 'id'), - name: any(named: 'name'), - )).thenAnswer((_) async {}); - - when(() => mockAuthService.getSessionFromURI(any())) - .thenAnswer((_) async {}); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pumpAndSettle(); - - expect(find.text('Home Page'), findsOneWidget); - }); - - testWidgets('handles null initial app link', (WidgetTester tester) async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('com.llfbandit.app_links/messages'), - (MethodCall methodCall) async { - if (methodCall.method == 'getInitialAppLink') { - return null; - } - return null; - }, - ); - - when(() => mockAuthService.isLoggedIn()) - .thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pumpAndSettle(); - - expect(find.text('Home Page'), findsOneWidget); - }); - - testWidgets('ignores deep link with wrong scheme', (WidgetTester tester) async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('com.llfbandit.app_links/messages'), - (MethodCall methodCall) async { - if (methodCall.method == 'getInitialAppLink') { - return 'https://email-verification'; - } - return null; - }, - ); - - when(() => mockAuthService.isLoggedIn()) - .thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pumpAndSettle(); - - expect(find.text('Home Page'), findsOneWidget); - }); - - testWidgets('ignores deep link with wrong host', (WidgetTester tester) async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('com.llfbandit.app_links/messages'), - (MethodCall methodCall) async { - if (methodCall.method == 'getInitialAppLink') { - return 'clean-stream://wrong-host'; - } - return null; - }, - ); - - when(() => mockAuthService.isLoggedIn()) - .thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pumpAndSettle(); - - expect(find.text('Home Page'), findsOneWidget); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('com.llfbandit.app_links/messages'), - null, - ); - }); - }); - - group('State Management Tests', () { - testWidgets('does not navigate if widgets is unmounted', (WidgetTester tester) async { - when(() => mockAuthService.isLoggedIn()) - .thenAnswer((_) async { - await Future.delayed(const Duration(milliseconds: 100)); - return AuthenticationResponses.success; - }); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pumpAndSettle(); - }); - }); - - group('UI Element Tests', () { - testWidgets('error icon has correct properties', (WidgetTester tester) async { - when(() => mockAuthService.isLoggedIn()) - .thenThrow(Exception('Error')); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pumpAndSettle(); - - final icon = tester.widget(find.byIcon(Icons.error_outline)); - expect(icon.color, Colors.redAccent); - expect(icon.size, 80); - }); - - testWidgets('logo has correct dimensions', (WidgetTester tester) async { - when(() => mockAuthService.isLoggedIn()) - .thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pump(); - - final image = tester.widget(find.byType(Image)); - expect(image.height, 250); - - await tester.pumpAndSettle(); - }); - - testWidgets('all text elements are present in error state', (WidgetTester tester) async { - when(() => mockAuthService.isLoggedIn()) - .thenThrow(Exception('Test error')); - - await tester.pumpWidget(createTestWidget(LoadingPage())); - await tester.pumpAndSettle(); - - expect(find.text('Authentication Failed'), findsOneWidget); - expect(find.text('Exception: Test error'), findsOneWidget); - expect(find.text('Return to Login'), findsOneWidget); - }); - }); -} \ No newline at end of file diff --git a/test/pages/login_page_test.dart b/test/pages/login_page_test.dart deleted file mode 100644 index fe42d43..0000000 --- a/test/pages/login_page_test.dart +++ /dev/null @@ -1,452 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/pages/login_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; -import 'mocks.dart'; - -void main() { - late MockAuthService mockAuthService; - late MockProfileService mockProfileService; - late FakeAppLinks fakeAppLinks; - - setUpAll(() { - registerFallbackValue(FakeUri()); - }); - - setUp(() { - mockAuthService = MockAuthService(); - mockProfileService = MockProfileService(); - fakeAppLinks = FakeAppLinks(); - - final getIt = GetIt.instance; - if (getIt.isRegistered()) { - getIt.unregister(); - } - - if (getIt.isRegistered()) { - getIt.unregister(); - } - - getIt.registerSingleton(mockAuthService); - getIt.registerSingleton(mockProfileService); - }); - - tearDown(() => GetIt.instance.reset()); - - Widget createWidgetUnderTest({String initialRoute = '/'}) { - final router = GoRouter( - initialLocation: initialRoute, - routes: [ - GoRoute( - path: '/', - builder: (context, state) => LoginScreen(appLinks: fakeAppLinks), - ), - GoRoute( - path: '/homePage', - builder: (context, state) => const Scaffold(body: Text('Home Page')), - ), - GoRoute( - path: '/email-Verification', - builder: (context, state) => - const Scaffold(body: Text('Email Verification')), - ), - GoRoute( - path: '/sign_up', - builder: (context, state) => - const Scaffold(body: Text('Sign Up Page')), - ), - ], - ); - return MaterialApp.router(routerConfig: router); - } - - void mockLoginResponse(AuthenticationResponses response) { - when( - () => mockAuthService.login(any(), any()), - ).thenAnswer((_) async => response); - } - - Future enterCredentials( - WidgetTester tester, { - String email = 'test@example.com', - String password = 'password123', - }) async { - await tester.enterText(find.widgetWithText(TextField, 'Email'), email); - await tester.enterText( - find.widgetWithText(TextField, 'Password'), - password, - ); - } - - group('LoginScreen Widget Tests', () { - group('UI Structure', () { - testWidgets('should display logo image', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final imageFinder = find.byKey(const Key('app_logo')); - expect(imageFinder, findsOneWidget); - - final image = tester.widget(imageFinder); - expect(image.height, 250); - expect(image.width, 250); - }); - - testWidgets('should have email and password text fields', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect(find.widgetWithText(TextField, 'Email'), findsOneWidget); - expect(find.widgetWithText(TextField, 'Password'), findsOneWidget); - }); - - testWidgets('should have proper icons in text fields', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.email), findsOneWidget); - expect(find.byIcon(Icons.lock), findsOneWidget); - }); - - testWidgets('should have login button', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect(find.widgetWithText(ElevatedButton, 'Log In'), findsOneWidget); - }); - - testWidgets('should have create account link', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect(find.text('Create Account'), findsOneWidget); - - final inkWell = find.ancestor( - of: find.text('Create Account'), - matching: find.byType(InkWell), - ); - expect(inkWell, findsOneWidget); - }); - - testWidgets('should have password field obscured', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final passwordField = tester.widget( - find.widgetWithText(TextField, 'Password'), - ); - expect(passwordField.obscureText, true); - }); - }); - - group('Login Functionality', () { - testWidgets('should show error when fields are empty', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); - await tester.pump(); - - expect(find.text('Please fill in both fields.'), findsOneWidget); - }); - - testWidgets('should show error when email is empty', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - await tester.enterText( - find.widgetWithText(TextField, 'Password'), - 'password123', - ); - await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); - await tester.pump(); - - expect(find.text('Please fill in both fields.'), findsOneWidget); - }); - - testWidgets('should show error when password is empty', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - await tester.enterText( - find.widgetWithText(TextField, 'Email'), - 'test@example.com', - ); - await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); - await tester.pump(); - - expect(find.text('Please fill in both fields.'), findsOneWidget); - }); - - testWidgets('should show logging in message', (tester) async { - mockLoginResponse(AuthenticationResponses.success); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - await enterCredentials(tester); - await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); - await tester.pump(); - - expect(find.text('Logging in as test@example.com...'), findsOneWidget); - }); - - testWidgets('should navigate to email verification on unverified email', ( - tester, - ) async { - mockLoginResponse(AuthenticationResponses.emailNotVerified); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - await enterCredentials(tester); - await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); - await tester.pumpAndSettle(); - - expect(find.text('Email Verification'), findsOneWidget); - }); - - testWidgets('should change colors to red on failed login', ( - tester, - ) async { - mockLoginResponse(AuthenticationResponses.failure); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - await enterCredentials(tester); - await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); - await tester.pumpAndSettle(); - - expect(find.text('Invalid Password or Email'), findsNWidgets(2)); - - final emailIcon = tester.widget(find.byIcon(Icons.email)); - expect(emailIcon.color, Colors.red); - - final lockIcon = tester.widget(find.byIcon(Icons.lock)); - expect(lockIcon.color, Colors.red); - }); - - testWidgets('should call auth service with correct credentials', ( - tester, - ) async { - mockLoginResponse(AuthenticationResponses.success); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - const email = 'test@example.com'; - const password = 'password123'; - - await enterCredentials(tester, email: email, password: password); - await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.login(email, password)).called(1); - }); - - testWidgets('should trim email before login', (tester) async { - mockLoginResponse(AuthenticationResponses.success); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - await enterCredentials(tester, email: ' test@example.com '); - await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); - await tester.pumpAndSettle(); - - verify( - () => mockAuthService.login('test@example.com', any()), - ).called(1); - }); - - testWidgets('should have Apple Sign In button', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect( - find.widgetWithText(ElevatedButton, 'Sign in with Apple'), - findsOneWidget, - ); - }); - - testWidgets('should have Google Sign In button', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect( - find.widgetWithText(ElevatedButton, 'Sign in with Google'), - findsOneWidget, - ); - }); - }); - - group('Navigation', () { - testWidgets( - 'should navigate to sign_up page when create account is tapped', - (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final createAccountFinder = find.text('Create Account'); - await tester.ensureVisible(createAccountFinder); - await tester.tap(find.text('Create Account')); - await tester.pumpAndSettle(); - - expect(find.text('Sign Up Page'), findsOneWidget); - }, - ); - }); - - group('Styling', () { - testWidgets('should have blue styled login button', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final button = tester.widget( - find.widgetWithText(ElevatedButton, 'Log In'), - ); - final buttonStyle = button.style; - - expect(buttonStyle?.backgroundColor?.resolve({}), Colors.blue); - expect(buttonStyle?.foregroundColor?.resolve({}), Colors.white); - }); - - testWidgets('should have underlined create account text', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final textWidget = tester.widget(find.text('Create Account')); - expect(textWidget.style?.color, Colors.blue); - expect(textWidget.style?.decoration, TextDecoration.underline); - }); - - testWidgets('should have rounded text field borders', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final emailField = tester.widget( - find.widgetWithText(TextField, 'Email'), - ); - final decoration = emailField.decoration as InputDecoration; - final border = decoration.border as OutlineInputBorder; - - expect(border.borderRadius, BorderRadius.circular(12)); - }); - }); - - group('Error State Persistence', () { - testWidgets('should maintain error colors after failed login', ( - tester, - ) async { - mockLoginResponse(AuthenticationResponses.failure); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - await enterCredentials(tester); - await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); - await tester.pumpAndSettle(); - - expect(find.text('Invalid Password or Email'), findsNWidgets(2)); - - final emailIcon = tester.widget(find.byIcon(Icons.email)); - expect(emailIcon.color, Colors.red); - - await tester.enterText( - find.widgetWithText(TextField, 'Invalid Password or Email').first, - 'newtest@example.com', - ); - await tester.pump(); - - final emailIconAfter = tester.widget(find.byIcon(Icons.email)); - expect(emailIconAfter.color, Colors.red); - }); - }); - - group('LoginScreen Deep Link Tests', () { - testWidgets('navigates to /homePage on email-verification link', ( - tester, - ) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - fakeAppLinks.emit(Uri.parse('clean-stream://email-verification')); - await tester.pumpAndSettle(); - - expect(find.text('Home Page'), findsOneWidget); - }); - - testWidgets('handles oauth link and successful login', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - when( - () => mockAuthService.getSessionFromURI(any()), - ).thenAnswer((_) async {}); - when( - () => mockAuthService.isLoggedIn(), - ).thenAnswer((_) async => AuthenticationResponses.success); - when(() => mockAuthService.getCurrentUser()).thenReturn( - User( - id: 'testId', - appMetadata: {}, - userMetadata: {'full_name': 'Test User'}, - aud: '', - createdAt: '', - ), - ); - when( - () => mockProfileService.createAccount( - id: any(named: 'id'), - name: any(named: 'name'), - ), - ).thenAnswer((_) async {}); - - fakeAppLinks.emit(Uri.parse('clean-stream://oauth')); - await tester.pumpAndSettle(); - - expect(find.text('Home Page'), findsOneWidget); - verify(() => mockAuthService.getSessionFromURI(any())).called(1); - verify(() => mockAuthService.isLoggedIn()).called(1); - verify(() => mockAuthService.getCurrentUser()).called(1); - verify( - () => - mockProfileService.createAccount(id: 'testId', name: 'Test User'), - ).called(1); - }); - }); - - group("Test for enter keystroke",() { - - testWidgets("Tests that handle_login was called when enter is clicked", (tester) async { - - when(() => mockAuthService.login(any(), any())).thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - await tester.enterText(find.byType(TextField).first, 'test@example.com'); - await tester.enterText(find.byType(TextField).last, 'password123'); - - await tester.pump(); - - // Simulate pressing Enter - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - - await tester.pumpAndSettle(); - - expect(find.textContaining('Logging in as test@example.com'), findsOneWidget); - - }); - - }); - }); -} diff --git a/test/pages/loyalty_card_page_test.dart b/test/pages/loyalty_card_page_test.dart deleted file mode 100644 index 7dd0578..0000000 --- a/test/pages/loyalty_card_page_test.dart +++ /dev/null @@ -1,888 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; -import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; -import 'package:clean_stream_laundry_app/pages/loyalty_card_page.dart'; -import 'package:clean_stream_laundry_app/widgets/base_page.dart'; -import 'package:clean_stream_laundry_app/features/loyalty/widgets/credit_card.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mocktail/mocktail.dart'; -import 'mocks.dart'; - -void main() { - late MockLoyaltyViewModel mockViewModel; - const singleTransaction = 'Test transaction'; - const firstTransaction = 'Test transaction 1'; - const secondTransaction = 'Test transaction 2'; - const thirdTransaction = 'Test transaction 3'; - const transactionHistory = [ - firstTransaction, - secondTransaction, - thirdTransaction, - ]; - - Finder findTransactionScrollable() { - return find.descendant( - of: find.byType(ListView), - matching: find.byType(Scrollable), - ); - } - - setUpAll(() { - // Register fallback values for mocktail - registerFallbackValue((_) {}); - }); - - setUp(() { - mockViewModel = MockLoyaltyViewModel(); - - // Register mock in GetIt - ensure it's completely reset first - final getIt = GetIt.instance; - if (getIt.isRegistered()) { - getIt.unregister(); - } - getIt.registerSingleton(mockViewModel); - - // Setup default property values - when(() => mockViewModel.isLoading).thenReturn(false); - when(() => mockViewModel.errorMessage).thenReturn(null); - when(() => mockViewModel.userName).thenReturn('Test User'); - when(() => mockViewModel.userBalance).thenReturn(25.50); - when(() => mockViewModel.recentTransactions).thenReturn([]); - when(() => mockViewModel.showPastTransactions).thenReturn(false); - - // Setup default method behaviors - when(() => mockViewModel.initialize()).thenAnswer((_) async => {}); - when(() => mockViewModel.fetchTransactions()).thenAnswer((_) async => {}); - when( - () => mockViewModel.toggleTransactionView(), - ).thenAnswer((_) async => {}); - when(() => mockViewModel.addListener(any())).thenReturn(null); - when(() => mockViewModel.removeListener(any())).thenReturn(null); - }); - - tearDown(() { - // Properly unregister after each test - final getIt = GetIt.instance; - if (getIt.isRegistered()) { - getIt.unregister(); - } - }); - - Widget createTestWidget(Widget child) { - return MaterialApp.router( - routerConfig: GoRouter( - routes: [ - GoRoute(path: '/', builder: (context, state) => child), - GoRoute( - path: '/scanner', - builder: (context, state) => const Scaffold(body: Text('Scanner')), - ), - GoRoute( - path: '/login', - builder: (context, state) => const Scaffold(body: Text('Login')), - ), - ], - ), - ); - } - - group('LoyaltyPage Initialization', () { - testWidgets('should call initialize on viewModel during initState', ( - tester, - ) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - - verify(() => mockViewModel.initialize()).called(1); - }); - - testWidgets('should add listener to viewModel', (tester) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - - verify(() => mockViewModel.addListener(any())).called(1); - }); - - testWidgets('should display BasePage when not loading', (tester) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.byType(BasePage), findsOneWidget); - }); - }); - - group('Content Display', () { - testWidgets('should display CreditCard with correct username', ( - tester, - ) async { - when(() => mockViewModel.userName).thenReturn('John Doe'); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.byType(CreditCard), findsOneWidget); - final creditCard = tester.widget(find.byType(CreditCard)); - expect(creditCard.username, equals('John Doe')); - }); - - testWidgets('should display default username when userName is null', ( - tester, - ) async { - when(() => mockViewModel.userName).thenReturn(null); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - final creditCard = tester.widget(find.byType(CreditCard)); - expect(creditCard.username, equals('John Doe')); - }); - - testWidgets('should display correct balance with proper formatting', ( - tester, - ) async { - when(() => mockViewModel.userBalance).thenReturn(42.75); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.text('Loyalty Balance: \$42.75'), findsOneWidget); - }); - - testWidgets('should display balance with two decimal places', ( - tester, - ) async { - when(() => mockViewModel.userBalance).thenReturn(100.0); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.text('Loyalty Balance: \$100.00'), findsOneWidget); - }); - - testWidgets('should display default balance when userBalance is null', ( - tester, - ) async { - when(() => mockViewModel.userBalance).thenReturn(null); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget); - }); - - testWidgets('should display Load card button with correct styling', ( - tester, - ) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.text('Load card'), findsOneWidget); - - final button = tester.widget( - find.widgetWithText(ElevatedButton, 'Load card'), - ); - - expect(button.onPressed, isNotNull); - }); - }); - - group('Transactions Display', () { - testWidgets('should show "No transactions found" when list is empty', ( - tester, - ) async { - when(() => mockViewModel.recentTransactions).thenReturn([]); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.text('No transactions found.'), findsOneWidget); - expect(find.text('Transactions'), findsNothing); - }); - - testWidgets('should display transaction header when transactions exist', ( - tester, - ) async { - when( - () => mockViewModel.recentTransactions, - ).thenReturn([singleTransaction]); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.text('Transactions'), findsOneWidget); - }); - - testWidgets('should display all transactions in list', (tester) async { - when( - () => mockViewModel.recentTransactions, - ).thenReturn(transactionHistory); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pumpAndSettle(); - - expect(find.text(firstTransaction, skipOffstage: false), findsOneWidget); - - await tester.scrollUntilVisible( - find.text(secondTransaction), - 100, - scrollable: findTransactionScrollable(), - ); - expect(find.text(secondTransaction), findsOneWidget); - - expect(find.text(thirdTransaction, skipOffstage: false), findsOneWidget); - }); - - testWidgets( - 'should display Show More button when showPastTransactions is false', - (tester) async { - when( - () => mockViewModel.recentTransactions, - ).thenReturn([singleTransaction]); - when(() => mockViewModel.showPastTransactions).thenReturn(false); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.text('Show More'), findsOneWidget); - expect(find.byIcon(Icons.expand_more), findsOneWidget); - }, - ); - - testWidgets( - 'should display Show Less button when showPastTransactions is true', - (tester) async { - when( - () => mockViewModel.recentTransactions, - ).thenReturn([singleTransaction]); - when(() => mockViewModel.showPastTransactions).thenReturn(true); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.text('Show Less'), findsOneWidget); - expect(find.byIcon(Icons.expand_less), findsOneWidget); - }, - ); - - testWidgets('should call toggleTransactionView when Show More is tapped', ( - tester, - ) async { - when( - () => mockViewModel.recentTransactions, - ).thenReturn([singleTransaction]); - when(() => mockViewModel.showPastTransactions).thenReturn(false); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Show More')); - await tester.pump(); - - verify(() => mockViewModel.toggleTransactionView()).called(1); - }); - - testWidgets('should call toggleTransactionView when Show Less is tapped', ( - tester, - ) async { - when( - () => mockViewModel.recentTransactions, - ).thenReturn([singleTransaction]); - when(() => mockViewModel.showPastTransactions).thenReturn(true); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Show Less')); - await tester.pump(); - - verify(() => mockViewModel.toggleTransactionView()).called(1); - }); - - testWidgets('should display transaction cards with correct styling', ( - tester, - ) async { - when( - () => mockViewModel.recentTransactions, - ).thenReturn([singleTransaction]); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.byType(Card, skipOffstage: false), findsWidgets); - expect( - find.byIcon(Icons.receipt_long, skipOffstage: false), - findsOneWidget, - ); - }); - }); - - group('Error Handling', () { - testWidgets('should show error dialog when errorMessage is set', ( - tester, - ) async { - when(() => mockViewModel.errorMessage).thenReturn('Test error message'); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); // Build initial frame - await tester.pump(); // Process post-frame callback - - expect(find.text('Error'), findsOneWidget); - expect(find.text('Test error message'), findsOneWidget); - expect(find.byIcon(Icons.error), findsOneWidget); - }); - - testWidgets( - 'should navigate to /scanner when error is "Failed to fetch balance"', - (tester) async { - when( - () => mockViewModel.errorMessage, - ).thenReturn('Failed to fetch balance'); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - await tester.pump(); - - expect(find.text('Failed to fetch balance'), findsOneWidget); - - await tester.tap(find.text('OK')); - await tester.pumpAndSettle(); - - expect(find.text('Scanner'), findsOneWidget); - }, - ); - - testWidgets('should navigate to /login for other errors', (tester) async { - when(() => mockViewModel.errorMessage).thenReturn('User not known'); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - await tester.pump(); - - expect(find.text('User not known'), findsOneWidget); - - await tester.tap(find.text('OK')); - await tester.pumpAndSettle(); - - expect(find.text('Login'), findsOneWidget); - }); - - testWidgets('should display error dialog only once', (tester) async { - when(() => mockViewModel.errorMessage).thenReturn('Something went wrong'); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - await tester.pump(); - - // Should only show one dialog with the title and message - expect(find.byType(AlertDialog), findsOneWidget); - expect(find.text('Error'), findsOneWidget); // Dialog title - expect( - find.text('Something went wrong'), - findsOneWidget, - ); // Error message - }); - }); - - group('Load Card Dialog', () { - testWidgets('should open dialog when Load card button is tapped', ( - tester, - ) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - expect(find.text('Load Loyalty Card'), findsOneWidget); - expect( - find.text('Select an amount to add to your card.'), - findsOneWidget, - ); - }); - - testWidgets('should initialize dialog with \$1.00 amount', (tester) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - expect(find.text('\$1.00'), findsOneWidget); - }); - - testWidgets('should increment amount by 25¢ when +25¢ is tapped', ( - tester, - ) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('+25¢')); - await tester.pumpAndSettle(); - - expect(find.text('\$1.25'), findsOneWidget); - }); - - testWidgets('should decrement amount by 25¢ when -25¢ is tapped', ( - tester, - ) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - // Increment first - await tester.tap(find.text('+25¢')); - await tester.pumpAndSettle(); - - // Then decrement - await tester.tap(find.text('-25¢')); - await tester.pumpAndSettle(); - - expect(find.text('\$1.00'), findsOneWidget); - }); - - testWidgets('should disable -25¢ button at minimum amount', (tester) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - final decrementButton = find.widgetWithText(OutlinedButton, '-25¢'); - final button = tester.widget(decrementButton); - - expect(button.onPressed, isNull); - }); - - testWidgets('should not go below \$1.00 minimum', (tester) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - // Try to decrement below minimum (button should be disabled) - final decrementButton = find.widgetWithText(OutlinedButton, '-25¢'); - await tester.tap(decrementButton); - await tester.pumpAndSettle(); - - expect(find.text('\$1.00'), findsOneWidget); - }); - - testWidgets('should not go above \$500.00 maximum', (tester) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - // Tap increment many times to reach max - final incrementButton = find.text('+25¢'); - for (int i = 0; i < 2000; i++) { - await tester.tap(incrementButton); - await tester.pump(); - } - await tester.pumpAndSettle(); - - expect(find.text('\$500.00'), findsOneWidget); - - // Button should be disabled - final button = tester.widget( - find.widgetWithText(OutlinedButton, '+25¢'), - ); - expect(button.onPressed, isNull); - }); - - testWidgets('should select \$10 when \$10 ChoiceChip is tapped', ( - tester, - ) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('\$10')); - await tester.pumpAndSettle(); - - expect(find.text('\$10.00'), findsOneWidget); - }); - - testWidgets('should select \$15 when \$15 ChoiceChip is tapped', ( - tester, - ) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('\$15')); - await tester.pumpAndSettle(); - - expect(find.text('\$15.00'), findsOneWidget); - }); - - testWidgets('should select \$25 when \$25 ChoiceChip is tapped', ( - tester, - ) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('\$25')); - await tester.pumpAndSettle(); - - expect(find.text('\$25.00'), findsOneWidget); - }); - - testWidgets('should update slider when amount changes', (tester) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - expect(find.byType(Slider), findsOneWidget); - - final slider = tester.widget(find.byType(Slider)); - expect(slider.value, equals(1.0)); - }); - - testWidgets('should close dialog when Cancel is tapped', (tester) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - expect(find.text('Load Loyalty Card'), findsOneWidget); - - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - - expect(find.text('Load Loyalty Card'), findsNothing); - }); - - testWidgets('should have Pay button in dialog', (tester) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - expect(find.widgetWithText(ElevatedButton, 'Pay'), findsOneWidget); - }); - }); - - group('Payment Handling', () { - testWidgets( - 'should call loadCard with correct amount on successful payment', - (tester) async { - when( - () => mockViewModel.loadCard(any()), - ).thenAnswer((_) async => PaymentResult.success); - when(() => mockViewModel.userBalance).thenReturn(25.0); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Pay')); - await tester.pumpAndSettle(); - - verify(() => mockViewModel.loadCard(1.0)).called(1); - }, - ); - - testWidgets('should show success dialog on successful payment', ( - tester, - ) async { - when( - () => mockViewModel.loadCard(any()), - ).thenAnswer((_) async => PaymentResult.success); - when(() => mockViewModel.userBalance).thenReturn(25.0); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Pay')); - await tester.pumpAndSettle(); - - expect(find.text('Payment Successful!'), findsOneWidget); - expect( - find.text( - 'Thank you! Your payment of \$1.00 was processed successfully.', - ), - findsOneWidget, - ); - expect(find.byIcon(Icons.check_circle), findsOneWidget); - }); - - testWidgets('should call fetchTransactions after successful payment', ( - tester, - ) async { - when( - () => mockViewModel.loadCard(any()), - ).thenAnswer((_) async => PaymentResult.success); - when(() => mockViewModel.userBalance).thenReturn(25.0); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Pay')); - await tester.pumpAndSettle(); - - verify(() => mockViewModel.fetchTransactions()).called(1); - }); - - testWidgets('should show canceled dialog when payment is canceled', ( - tester, - ) async { - when( - () => mockViewModel.loadCard(any()), - ).thenAnswer((_) async => PaymentResult.canceled); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Pay')); - await tester.pumpAndSettle(); - - expect(find.text('Payment Canceled'), findsOneWidget); - expect(find.text('Payment of \$1.00 was canceled.'), findsOneWidget); - expect(find.byIcon(Icons.error), findsOneWidget); - }); - - testWidgets('should show failed dialog when payment fails', (tester) async { - when( - () => mockViewModel.loadCard(any()), - ).thenAnswer((_) async => PaymentResult.failed); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Pay')); - await tester.pumpAndSettle(); - - expect(find.text('Payment Failed'), findsOneWidget); - expect( - find.text( - 'An error occurred while processing your payment. Please try again.', - ), - findsOneWidget, - ); - }); - - testWidgets('should close status dialog when Done is tapped', ( - tester, - ) async { - when( - () => mockViewModel.loadCard(any()), - ).thenAnswer((_) async => PaymentResult.success); - when(() => mockViewModel.userBalance).thenReturn(25.0); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Pay')); - await tester.pumpAndSettle(); - - expect(find.text('Payment Successful!'), findsOneWidget); - - await tester.tap(find.text('Done')); - await tester.pumpAndSettle(); - - expect(find.text('Payment Successful!'), findsNothing); - }); - - testWidgets('should handle custom amount payment', (tester) async { - when( - () => mockViewModel.loadCard(any()), - ).thenAnswer((_) async => PaymentResult.success); - when(() => mockViewModel.userBalance).thenReturn(25.0); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - // Set custom amount - await tester.tap(find.text('\$25')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Pay')); - await tester.pumpAndSettle(); - - verify(() => mockViewModel.loadCard(25.0)).called(1); - }); - - testWidgets('should not call fetchTransactions on failed payment', ( - tester, - ) async { - when( - () => mockViewModel.loadCard(any()), - ).thenAnswer((_) async => PaymentResult.failed); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Pay')); - await tester.pumpAndSettle(); - - verifyNever(() => mockViewModel.fetchTransactions()); - }); - - testWidgets('should not call fetchTransactions on canceled payment', ( - tester, - ) async { - when( - () => mockViewModel.loadCard(any()), - ).thenAnswer((_) async => PaymentResult.canceled); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.text('Load card')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Pay')); - await tester.pumpAndSettle(); - - verifyNever(() => mockViewModel.fetchTransactions()); - }); - }); - - group('Widget Lifecycle', () { - testWidgets('should remove listener on dispose', (tester) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - // Navigate away to dispose the widgets - await tester.pumpWidget(const MaterialApp(home: Scaffold())); - - verify(() => mockViewModel.removeListener(any())).called(1); - }); - }); - - group('Edge Cases', () { - testWidgets('should handle null userName gracefully', (tester) async { - when(() => mockViewModel.userName).thenReturn(null); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.byType(CreditCard), findsOneWidget); - // Should use default 'John Doe' - }); - - testWidgets('should handle null userBalance gracefully', (tester) async { - when(() => mockViewModel.userBalance).thenReturn(null); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget); - }); - - testWidgets('should handle empty transaction list', (tester) async { - when(() => mockViewModel.recentTransactions).thenReturn([]); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.text('No transactions found.'), findsOneWidget); - expect(find.byType(ListView), findsNothing); - }); - - testWidgets('should handle very large balance', (tester) async { - when(() => mockViewModel.userBalance).thenReturn(9999.99); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.text('Loyalty Balance: \$9999.99'), findsOneWidget); - }); - - testWidgets('should handle zero balance', (tester) async { - when(() => mockViewModel.userBalance).thenReturn(0.0); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget); - }); - }); - group('Reward Info Dialog', () { - testWidgets('should display info button next to reward text', ( - tester, - ) async { - when(() => mockViewModel.userReward).thenReturn(5.0); - - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.byIcon(Icons.info_outline), findsOneWidget); - }); - - testWidgets('should open reward info dialog when info button is tapped', ( - tester, - ) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.byIcon(Icons.info_outline)); - await tester.pumpAndSettle(); - - expect(find.text('Rewards program'), findsOneWidget); - expect( - find.text( - 'For every \$20 you spend, you get an extra \$5 automatically added to your loyalty balance.', - ), - findsOneWidget, - ); - }); - - testWidgets('should close reward info dialog when Got it is tapped', ( - tester, - ) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - await tester.tap(find.byIcon(Icons.info_outline)); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Got it')); - await tester.pumpAndSettle(); - - expect(find.text('Rewards program'), findsNothing); - }); - }); -} diff --git a/test/pages/mocks.dart b/test/pages/mocks.dart deleted file mode 100644 index a6dee91..0000000 --- a/test/pages/mocks.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'dart:async'; - -import 'package:app_links/app_links.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; -import 'package:clean_stream_laundry_app/middleware/app_router.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme_manager.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; -import 'package:clean_stream_laundry_app/services/notification_service.dart'; -import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; -import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; -import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; - -class MockAuthService extends Mock implements AuthService {} - -class MockTransactionService extends Mock implements TransactionService {} - -class MockLocationService extends Mock implements LocationService {} - -class MockMachineService extends Mock implements MachineService {} - -class MockDoorUnlocker extends Mock implements DoorUnlocker {} - -class MockThemeManager extends Mock implements ThemeManager {} - -class MockProfileService extends Mock implements ProfileService {} - -class MockRouterService extends Mock implements RouterService {} - -class MockLoyaltyViewModel extends Mock implements LoyaltyViewModel {} - -class MockPaymentProcessor extends Mock implements PaymentProcessor {} - -class MockMachineCommunicationService extends Mock - implements MachineCommunicationService {} - -class FakeAuthService extends Fake implements AuthService {} - -class MockEdgeFunctionService extends Mock implements EdgeFunctionService {} - -class MockNotificationService extends Mock implements NotificationService {} - -class FakeUri extends Fake implements Uri {} - -class FakeAppLinks extends Fake implements AppLinks { - final StreamController _controller = StreamController.broadcast(); - - @override - Stream get uriLinkStream => _controller.stream; - - /// Helper to emit a deep link in tests - void emit(Uri uri) { - _controller.add(uri); - } - - void dispose() { - _controller.close(); - } -} diff --git a/test/pages/monthly_transaction_history_test.dart b/test/pages/monthly_transaction_history_test.dart deleted file mode 100644 index 0bd75d2..0000000 --- a/test/pages/monthly_transaction_history_test.dart +++ /dev/null @@ -1,634 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; -import 'package:clean_stream_laundry_app/pages/monthly_transaction_history.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; -import 'mocks.dart'; - -void main() { - late MockAuthService mockAuthService; - late MockTransactionService mockTransactionService; - late GoRouter router; - - setUp(() { - mockAuthService = MockAuthService(); - mockTransactionService = MockTransactionService(); - - final getIt = GetIt.instance; - if (getIt.isRegistered()) { - getIt.unregister(); - } - if (getIt.isRegistered()) { - getIt.unregister(); - } - getIt.registerSingleton(mockAuthService); - getIt.registerSingleton(mockTransactionService); - }); - - tearDown(() => GetIt.instance.reset()); - - /// Helper function to create a transaction in the past month - Map createTransaction({ - required int monthsAgo, - required String description, - required double amount, - }) { - final now = DateTime.now(); - final date = DateTime(now.year, now.month - monthsAgo, 15); - return { - 'created_at': date.toIso8601String(), - 'description': description, - 'amount': amount, - }; - } - - Widget createWidgetUnderTest(List> transactions) { - router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => - MonthlyTransactionHistory(transactions: transactions), - ), - ], - ); - return MaterialApp.router(routerConfig: router); - } - - Future selectYearFilter(WidgetTester tester, int year) async { - await tester.tap(find.byKey(const ValueKey('year-filter-button'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(ValueKey('year-option-$year'))); - await tester.pumpAndSettle(); - } - - group('MonthlyTransactionHistory Widget Tests', () { - testWidgets('renders AppBar title even when transactions empty', ( - WidgetTester tester, - ) async { - await tester.pumpWidget(createWidgetUnderTest([])); - await tester.pumpAndSettle(); - - expect(find.text('Monthly Transaction History'), findsOneWidget); - }); - - testWidgets('renders AppBar with back button', (WidgetTester tester) async { - await tester.pumpWidget(createWidgetUnderTest([])); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.arrow_back), findsOneWidget); - }); - - testWidgets('back button pops the route', (WidgetTester tester) async { - await tester.pumpWidget(createWidgetUnderTest([])); - await tester.pumpAndSettle(); - - await tester.tap(find.byIcon(Icons.arrow_back)); - await tester.pumpAndSettle(); - - expect(tester.takeException(), isNotNull); - }); - - testWidgets('displays no cards when transactions are empty', ( - WidgetTester tester, - ) async { - await tester.pumpWidget(createWidgetUnderTest([])); - await tester.pumpAndSettle(); - - expect(find.byType(Card), findsNothing); - }); - - testWidgets('displays card for month with transactions', ( - WidgetTester tester, - ) async { - final transactions = [ - createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - final transactionYear = DateTime.parse( - transactions.first['created_at'] as String, - ).year; - await selectYearFilter(tester, transactionYear); - - expect(find.byType(Card), findsOneWidget); - }); - - testWidgets('displays correct monthly total', (WidgetTester tester) async { - final transactions = [ - createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), - createTransaction(monthsAgo: 1, description: 'Dryer #3', amount: 1.75), - createTransaction( - monthsAgo: 1, - description: 'loyalty card', - amount: 10.00, - ), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$14.25'), findsOneWidget); - }); - - testWidgets('displays all transaction categories', ( - WidgetTester tester, - ) async { - final transactions = [ - createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Washer #3', - amount: 2.00, - ), - createTransaction(monthsAgo: 1, description: 'Dryer #2', amount: 1.75), - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Dryer #1', - amount: 1.50, - ), - createTransaction( - monthsAgo: 1, - description: 'loyalty card', - amount: 10.00, - ), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('Direct Washer Payments'), findsOneWidget); - expect(find.text('Loyalty Washer Payments'), findsOneWidget); - expect(find.text('Direct Dryer Payments'), findsOneWidget); - expect(find.text('Loyalty Dryer Payments'), findsOneWidget); - expect(find.text('Loyalty Card Loads'), findsOneWidget); - }); - - testWidgets('displays correct amounts for each category', ( - WidgetTester tester, - ) async { - final transactions = [ - createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), - createTransaction(monthsAgo: 1, description: 'Washer #3', amount: 3.00), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$5.50'), findsWidgets); - }); - - testWidgets('sorts months in descending order', ( - WidgetTester tester, - ) async { - final transactions = [ - createTransaction(monthsAgo: 3, description: 'Washer #5', amount: 2.50), - createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 3.00), - createTransaction(monthsAgo: 2, description: 'Washer #5', amount: 2.75), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - final cardFinder = find.byType(Card); - expect(cardFinder, findsAtLeastNWidgets(2)); - - final firstCard = cardFinder.first; - final firstCardTexts = find.descendant( - of: firstCard, - matching: find.byType(Text), - ); - expect(firstCardTexts, findsAtLeastNWidgets(1)); - - final expectedMostRecent = DateFormat( - 'MMM yyyy', - ).format(DateTime(DateTime.now().year, DateTime.now().month - 1, 1)); - expect( - find.descendant(of: firstCard, matching: find.text(expectedMostRecent)), - findsOneWidget, - ); - }); - - testWidgets('displays scrollbar', (WidgetTester tester) async { - final transactions = [ - createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - expect(find.byType(Scrollbar), findsOneWidget); - }); - - testWidgets('displays ListView with proper padding', ( - WidgetTester tester, - ) async { - final transactions = [ - createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - final listView = tester.widget(find.byType(ListView)); - expect(listView.padding, const EdgeInsets.all(16)); - }); - - testWidgets('displays multiple months correctly', ( - WidgetTester tester, - ) async { - final transactions = [ - createTransaction(monthsAgo: 3, description: 'Washer #5', amount: 2.50), - createTransaction(monthsAgo: 2, description: 'Washer #5', amount: 3.00), - createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 3.50), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - expect(find.byType(Card), findsAtLeastNWidgets(2)); - }); - - testWidgets('displays divider between month and transaction details', ( - WidgetTester tester, - ) async { - final transactions = [ - createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - expect(find.byType(Divider), findsOneWidget); - }); - - testWidgets('displays zero amounts when no transactions of that type', ( - WidgetTester tester, - ) async { - final transactions = [ - createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - final zeroAmountFinder = find.text('\$0.00'); - expect(zeroAmountFinder, findsWidgets); - }); - - testWidgets('card has proper margin', (WidgetTester tester) async { - final transactions = [ - createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - final card = tester.widget(find.byType(Card)); - expect(card.margin, const EdgeInsets.only(bottom: 16)); - }); - - testWidgets('handles loyalty washer payments correctly', ( - WidgetTester tester, - ) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Washer #5', - amount: 2.50, - ), - createTransaction( - monthsAgo: 1, - description: 'loyalty payment on washer #3', - amount: 3.00, - ), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$5.50'), findsWidgets); - }); - - testWidgets('handles loyalty dryer payments correctly', ( - WidgetTester tester, - ) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Dryer #2', - amount: 1.50, - ), - createTransaction( - monthsAgo: 1, - description: 'loyalty payment on dryer #1', - amount: 1.25, - ), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$2.75'), findsWidgets); - }); - - testWidgets('handles direct dryer payments correctly', ( - WidgetTester tester, - ) async { - final transactions = [ - createTransaction(monthsAgo: 1, description: 'Dryer #2', amount: 1.50), - createTransaction(monthsAgo: 1, description: 'DRYER #1', amount: 1.25), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$2.75'), findsWidgets); - }); - - testWidgets('handles loyalty card loads correctly', ( - WidgetTester tester, - ) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'loyalty card', - amount: 10.00, - ), - createTransaction( - monthsAgo: 1, - description: 'loyalty card', - amount: 20.00, - ), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$30.00'), findsWidgets); - }); - - testWidgets('displays formatted decimal amounts correctly', ( - WidgetTester tester, - ) async { - final transactions = [ - createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.5), - createTransaction(monthsAgo: 1, description: 'Dryer #3', amount: 1.76), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$2.50'), findsWidgets); - expect(find.text('\$1.76'), findsWidgets); - }); - - testWidgets('multiple transactions in same month aggregate correctly', ( - WidgetTester tester, - ) async { - final transactions = [ - createTransaction(monthsAgo: 1, description: 'Washer #1', amount: 2.50), - createTransaction(monthsAgo: 1, description: 'Washer #2', amount: 3.00), - createTransaction(monthsAgo: 1, description: 'Washer #3', amount: 2.75), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$8.25'), findsWidgets); - expect(find.byType(Card), findsOneWidget); - }); - - testWidgets('ignores current month transactions', ( - WidgetTester tester, - ) async { - final now = DateTime.now(); - final transactions = [ - { - 'created_at': DateTime(now.year, now.month, 15).toIso8601String(), - 'description': 'Washer #5', - 'amount': 2.50, - }, - createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 3.00), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - final transactionYear = DateTime.parse( - transactions.first['created_at'] as String, - ).year; - await selectYearFilter(tester, transactionYear); - - expect(find.byType(Card), findsOneWidget); - expect(find.text('\$3.00'), findsExactly(2)); - }); - - testWidgets('handles transactions from exactly 11 months ago', ( - WidgetTester tester, - ) async { - final transactions = [ - createTransaction( - monthsAgo: 11, - description: 'Washer #5', - amount: 2.50, - ), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - final transactionYear = DateTime.parse( - transactions.first['created_at'] as String, - ).year; - await selectYearFilter(tester, transactionYear); - - expect(find.byType(Card), findsOneWidget); - }); - - testWidgets('handles mixed transaction types in same month', ( - WidgetTester tester, - ) async { - final transactions = [ - createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Washer #3', - amount: 2.00, - ), - createTransaction(monthsAgo: 1, description: 'Dryer #2', amount: 1.75), - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Dryer #1', - amount: 1.50, - ), - createTransaction( - monthsAgo: 1, - description: 'loyalty card', - amount: 10.00, - ), - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$14.25'), findsOneWidget); - expect(find.text('\$2.50'), findsWidgets); - expect(find.text('\$2.00'), findsWidgets); - expect(find.text('\$1.75'), findsWidgets); - expect(find.text('\$1.50'), findsWidgets); - expect(find.text('\$10.00'), findsWidgets); - }); - - testWidgets('shows year filter options from transaction years', ( - WidgetTester tester, - ) async { - final newerDate = DateTime( - DateTime.now().year, - DateTime.now().month - 1, - 15, - ); - final olderDate = DateTime( - DateTime.now().year, - DateTime.now().month - 11, - 15, - ); - - final expectedYears = {newerDate.year, olderDate.year}.toList() - ..sort((a, b) => b.compareTo(a)); - - final transactions = [ - { - 'created_at': olderDate.toIso8601String(), - 'description': 'Washer #1', - 'amount': 2.50, - }, - { - 'created_at': newerDate.toIso8601String(), - 'description': 'Dryer #1', - 'amount': 1.75, - }, - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - expect(find.byKey(const ValueKey('year-filter-button')), findsOneWidget); - - await tester.tap(find.byKey(const ValueKey('year-filter-button'))); - await tester.pumpAndSettle(); - - expect(find.text('Year: ${expectedYears.first}'), findsOneWidget); - - for (final year in expectedYears) { - expect(find.byKey(ValueKey('year-option-$year')), findsOneWidget); - } - - await tester.tap( - find.byKey(ValueKey('year-option-${expectedYears.first}')), - ); - await tester.pumpAndSettle(); - }); - - testWidgets('selecting a year filter shows that year data', ( - WidgetTester tester, - ) async { - final newerDate = DateTime( - DateTime.now().year, - DateTime.now().month - 1, - 15, - ); - final olderDate = DateTime( - DateTime.now().year, - DateTime.now().month - 11, - 15, - ); - final newerYear = newerDate.year; - final olderYear = olderDate.year; - final olderMonthLabel = DateFormat( - 'MMM yyyy', - ).format(DateTime(olderDate.year, olderDate.month, 1)); - final newerMonthLabel = DateFormat( - 'MMM yyyy', - ).format(DateTime(newerDate.year, newerDate.month, 1)); - - final transactions = [ - { - 'created_at': olderDate.toIso8601String(), - 'description': 'Washer #5', - 'amount': 2.50, - }, - { - 'created_at': newerDate.toIso8601String(), - 'description': 'Dryer #2', - 'amount': 3.00, - }, - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - if (olderYear == newerYear) { - expect(find.text(newerMonthLabel), findsOneWidget); - expect(find.text(olderMonthLabel), findsOneWidget); - return; - } - - expect(find.text(newerMonthLabel), findsOneWidget); - expect(find.text(olderMonthLabel), findsNothing); - - await selectYearFilter(tester, olderYear); - - expect(find.text(olderMonthLabel), findsOneWidget); - expect(find.text(newerMonthLabel), findsNothing); - }); - - testWidgets('can switch between year filters without errors', ( - WidgetTester tester, - ) async { - final newerDate = DateTime( - DateTime.now().year, - DateTime.now().month - 1, - 15, - ); - final olderDate = DateTime( - DateTime.now().year, - DateTime.now().month - 11, - 15, - ); - final olderYear = olderDate.year; - final newerYear = newerDate.year; - - final transactions = [ - { - 'created_at': olderDate.toIso8601String(), - 'description': 'Dryer #2', - 'amount': 1.75, - }, - { - 'created_at': newerDate.toIso8601String(), - 'description': 'Washer #2', - 'amount': 2.25, - }, - ]; - - await tester.pumpWidget(createWidgetUnderTest(transactions)); - await tester.pumpAndSettle(); - - await selectYearFilter(tester, olderYear); - - expect(tester.takeException(), isNull); - - if (olderYear != newerYear) { - await selectYearFilter(tester, newerYear); - } - - expect(tester.takeException(), isNull); - }); - }); -} \ No newline at end of file diff --git a/test/pages/not_found_page_test.dart b/test/pages/not_found_page_test.dart deleted file mode 100644 index ba395f0..0000000 --- a/test/pages/not_found_page_test.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/pages/not_found_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mocktail/mocktail.dart'; -import 'mocks.dart'; - -void main() { - late MockAuthService mockAuthService; - - setUp(() { - mockAuthService = MockAuthService(); - - // Default stubs to prevent null errors - when( - () => mockAuthService.isLoggedIn(), - ).thenAnswer((_) async => AuthenticationResponses.failure); - when(() => mockAuthService.isEmailVerified()).thenReturn(false); - when( - () => mockAuthService.login(any(), any()), - ).thenAnswer((_) async => AuthenticationResponses.failure); - when( - () => mockAuthService.signUp(any(), any(), any()), - ).thenAnswer((_) async => AuthenticationResponses.failure); - when( - () => mockAuthService.resendVerification(), - ).thenAnswer((_) async => AuthenticationResponses.failure); - when( - () => mockAuthService.logout(), - ).thenAnswer((_) async => Future.value()); - when(() => mockAuthService.getCurrentUserId).thenReturn(null); - when( - () => mockAuthService.onAuthChange, - ).thenAnswer((_) => const Stream.empty()); - - final getIt = GetIt.instance; - if (getIt.isRegistered()) { - getIt.unregister(); - } - - getIt.registerSingleton(mockAuthService); - }); - - tearDown(() => GetIt.instance.reset()); - - Widget createWidgetUnderTest() { - final router = GoRouter( - routes: [ - GoRoute(path: '/', builder: (context, state) => const NotFoundScreen()), - GoRoute( - path: '/homePage', - builder: (context, state) => const Scaffold(body: Text('Home Page')), - ), - GoRoute( - path: '/login', - builder: (context, state) => const Scaffold(body: Text('Login Page')), - ), - ], - ); - return MaterialApp.router(routerConfig: router); - } - - void mockAuthResponse(AuthenticationResponses response) { - when(() => mockAuthService.isLoggedIn()).thenAnswer((_) async => response); - } - - group('NotFoundScreen Widget Tests', () { - testWidgets('should display 404 error icon', (WidgetTester tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - - expect(find.byIcon(Icons.error_outline), findsOneWidget); - - final icon = tester.widget(find.byIcon(Icons.error_outline)); - expect(icon.size, 80); - expect(icon.color, Colors.redAccent); - }); - - testWidgets('should display 404 - Page Not Found text', ( - WidgetTester tester, - ) async { - await tester.pumpWidget(createWidgetUnderTest()); - - expect(find.text('404 - Page Not Found'), findsOneWidget); - - final text = tester.widget(find.text('404 - Page Not Found')); - expect(text.style?.fontSize, 24); - expect(text.style?.color, Colors.blue); - }); - - testWidgets('should display Go to Home button', ( - WidgetTester tester, - ) async { - await tester.pumpWidget(createWidgetUnderTest()); - - expect(find.text('Go to Home'), findsOneWidget); - expect(find.byType(ElevatedButton), findsOneWidget); - }); - - testWidgets('should navigate to home page when user is logged in', ( - WidgetTester tester, - ) async { - mockAuthResponse(AuthenticationResponses.success); - - await tester.pumpWidget(createWidgetUnderTest()); - - await tester.tap(find.text('Go to Home')); - await tester.pumpAndSettle(); - - expect(find.text('Home Page'), findsOneWidget); - verify(() => mockAuthService.isLoggedIn()).called(1); - }); - - testWidgets('should navigate to login page when user is not logged in', ( - WidgetTester tester, - ) async { - mockAuthResponse(AuthenticationResponses.failure); - - await tester.pumpWidget(createWidgetUnderTest()); - - await tester.tap(find.text('Go to Home')); - await tester.pumpAndSettle(); - - expect(find.text('Login Page'), findsOneWidget); - verify(() => mockAuthService.isLoggedIn()).called(1); - }); - - testWidgets('Go to Home button should be tappable', ( - WidgetTester tester, - ) async { - mockAuthResponse(AuthenticationResponses.success); - - await tester.pumpWidget(createWidgetUnderTest()); - - final button = tester.widget(find.byType(ElevatedButton)); - expect(button.onPressed, isNotNull); - }); - - testWidgets('should handle auth check when button is pressed', ( - WidgetTester tester, - ) async { - mockAuthResponse(AuthenticationResponses.success); - - await tester.pumpWidget(createWidgetUnderTest()); - - await tester.tap(find.text('Go to Home')); - await tester.pump(); - - verify(() => mockAuthService.isLoggedIn()).called(1); - }); - }); -} diff --git a/test/pages/password_reset_test.dart b/test/pages/password_reset_test.dart deleted file mode 100644 index 49ea85b..0000000 --- a/test/pages/password_reset_test.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/pages/password_reset.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mocktail/mocktail.dart'; -import 'mocks.dart'; - -void main() { - late MockAuthService mockAuthService; - - setUp(() { - mockAuthService = MockAuthService(); - - final getIt = GetIt.instance; - if (getIt.isRegistered()) { - getIt.unregister(); - } - - getIt.registerSingleton(mockAuthService); - }); - - tearDown(() => GetIt.instance.reset()); - - Widget createWidgetUnderTest() { - final router = GoRouter( - initialLocation: '/password-reset', - routes: [ - GoRoute( - path: '/password-reset', - builder: (context, state) => const PasswordResetPage(), - ), - GoRoute( - path: '/login', - builder: (context, state) => const Scaffold(body: Text('Login Page')), - ), - ], - ); - - return MaterialApp.router(routerConfig: router); - } - - testWidgets('renders reset password UI', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect(find.text('Reset Password'), findsOneWidget); - expect(find.text('Forgot your password?'), findsOneWidget); - expect(find.text('Send Reset Link'), findsOneWidget); - expect(find.text('Back to Login'), findsOneWidget); - expect(find.byType(TextFormField), findsOneWidget); - }); - - testWidgets('validates empty email', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Send Reset Link')); - await tester.pumpAndSettle(); - - expect(find.text('Please enter your email'), findsOneWidget); - }); - - testWidgets('sends reset email and shows success message', (tester) async { - when( - () => mockAuthService.resetPassword(any()), - ).thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - await tester.enterText(find.byType(TextFormField), 'test@example.com'); - await tester.tap(find.text('Send Reset Link')); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.resetPassword('test@example.com')).called(1); - expect( - find.text('Password reset email sent! Check your email.'), - findsOneWidget, - ); - }); - - testWidgets('shows failure message when reset fails', (tester) async { - when( - () => mockAuthService.resetPassword(any()), - ).thenAnswer((_) async => AuthenticationResponses.failure); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - await tester.enterText(find.byType(TextFormField), 'test@example.com'); - await tester.tap(find.text('Send Reset Link')); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.resetPassword('test@example.com')).called(1); - expect(find.text('Failed to send reset email.'), findsOneWidget); - }); - - testWidgets('shows error message when reset throws', (tester) async { - when( - () => mockAuthService.resetPassword(any()), - ).thenThrow(Exception('network')); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - await tester.enterText(find.byType(TextFormField), 'test@example.com'); - await tester.tap(find.text('Send Reset Link')); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.resetPassword('test@example.com')).called(1); - expect(find.textContaining('Error:'), findsOneWidget); - }); - - testWidgets('back to login navigates to login page', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Back to Login')); - await tester.pumpAndSettle(); - - expect(find.text('Login Page'), findsOneWidget); - }); - - testWidgets('back arrow navigates to login page', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - await tester.tap(find.byIcon(Icons.arrow_back)); - await tester.pumpAndSettle(); - - expect(find.text('Login Page'), findsOneWidget); - }); -} diff --git a/test/pages/payment_page_test.dart b/test/pages/payment_page_test.dart deleted file mode 100644 index 8c48297..0000000 --- a/test/pages/payment_page_test.dart +++ /dev/null @@ -1,391 +0,0 @@ -import 'package:clean_stream_laundry_app/pages/payment_page.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; -import 'package:clean_stream_laundry_app/services/notification_service.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:mocktail/mocktail.dart'; -import 'mocks.dart'; -import 'package:go_router/go_router.dart'; -import 'package:clean_stream_laundry_app/middleware/app_router.dart'; -import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; -import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; - -void main() { - late MockAuthService mockAuthService; - late MockMachineService mockMachineService; - late MockProfileService mockProfileService; - late MockTransactionService mockTransactionService; - late MockMachineCommunicationService mockMachineCommunicator; - late MockRouterService mockRouterService; - late MockNotificationService mockNotificationService; - late MockPaymentProcessor mockPaymentProcessor; - late MockLoyaltyViewModel mockLoyaltyViewModel; - - setUpAll(() { - // Register fallback values for mocktail - registerFallbackValue(FakeAuthService()); - registerFallbackValue(const Duration(seconds: 1)); - }); - - setUp(() { - mockAuthService = MockAuthService(); - mockMachineService = MockMachineService(); - mockProfileService = MockProfileService(); - mockTransactionService = MockTransactionService(); - mockMachineCommunicator = MockMachineCommunicationService(); - mockRouterService = MockRouterService(); - mockNotificationService = MockNotificationService(); - mockPaymentProcessor = MockPaymentProcessor(); - mockLoyaltyViewModel = MockLoyaltyViewModel(); - - final getIt = GetIt.instance; - getIt.registerSingleton(mockAuthService); - getIt.registerSingleton(mockMachineService); - getIt.registerSingleton(mockProfileService); - getIt.registerSingleton(mockTransactionService); - getIt.registerSingleton( - mockMachineCommunicator, - ); - getIt.registerSingleton(mockRouterService); - getIt.registerSingleton(mockNotificationService); - - when(() => mockNotificationService.scheduleEarlyMachineNotification( - id: any(named: 'id'), - machineTime: any(named: 'machineTime'), - machineName: any(named: 'machineName'), - )).thenAnswer((_) async {}); - - getIt.registerSingleton(mockPaymentProcessor); - getIt.registerSingleton(mockLoyaltyViewModel); - }); - - tearDown(() { - GetIt.instance.reset(); - }); - - Widget createTestWidget(String machineId) { - final router = GoRouter( - initialLocation: '/payment', - routes: [ - GoRoute( - path: '/payment', - builder: (context, state) => PaymentPage(machineId: machineId), - ), - ], - ); - - return MaterialApp.router(routerConfig: router); - } - - group('PaymentPage Initialization', () { - testWidgets('displays loading indicator initially', (tester) async { - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - - when(() => mockMachineService.getMachineById(any())).thenAnswer(( - _, - ) async { - await Future.delayed(Duration(milliseconds: 10)); - return {'Name': 'Washer01', 'Price': 3.50}; - }); - - when(() => mockProfileService.getUserBalanceById(any())).thenAnswer(( - _, - ) async { - await Future.delayed(Duration(milliseconds: 10)); - return {'balance': 10.0}; - }); - - await tester.pumpWidget(createTestWidget('machine123')); - - // Loader should appear on the first frame - expect(find.byType(CircularProgressIndicator), findsOneWidget); - - // Now advance async tasks - await tester.pumpAndSettle(); - - // Loader should be gone after data loads - expect(find.byType(CircularProgressIndicator), findsNothing); - }); - - testWidgets('displays machine information after loading', ( - WidgetTester tester, - ) async { - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - when( - () => mockMachineService.getMachineById('machine123'), - ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); - when( - () => mockProfileService.getUserBalanceById('user123'), - ).thenAnswer((_) async => {'balance': 10.0}); - - await tester.pumpWidget(createTestWidget('machine123')); - await tester.pumpAndSettle(); - - expect(find.text('Machine Washer01'), findsOneWidget); - expect(find.text('Amount Due'), findsOneWidget); - }); - - testWidgets('handles null user ID gracefully', (WidgetTester tester) async { - when(() => mockAuthService.getCurrentUserId).thenReturn(null); - when( - () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); - - await tester.pumpWidget(createTestWidget('machine123')); - await tester.pump(); - - verify(() => mockAuthService.getCurrentUserId).called(1); - verifyNever(() => mockProfileService.getUserBalanceById(any())); - }); - - testWidgets('handles machine not found error', (WidgetTester tester) async { - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - when( - () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => null); - when( - () => mockProfileService.getUserBalanceById(any()), - ).thenAnswer((_) async => {'balance': 10.0}); - - await tester.pumpWidget(createTestWidget('machine123')); - await tester.pumpAndSettle(); - - expect(find.text('Machine Unknown'), findsOneWidget); - expect(find.text('\$0.00'), findsOneWidget); - }); - }); - - group('payment Buttons', () { - testWidgets('displays both payment buttons', (WidgetTester tester) async { - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - when( - () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 3.50}); - when( - () => mockProfileService.getUserBalanceById(any()), - ).thenAnswer((_) async => {'balance': 10.0}); - - await tester.pumpWidget(createTestWidget('machine123')); - await tester.pumpAndSettle(); - - expect(find.text('Pay \$1.50'), findsOneWidget); - expect(find.text('Pay with Loyalty'), findsOneWidget); - }); - - testWidgets('disables loyalty button when balance is insufficient', ( - WidgetTester tester, - ) async { - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - when( - () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 1.50}); - when(() => mockProfileService.getUserBalanceById(any())).thenAnswer( - (_) async => { - 'balance': 1.0, // Insufficient balance - }, - ); - - await tester.pumpWidget(createTestWidget('machine123')); - await tester.pumpAndSettle(); - - final loyaltyButton = find.widgetWithText( - ElevatedButton, - 'Pay with Loyalty', - ); - final button = tester.widget(loyaltyButton); - - expect(button.onPressed, isNull); - }); - - testWidgets('enables loyalty button when balance is sufficient', ( - WidgetTester tester, - ) async { - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - when( - () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 3.50}); - when( - () => mockProfileService.getUserBalanceById(any()), - ).thenAnswer((_) async => {'balance': 10.0}); - - await tester.pumpWidget(createTestWidget('machine123')); - await tester.pumpAndSettle(); - - final loyaltyButton = find.widgetWithText( - ElevatedButton, - 'Pay with Loyalty', - ); - final button = tester.widget(loyaltyButton); - - expect(button.onPressed, isNotNull); - }); - }); - - group('Loyalty payment Processing', () { - testWidgets('processes loyalty payment successfully', ( - WidgetTester tester, - ) async { - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - when( - () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 1.50}); - when( - () => mockProfileService.getUserBalanceById(any()), - ).thenAnswer((_) async => {'balance': 10.0}); - when( - () => mockProfileService.updateBalanceById('user123', any()), - ).thenAnswer((_) async => {}); - when( - () => mockMachineCommunicator.wakeDevice(any()), - ).thenAnswer((_) async => true); - when( - () => mockTransactionService.recordTransaction( - amount: any(named: 'amount'), - description: any(named: 'description'), - type: any(named: 'type'), - ), - ).thenAnswer((_) async => {}); - - await tester.pumpWidget(createTestWidget('machine123')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Pay with Loyalty')); - await tester.pump(); - await tester.pumpAndSettle(); - - verify(() => mockProfileService.updateBalanceById("user123", 8.5)).called(1); - verify(() => mockMachineCommunicator.wakeDevice('machine123')).called(1); - verify( - () => mockTransactionService.recordTransaction( - amount: 1.50, - description: any(named: 'description'), - type: 'laundry', - ), - ).called(1); - }); - - testWidgets('handles machine wake failure in loyalty payment', ( - WidgetTester tester, - ) async { - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - when( - () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 1.50}); - when( - () => mockProfileService.getUserBalanceById(any()), - ).thenAnswer((_) async => {'balance': 10.0}); - when( - () => mockProfileService.updateBalanceById('user123', any()), - ).thenAnswer((_) async => {}); - when( - () => mockMachineCommunicator.wakeDevice(any()), - ).thenAnswer((_) async => false); - when( - () => mockTransactionService.recordTransaction( - amount: any(named: 'amount'), - description: any(named: 'description'), - type: any(named: 'type'), - ), - ).thenAnswer((_) async => {}); - - await tester.pumpWidget(createTestWidget('machine123')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Pay with Loyalty')); - await tester.pump(); - await tester.pumpAndSettle(); - - expect(find.text('Machine Error'), findsWidgets); - verifyNever( - () => mockTransactionService.recordTransaction( - amount: any(named: 'amount'), - description: any(named: 'description'), - type: any(named: 'type'), - ), - ); - }); - }); - - group('UI Elements', () { - testWidgets('displays laundry service icon', (WidgetTester tester) async { - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - when( - () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 1.50}); - when( - () => mockProfileService.getUserBalanceById(any()), - ).thenAnswer((_) async => {'balance': 10.0}); - - await tester.pumpWidget(createTestWidget('machine123')); - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.local_laundry_service), findsOneWidget); - }); - - testWidgets('displays formatted price correctly', ( - WidgetTester tester, - ) async { - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - when( - () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Dryer05', 'Price': 2.75}); - when( - () => mockProfileService.getUserBalanceById(any()), - ).thenAnswer((_) async => {'balance': 5.0}); - - await tester.pumpWidget(createTestWidget('machine456')); - await tester.pumpAndSettle(); - - expect(find.text('Machine Dryer05'), findsOneWidget); - expect(find.text('\$1.50'), findsOneWidget); - expect(find.text('Pay \$1.50'), findsOneWidget); - }); - - testWidgets('sends notification after successful loyalty payment', ( - tester, - ) async { - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - - when( - () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); - - when( - () => mockProfileService.getUserBalanceById(any()), - ).thenAnswer((_) async => {'balance': 10.0}); - - when( - () => mockProfileService.updateBalanceById('user123', any()), - ).thenAnswer((_) async {}); - when( - () => mockMachineCommunicator.wakeDevice(any()), - ).thenAnswer((_) async => true); - - when( - () => mockTransactionService.recordTransaction( - amount: any(named: 'amount'), - description: any(named: 'description'), - type: any(named: 'type'), - ), - ).thenAnswer((_) async {}); - - await tester.pumpWidget(createTestWidget('machine123')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Pay with Loyalty')); - await tester.pump(); - await tester.pumpAndSettle(); - - verify(() => mockNotificationService.scheduleEarlyMachineNotification( - id: 1, - machineTime: any(named: 'machineTime'), - machineName: any(named: 'machineName'), - )).called(1); - }); - }); -} diff --git a/test/pages/refund_page_test.dart b/test/pages/refund_page_test.dart deleted file mode 100644 index 3ba0d61..0000000 --- a/test/pages/refund_page_test.dart +++ /dev/null @@ -1,732 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; -import 'package:clean_stream_laundry_app/pages/refund_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mocktail/mocktail.dart'; -import 'mocks.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; -import 'package:clean_stream_laundry_app/widgets/transactions_search_sheet.dart'; - -void main() { - late MockAuthService mockAuthService; - late MockTransactionService mockTransactionService; - late MockEdgeFunctionService mockEdgeFunctionService; - late MockProfileService mockProfileService; - - setUp(() { - mockAuthService = MockAuthService(); - mockTransactionService = MockTransactionService(); - mockEdgeFunctionService = MockEdgeFunctionService(); - mockProfileService = MockProfileService(); - - final getIt = GetIt.instance; - getIt.reset(); - getIt.registerSingleton(mockAuthService); - getIt.registerSingleton(mockTransactionService); - getIt.registerSingleton(mockEdgeFunctionService); - getIt.registerSingleton(mockProfileService); - - // Setup default stubs - when(() => mockAuthService.getCurrentUserId).thenReturn('test-user-id'); - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => []); - }); - - tearDown(() { - GetIt.instance.reset(); - }); - - Widget createWidgetUnderTest() { - final router = GoRouter( - routes: [ - GoRoute(path: '/', builder: (context, state) => const RefundPage()), - GoRoute( - path: '/settings', - builder: (context, state) => - const Scaffold(body: Text('Settings Page')), - ), - ], - ); - - return MaterialApp.router(routerConfig: router); - } - - group('RefundPage', () { - testWidgets('renders correctly with all elements', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect(find.text('Request Refund'), findsOneWidget); - expect(find.text('Select a transaction and describe your issue. Our team will review it shortly.'), findsOneWidget); - expect(find.byIcon(Icons.receipt_long_rounded), findsOneWidget); - expect(find.text("Submit a Refund Request"), findsOneWidget); - expect(find.text('Select a Transaction'), findsOneWidget); - expect( - find.text('Describe the issue with your transaction...'), - findsOneWidget, - ); - expect(find.text('Submit Refund Request'), findsOneWidget); - }); - - testWidgets('submit button shows error when form is invalid', ( - tester, - ) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final submitButton = find.widgetWithText(ElevatedButton, 'Submit Refund Request'); - expect(submitButton, findsOneWidget); - - // Tap the button - await tester.tap(submitButton); - await tester.pump(); - - // Expect the error text to appear - expect(find.text('Please fill in all fields'), findsOneWidget); - }); - - testWidgets('loads transactions on init', (tester) async { - - when( - () => mockTransactionService.getRefundableTransactionsForUser(), - ).thenAnswer((_) async => ( - transactions: ['\$10.00 - machine on Jan 01, 2024', '\$20.00 - machine on Jan 02, 2024'], - ids: [1, 2],)); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - verify(() => mockTransactionService.getRefundableTransactionsForUser()).called(1); - }); - - testWidgets('can select transaction from dropdown', (tester) async { - when( - () => mockTransactionService.getRefundableTransactionsForUser(), - ).thenAnswer((_) async => ( - transactions: ['\$10.00 - machine on Jan 01, 2024'], - ids: [1], - )); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - // Tap the TextFormField to open the bottom sheet - await tester.tap(find.byType(TextFormField)); - await tester.pumpAndSettle(); - - // Bottom sheet is now open, tap the transaction in the list - expect(find.byType(TransactionSearchSheet), findsOneWidget); - await tester.tap(find.text('\$10.00 - machine on Jan 01, 2024')); - await tester.pumpAndSettle(); - - // Verify the selection appeared in the field - expect(find.text('\$10.00 - machine on Jan 01, 2024'), findsOneWidget); - }); - - testWidgets('can enter description text', (tester) async { - when( - () => mockTransactionService.getRefundableTransactionsForUser(), - ).thenAnswer((_) async => ( - transactions: ['\$10.00 - machine on Jan 01, 2024'], - ids: [1], - )); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final textField = find.widgetWithText(TextField, "Describe the issue with your transaction..."); - await tester.enterText(textField, 'Test refund reason'); - await tester.pumpAndSettle(); - - expect(find.text('Test refund reason'), findsOneWidget); - }); - - testWidgets('submit button enabled when form is valid', (tester) async { - when( - () => mockTransactionService.getRefundableTransactionsForUser(), - ).thenAnswer((_) async => ( - transactions: ['\$10.00 - machine on Jan 01, 2024'], - ids: [1], - )); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - // Select a transaction - await tester.tap(find.byType(TextFormField)); - await tester.pumpAndSettle(); - await tester.tap(find.text('\$10.00 - machine on Jan 01, 2024')); - await tester.pumpAndSettle(); - - // Enter description - await tester.enterText( - find.widgetWithText(TextField, 'Describe the issue with your transaction...'), - 'Test refund reason', - ); - await tester.pumpAndSettle(); - - // Verify button is enabled - final button = tester.widget(find.byType(ElevatedButton)); - expect(button.onPressed, isNotNull); - expect(button.style?.backgroundColor?.resolve({}), equals(Theme.of(tester.element(find.byType(ElevatedButton))).colorScheme.primary)); - }); - - testWidgets('handles refund submission successfully', (tester) async { - final mockTransactions = [ - {'id': 123, 'amount': 25.50, 'date': '2024-01-01'}, - ]; - - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); - when( - () => mockProfileService.getUserNameById('test-user-id'), - ).thenAnswer((_) async => 'Test User'); - when( - () => mockTransactionService.recordRefundRequest( - transaction_id: any(named: 'transaction_id'), - description: any(named: 'description'), - ), - ).thenAnswer((_) async => "25.50"); - when( - () => mockEdgeFunctionService.runEdgeFunction( - name: any(named: 'name'), - body: any(named: 'body'), - ), - ).thenAnswer((_) async => null); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - }); - - testWidgets('shows error when user is not logged in', (tester) async { - when(() => mockAuthService.getCurrentUserId).thenReturn(null); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - }); - - testWidgets('navigates to settings after successful refund', ( - tester, - ) async { - final mockTransactions = [ - {'id': 123, 'amount': 25.50, 'date': '2024-01-01'}, - ]; - - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); - when( - () => mockProfileService.getUserNameById('test-user-id'), - ).thenAnswer((_) async => 'Test User'); - when( - () => mockTransactionService.recordRefundRequest( - transaction_id: any(named: 'transaction_id'), - description: any(named: 'description'), - ), - ).thenAnswer((_) async => "25.50"); - when( - () => mockEdgeFunctionService.runEdgeFunction( - name: any(named: 'name'), - body: any(named: 'body'), - ), - ).thenAnswer((_) async => null); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - }); - - testWidgets('handles transaction fetch error gracefully', (tester) async { - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenThrow(Exception('Failed to fetch transactions')); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect(find.text('Request Refund'), findsOneWidget); - }); - }); - - testWidgets('selecting a transaction updates state', (tester) async { - when( - () => mockTransactionService.getRefundableTransactionsForUser(), - ).thenAnswer((_) async => ( - transactions: ['\$10.00 - machine on Jan 01, 2024', '\$20.00 - machine on Jan 02, 2024'], - ids: [1, 2], - )); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - // Open the search sheet - await tester.tap(find.byType(TextFormField)); - await tester.pumpAndSettle(); - - expect(find.byType(TransactionSearchSheet), findsOneWidget); - - // Select a transaction - await tester.tap(find.text('\$10.00 - machine on Jan 01, 2024')); - await tester.pumpAndSettle(); - - // Verify the TextFormField now shows the selected transaction - expect(find.text('\$10.00 - machine on Jan 01, 2024'), findsOneWidget); - }); - - testWidgets('_handleRefund calls all required services in correct order', ( - tester, - ) async { - when( - () => mockTransactionService.getRefundableTransactionsForUser(), - ).thenAnswer((_) async => ( - transactions: ['\$25.50 - machine on Jan 01, 2024'], - ids: [123], - )); - when( - () => mockAuthService.getCurrentUserId, - ).thenReturn('test-user-id'); - when( - () => mockProfileService.getUserNameById('test-user-id'), - ).thenAnswer((_) async => 'Test User'); - when( - () => mockTransactionService.recordRefundRequest( - transaction_id: any(named: 'transaction_id'), - description: any(named: 'description'), - ), - ).thenAnswer((_) async => '25.50'); - when( - () => mockEdgeFunctionService.runEdgeFunction( - name: any(named: 'name'), - body: any(named: 'body'), - ), - ).thenAnswer((_) async => null); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - // Select a transaction - await tester.tap(find.byType(TextFormField)); - await tester.pumpAndSettle(); - await tester.tap(find.text('\$25.50 - machine on Jan 01, 2024')); - await tester.pumpAndSettle(); - - // Enter description - await tester.enterText( - find.widgetWithText(TextField, 'Describe the issue with your transaction...'), - 'Test refund reason', - ); - await tester.pumpAndSettle(); - - // Submit - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - - // Verify services were called - verify(() => mockTransactionService.getRefundableTransactionsForUser()).called(1); - verify(() => mockProfileService.getUserNameById('test-user-id')).called(1); - verify(() => mockTransactionService.recordRefundRequest( - transaction_id: '123', - description: 'Test refund reason', - )).called(1); - verify(() => mockEdgeFunctionService.runEdgeFunction( - name: 'refund-email', - body: any(named: 'body'), - )).called(1); - }); - - testWidgets('_handleRefund early return when userId is null', (tester) async { - when(() => mockAuthService.getCurrentUserId).thenReturn(null); - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => []); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect(find.text('Request Refund'), findsOneWidget); - - verifyNever(() => mockProfileService.getUserNameById(any())); - verifyNever( - () => mockTransactionService.recordRefundRequest( - transaction_id: any(named: 'transaction_id'), - description: any(named: 'description'), - ), - ); - }); - - testWidgets('verifies edge function is called with correct parameters', ( - tester, - ) async { - when( - () => mockTransactionService.getRefundableTransactionsForUser(), - ).thenAnswer((_) async => ( - transactions: ['\$50.00 - machine on Jan 15, 2024'], - ids: [456], - )); - when( - () => mockProfileService.getUserNameById('test-user-id'), - ).thenAnswer((_) async => 'John Doe'); - when( - () => mockTransactionService.recordRefundRequest( - transaction_id: any(named: 'transaction_id'), - description: any(named: 'description'), - ), - ).thenAnswer((_) async => '50.00'); - when( - () => mockEdgeFunctionService.runEdgeFunction( - name: 'refund-email', - body: any(named: 'body'), - ), - ).thenAnswer((_) async => null); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - // Select transaction - await tester.tap(find.byType(TextFormField)); - await tester.pumpAndSettle(); - await tester.tap(find.text('\$50.00 - machine on Jan 15, 2024')); - await tester.pumpAndSettle(); - - // Enter description - await tester.enterText( - find.widgetWithText(TextField, 'Describe the issue with your transaction...'), - 'Wrong charge', - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - - verify(() => mockEdgeFunctionService.runEdgeFunction( - name: 'refund-email', - body: { - 'username': 'John Doe', - 'user_id': 'test-user-id', - 'transaction_id': '456', - 'amount': '50.00', - 'description': 'Wrong charge', - }, - )).called(1); - }); - - testWidgets('complete refund submission flow with dialog and navigation', ( - tester, - ) async { - when( - () => mockTransactionService.getRefundableTransactionsForUser(), - ).thenAnswer((_) async => ( - transactions: ['\$100.00 - machine on Jan 20, 2024'], - ids: [789], - )); - when( - () => mockProfileService.getUserNameById('test-user-id'), - ).thenAnswer((_) async => 'Jane Smith'); - when( - () => mockTransactionService.recordRefundRequest( - transaction_id: any(named: 'transaction_id'), - description: any(named: 'description'), - ), - ).thenAnswer((_) async => '100.00'); - when( - () => mockEdgeFunctionService.runEdgeFunction( - name: 'refund-email', - body: any(named: 'body'), - ), - ).thenAnswer((_) async => null); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect(find.text('Request Refund'), findsOneWidget); - expect(find.text('Submit Refund Request'), findsOneWidget); - }); - - testWidgets('_handleRefund executes all service calls correctly', ( - tester, - ) async { - when( - () => mockTransactionService.getRefundableTransactionsForUser(), - ).thenAnswer((_) async => ( - transactions: ['\$25.50 - machine on Jan 01, 2024'], - ids: [123], - )); - when( - () => mockProfileService.getUserNameById('test-user-id'), - ).thenAnswer((_) async => 'Test User'); - when( - () => mockTransactionService.recordRefundRequest( - transaction_id: any(named: 'transaction_id'), - description: any(named: 'description'), - ), - ).thenAnswer((_) async => '25.50'); - when( - () => mockEdgeFunctionService.runEdgeFunction( - name: 'refund-email', - body: any(named: 'body'), - ), - ).thenAnswer((_) async => null); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - // Select transaction - await tester.tap(find.byType(TextFormField)); - await tester.pumpAndSettle(); - await tester.tap(find.text('\$25.50 - machine on Jan 01, 2024')); - await tester.pumpAndSettle(); - - // Enter description - await tester.enterText( - find.widgetWithText(TextField, 'Describe the issue with your transaction...'), - 'I want a refund please', - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - - verify(() => mockProfileService.getUserNameById('test-user-id')).called(1); - verify(() => mockTransactionService.recordRefundRequest( - transaction_id: '123', - description: 'I want a refund please', - )).called(1); - verify(() => mockEdgeFunctionService.runEdgeFunction( - name: 'refund-email', - body: { - 'username': 'Test User', - 'user_id': 'test-user-id', - 'transaction_id': '123', - 'amount': '25.50', - 'description': 'I want a refund please', - }, - )).called(1); - }); - - testWidgets( - '_handleRefund early returns when userId is null (covers early return path)', - (tester) async { - final recentDate = DateTime.now().subtract(Duration(days: 3)); - final formattedDate = recentDate.toIso8601String(); - - final mockTransactions = [ - { - 'id': 456, - 'amount': 10.0, - 'created_at': formattedDate, - 'description': 'Test Service', - 'type': 'debit', - }, - ]; - - when(() => mockAuthService.getCurrentUserId).thenReturn(null); - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final RefundPageState state = tester.state(find.byType(RefundPage)); - - await tester.pump(const Duration(milliseconds: 100)); - await tester.pumpAndSettle(); - - if (state.recentTransactions.isNotEmpty) { - state.setState(() { - state.selectedTransactionIndex = 0; - state.selectedTransaction = state.recentTransactions[0]; - state.descriptionController.text = 'Test refund'; - }); - await tester.pumpAndSettle(); - - final submitButton = find.widgetWithText( - ElevatedButton, - 'Submit Refund', - ); - if (submitButton.evaluate().isNotEmpty) { - await tester.tap(submitButton); - await tester.pumpAndSettle(); - } - } - - verifyNever(() => mockProfileService.getUserNameById(any())); - verifyNever( - () => mockTransactionService.recordRefundRequest( - transaction_id: any(named: 'transaction_id'), - description: any(named: 'description'), - ), - ); - verifyNever( - () => mockEdgeFunctionService.runEdgeFunction( - name: any(named: 'name'), - body: any(named: 'body'), - ), - ); - }, - ); - - testWidgets('dropdown onChanged updates selectedTransaction state', ( - tester, - ) async { - final recentDate = DateTime.now().subtract(Duration(days: 5)); - final formattedDate = recentDate.toIso8601String(); - - final mockTransactions = [ - { - 'id': 111, - 'amount': 15.0, - 'created_at': formattedDate, - 'description': 'Service A', - 'type': 'debit', - }, - { - 'id': 222, - 'amount': 30.0, - 'created_at': formattedDate, - 'description': 'Service B', - 'type': 'debit', - }, - ]; - - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final RefundPageState state = tester.state(find.byType(RefundPage)); - - await tester.pump(const Duration(milliseconds: 100)); - await tester.pumpAndSettle(); - - expect(state.selectedTransaction, isNull); - expect(state.selectedTransactionIndex, isNull); - - if (state.recentTransactions.isNotEmpty) { - state.setState(() { - const int newIndex = 0; - state.selectedTransactionIndex = newIndex; - state.selectedTransaction = state.recentTransactions[newIndex]; - }); - await tester.pumpAndSettle(); - - expect(state.selectedTransactionIndex, equals(0)); - expect(state.selectedTransaction, isNotNull); - expect(state.selectedTransaction, equals(state.recentTransactions[0])); - } - }); - - testWidgets('can click enter for form resubmit', (tester) async { - final mockTransactions = [ - { - 'id': 123, - 'amount': 10.0, - 'created_at': '2024-01-01', - 'description': 'Test Transaction', - 'type': 'debit', - }, - ]; - - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); - when( - () => mockProfileService.getUserNameById('test-user-id'), - ).thenAnswer((_) async => 'Test User'); - when( - () => mockTransactionService.recordRefundRequest( - transaction_id: any(named: 'transaction_id'), - description: any(named: 'description'), - ), - ).thenAnswer((_) async => "10.0"); - when( - () => mockEdgeFunctionService.runEdgeFunction( - name: any(named: 'name'), - body: any(named: 'body'), - ), - ).thenAnswer((_) async => null); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - // Grab the state - final RefundPageState state = tester.state(find.byType(RefundPage)); - - // **Manually populate recentTransactions and recentTransactionIDs** - state.setState(() { - state.recentTransactions = ['2024-01-01 - \$10.0']; - state.recentTransactionIDs = [123]; - state.selectedTransactionIndex = 0; - state.selectedTransaction = state.recentTransactions[0]; - state.descriptionController.text = 'Test refund reason'; - }); - await tester.pumpAndSettle(); - - // Focus the KeyboardListener and send Enter - final keyboardListener = tester.widget( - find.byType(KeyboardListener), - ); - final focusNode = keyboardListener.focusNode; - focusNode.requestFocus(); - await tester.pump(); - - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pumpAndSettle(); - - verify( - () => mockEdgeFunctionService.runEdgeFunction( - name: 'refund-email', - body: { - 'username': 'Test User', - 'user_id': 'test-user-id', - 'transaction_id': '123', - 'amount': '10.0', - 'description': 'Test refund reason', - }, - ), - ).called(1); - - }); - - testWidgets('disclosure widgets displays correctly', (tester) async { - when( - () => mockTransactionService.getRefundableTransactionsForUser(), - ).thenAnswer((_) async => (transactions: [], ids: [])); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect( - find.textContaining('Refund requests are reviewed within 3–5 business days.'), - findsOneWidget, - ); - expect( - find.textContaining('Approved refunds will be returned to your loyalty card balance.'), - findsOneWidget, - ); - expect( - find.textContaining('We reserve the right to deny requests that do not meet our refund policy criteria.'), - findsOneWidget, - ); - - expect(find.byIcon(Icons.info_outline_rounded), findsOneWidget); - - final container = tester.widget( - find.ancestor( - of: find.byIcon(Icons.info_outline_rounded), - matching: find.byType(Container), - ).first, - ); - final decoration = container.decoration as BoxDecoration; - expect(decoration.color, const Color(0xFFFFFDE7).withOpacity(0.8)); - expect(decoration.borderRadius, BorderRadius.circular(14)); - }); -} diff --git a/test/pages/reset_protected_page_test.dart b/test/pages/reset_protected_page_test.dart deleted file mode 100644 index f8f664e..0000000 --- a/test/pages/reset_protected_page_test.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:clean_stream_laundry_app/logic/parsing/password_parser.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/pages/reset_protected_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mocktail/mocktail.dart'; -import 'mocks.dart'; - -void main() { - late MockAuthService mockAuthService; - - setUp(() { - mockAuthService = MockAuthService(); - - final getIt = GetIt.instance; - if (getIt.isRegistered()) { - getIt.unregister(); - } - - getIt.registerSingleton(mockAuthService); - }); - - tearDown(() => GetIt.instance.reset()); - - Widget createWidgetUnderTest() { - final router = GoRouter( - initialLocation: '/reset-protected', - routes: [ - GoRoute( - path: '/reset-protected', - builder: (context, state) => - ResetProtectedPage(), - ), - GoRoute( - path: '/login', - builder: (context, state) => const Scaffold(body: Text('Login Page')), - ), - ], - ); - - return MaterialApp.router(routerConfig: router); - } - - testWidgets('validates short password', (tester) async { - - await tester.pumpWidget( - createWidgetUnderTest() - ); - await tester.pumpAndSettle(); - - await tester.enterText(find.byType(TextField).at(0), 'short'); - await tester.enterText(find.byType(TextField).at(1), 'short'); - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - String? validations = PasswordParser.process("short"); - - if (validations != null) { - expect(find.text(validations), findsWidgets); - } - }); - - testWidgets('shows success message when update is successful', (tester) async { - - when(() => mockAuthService.updatePassword(any())).thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget( - createWidgetUnderTest() - ); - - await tester.pumpAndSettle(); - - await tester.enterText(find.byType(TextField).at(0), 'Password123&'); - await tester.enterText(find.byType(TextField).at(1), 'Password123&'); - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.updatePassword('Password123&')).called(1); - expect(find.text("Password reset successful"), findsWidgets); - expect(find.text("Login Page"), findsOneWidget); - }); - - testWidgets('shows failure message when update fails', (tester) async { - - when(() => mockAuthService.updatePassword(any())).thenThrow(Exception('network')); - - await tester.pumpWidget( - createWidgetUnderTest() - ); - - await tester.pumpAndSettle(); - - await tester.enterText(find.byType(TextField).at(0), 'Password123&'); - await tester.enterText(find.byType(TextField).at(1), 'Password123&'); - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.updatePassword('Password123&')).called(1); - expect(find.text('Failed to reset password'), findsWidgets); - }); - - testWidgets('shows failure message when exchangeCodeForSession throws', (tester) async { - when( - () => mockAuthService.exchangeCodeForSession('abc'), - ).thenAnswer((_) async => AuthenticationResponses.success); - when( - () => mockAuthService.updatePassword(any()), - ).thenThrow(Exception('network')); - - await tester.pumpWidget( - createWidgetUnderTest() - ); - await tester.pumpAndSettle(); - - await tester.enterText(find.byType(TextField).at(0), 'Password123&'); - await tester.enterText(find.byType(TextField).at(1), 'Password123&'); - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - - verify(() => mockAuthService.updatePassword('Password123&')).called(1); - expect(find.text('Failed to reset password'), findsWidgets); - }); - - testWidgets('Verifies that the image is present', (tester) async { - - await tester.pumpWidget( - createWidgetUnderTest() - ); - - await tester.pumpAndSettle(); - - expect(find.byType(Image),findsOneWidget); - }); -} diff --git a/test/pages/scanner_widget_test.dart b/test/pages/scanner_widget_test.dart deleted file mode 100644 index 3d6f111..0000000 --- a/test/pages/scanner_widget_test.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; -import 'package:clean_stream_laundry_app/pages/scanner_widget.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; -import 'package:mocktail/mocktail.dart'; -import 'mocks.dart'; - -void main() { - late MockMachineCommunicationService mockMachineCommunicator; - - setUp(() { - mockMachineCommunicator = MockMachineCommunicationService(); - - final getIt = GetIt.instance; - if (getIt.isRegistered()) { - getIt.unregister(); - } - - getIt.registerSingleton(mockMachineCommunicator); - - }); - - tearDown(() => GetIt.instance.reset()); - - Widget createWidgetUnderTest({String initialRoute = '/scanner'}) { - final router = GoRouter( - initialLocation: initialRoute, - routes: [ - GoRoute( - path: '/scanner', - builder: (context, state) => const ScannerWidget(), - ), - GoRoute( - path: '/startPage', - builder: (context, state) => const Scaffold(body: Text('Start Page')), - ), - GoRoute( - path: '/paymentPage', - builder: (context, state) { - // Simple stub page - no services needed! - return const Scaffold( - body: Column( - children: [ - Text('Payment Page'), - Text('Pay with Loyalty'), - ], - ), - ); - }, - ) - ], - ); - return MaterialApp.router(routerConfig: router); - } - - group('ScannerWidget Tests', () { - group('UI Elements', () { - testWidgets('should display all required UI components', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect(find.byType(MobileScanner), findsOneWidget); - expect(find.widgetWithText(FloatingActionButton, 'Cancel'), findsOneWidget); - expect(find.byIcon(Icons.close), findsOneWidget); - }); - - testWidgets('should have scanning frame overlay', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final frameFinder = find.byWidgetPredicate( - (widget) { - if (widget is Container && widget.decoration is BoxDecoration) { - final decoration = widget.decoration as BoxDecoration; - final border = decoration.border as Border?; - return border?.top.color == Colors.white && - border?.top.width == 3 && - decoration.borderRadius == BorderRadius.circular(12); - } - return false; - }, - ); - expect(frameFinder, findsOneWidget); - - final container = tester.widget(frameFinder); - expect(container.constraints?.maxWidth, 250); - expect(container.constraints?.maxHeight, 250); - }); - }); - - group('Navigation', () { - testWidgets('should navigate to start page when cancel is tapped', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - await tester.tap(find.widgetWithText(FloatingActionButton, 'Cancel')); - await tester.pumpAndSettle(); - - expect(find.text('Start Page'), findsOneWidget); - expect(find.byType(ScannerWidget), findsNothing); - }); - }); - - group('Camera Controller', () { - testWidgets('should have MobileScannerController with onDetect callback', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final mobileScanner = tester.widget( - find.byType(MobileScanner), - ); - expect(mobileScanner.controller, isNotNull); - expect(mobileScanner.onDetect, isNotNull); - }); - - testWidgets('should dispose without errors', (tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - await tester.tap(find.widgetWithText(FloatingActionButton, 'Cancel')); - await tester.pumpAndSettle(); - - expect(find.byType(ScannerWidget), findsNothing); - }); - - testWidgets('navigates to paymentPage when Nayax code passes',(tester) async { - when(() => mockMachineCommunicator.checkAvailability(any())).thenAnswer((_) async => "pass"); - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final dynamic state = tester.state(find.byType(ScannerWidget)); - await state.processNayaxCode("machine123"); - - await tester.pumpAndSettle(); - - expect(find.byType(ScannerWidget), findsNothing); - expect(find.text('Pay with Loyalty'), findsOneWidget); - }); - - testWidgets('navigates to paymentPage when Nayax code does not pass',(tester) async { - when(() => mockMachineCommunicator.checkAvailability(any())).thenAnswer((_) async => "fail"); - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final dynamic state = tester.state(find.byType(ScannerWidget)); - await state.processNayaxCode("machine123"); - - await tester.pumpAndSettle(); - - //expect(find.byType(ScannerWidget), findsNothing); - expect(find.text('Machine Unavailable'), findsOneWidget); - }); - }); - }); -} \ No newline at end of file diff --git a/test/pages/settings_test.dart b/test/pages/settings_test.dart deleted file mode 100644 index 742f21c..0000000 --- a/test/pages/settings_test.dart +++ /dev/null @@ -1,304 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme_manager.dart'; -import 'package:clean_stream_laundry_app/pages/settings.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:provider/provider.dart'; -import 'mocks.dart'; -import 'package:clean_stream_laundry_app/features/settings/widgets/settings_card.dart'; - -void main() { - late MockAuthService mockAuthService; - late MockTransactionService mockTransactionService; - late MockProfileService mockProfileService; - late MockThemeManager mockThemeManager; - late GoRouter router; - - setUp(() { - final getIt = GetIt.instance; - - mockAuthService = MockAuthService(); - mockTransactionService = MockTransactionService(); - mockThemeManager = MockThemeManager(); - mockProfileService = MockProfileService(); - - if (getIt.isRegistered()) { - getIt.unregister(); - } - - getIt.registerSingleton(mockProfileService); - - when(() => mockProfileService.getNotificationLeadTime()) - .thenAnswer((_) async => 5); - - when(() => mockProfileService.setNotificationLeadTime(any())) - .thenAnswer((_) async {}); - - if (getIt.isRegistered()) { - getIt.unregister(); - } - if (getIt.isRegistered()) { - getIt.unregister(); - } - getIt.registerSingleton(mockAuthService); - getIt.registerSingleton(mockTransactionService); - - router = GoRouter( - routes: [GoRoute(path: '/', builder: (context, state) => Settings())], - ); - }); - - tearDown(() { - GetIt.instance.reset(); - }); - - Widget createWidgetUnderTest() { - return ChangeNotifierProvider.value( - value: mockThemeManager, - child: MaterialApp.router(routerConfig: router), - ); - } - - group('Settings Widget Tests', () { - testWidgets('should display Settings logo', (WidgetTester tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - - expect( - find.byWidgetPredicate( - (widget) => - widget is Image && - widget.image is AssetImage && - (widget.image as AssetImage).assetName == 'assets/Logo.png', - ), - findsOneWidget, - ); - }); - - testWidgets('should display all six SettingsCard widgets', ( - WidgetTester tester, - ) async { - await tester.pumpWidget(createWidgetUnderTest()); - - expect(find.byType(SettingsCard), findsNWidgets(6)); - expect(find.text('Sign Out'), findsOneWidget); - expect(find.text('Monthly Report'), findsOneWidget); - expect(find.text('Request Refund'), findsOneWidget); - expect(find.text('Dark Mode'), findsOneWidget); - expect(find.text('Edit Profile'), findsOneWidget); - expect(find.text('Notify Before Finish'), findsOneWidget); - }); - - testWidgets( - 'should call logout and navigate to login when Sign Out is tapped', - (WidgetTester tester) async { - when(() => mockAuthService.logout()) - .thenAnswer((_) async {}); - - await tester.pumpWidget(createWidgetUnderTest()); - - // Scroll to make Sign Out visible - await tester.ensureVisible( - find.widgetWithText(SettingsCard, 'Sign Out'), - ); - await tester.pumpAndSettle(); - - // Tap the Sign Out card - await tester.tap(find.widgetWithText(SettingsCard, 'Sign Out')); - await tester.pumpAndSettle(); - - //Test to see if dialog box appears - expect(find.text('Are you sure you want to sign out?'), findsOneWidget); - - // Tap dialog "Sign Out" button - await tester.tap( - find.widgetWithText(ElevatedButton, 'Sign Out'), - ); - await tester.pumpAndSettle(); - - // Verify logout was called - verify(() => mockAuthService.logout()).called(1); - }, - ); - - - testWidgets('should call toggleTheme when theme card is tapped', ( - WidgetTester tester, - ) async { - await tester.pumpWidget(createWidgetUnderTest()); - - // Find the theme toggle card by its icon - final themeCard = find.ancestor( - of: find.byIcon(Icons.lightbulb), - matching: find.byType(SettingsCard), - ); - - await tester.tap(themeCard); - await tester.pumpAndSettle(); - - verify(() => mockThemeManager.toggleTheme()).called(1); - }); - - testWidgets('should fetch transactions and navigate to monthly report', ( - WidgetTester tester, - ) async { - final mockTransactions = >[ - {'id': '1', 'amount': 100}, - {'id': '2', 'amount': 200}, - ]; - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); - - await tester.pumpWidget(createWidgetUnderTest()); - - await tester.tap(find.widgetWithText(SettingsCard, 'Monthly Report')); - await tester.pumpAndSettle(); - - verify(() => mockTransactionService.getTransactionsForUser()).called(1); - }); - - testWidgets( - 'should navigate to refund page when Request Refund is tapped', - (WidgetTester tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - - // Scroll to make Request Refund visible - await tester.ensureVisible( - find.widgetWithText(SettingsCard, 'Request Refund'), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.widgetWithText(SettingsCard, 'Request Refund')); - await tester.pumpAndSettle(); - - // Verify navigation occurred (router location check) - expect(router.routerDelegate.currentConfiguration.uri.path, '/'); - }, - ); - - testWidgets( - 'should navigate to edit profile page when edit profile is tapped', - (WidgetTester tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - - // Scroll to make Request Refund visible - await tester.ensureVisible( - find.widgetWithText(SettingsCard, 'Edit Profile'), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.widgetWithText(SettingsCard, 'Edit Profile')); - await tester.pumpAndSettle(); - - // Verify navigation occurred (router location check) - expect( - router.routerDelegate.currentConfiguration.uri.path, - '/editProfile', - ); - }, - ); - - testWidgets('should display correct icons for each card', ( - WidgetTester tester, - ) async { - await tester.pumpWidget(createWidgetUnderTest()); - - expect(find.byIcon(Icons.lightbulb), findsOneWidget); - expect(find.byIcon(Icons.money), findsOneWidget); - expect(find.byIcon(Icons.request_page), findsOneWidget); - expect(find.byIcon(Icons.logout), findsOneWidget); - expect(find.byIcon(Icons.person), findsOneWidget); - }); - - testWidgets('should center content properly', (WidgetTester tester) async { - await tester.pumpWidget(createWidgetUnderTest()); - - expect(find.byType(Center), findsWidgets); - expect(find.byType(Column), findsWidgets); - expect(find.byType(SingleChildScrollView), findsOneWidget); - }); - - testWidgets('loads notification lead time from ProfileService', (tester) async { - when(() => mockProfileService.getNotificationLeadTime()) - .thenAnswer((_) async => 7); - - when(() => mockProfileService.setNotificationLeadTime(any())) - .thenAnswer((_) async {}); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - expect(find.text(' ' + '7'), findsOneWidget); - }); - - testWidgets('increments notification lead time when + is tapped', (tester) async { - when(() => mockProfileService.getNotificationLeadTime()) - .thenAnswer((_) async => 5); - - when(() => mockProfileService.setNotificationLeadTime(any())) - .thenAnswer((_) async {}); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final plusButton = find.byIcon(Icons.add); - - await tester.ensureVisible(plusButton); - await tester.tap(plusButton); - await tester.pumpAndSettle(); - - verify(() => mockProfileService.setNotificationLeadTime(6)).called(1); - }); - - testWidgets('does not decrement below 0', (tester) async { - when(() => mockProfileService.getNotificationLeadTime()) - .thenAnswer((_) async => 0); - - when(() => mockProfileService.setNotificationLeadTime(any())) - .thenAnswer((_) async {}); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final minusButton = find.byIcon(Icons.remove); - - await tester.tap(minusButton); - await tester.pumpAndSettle(); - - verifyNever(() => mockProfileService.setNotificationLeadTime(any())); - }); - - testWidgets('notification lead time does not exceed max limit', (tester) async { - when(() => mockProfileService.getNotificationLeadTime()) - .thenAnswer((_) async => Settings.maxNotificationLeadTime - 2); - - when(() => mockProfileService.setNotificationLeadTime(any())) - .thenAnswer((_) async {}); - - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pumpAndSettle(); - - final plusButton = find.byIcon(Icons.add); - - await tester.ensureVisible(plusButton); - await tester.tap(plusButton); - await tester.pumpAndSettle(); - - await tester.tap(plusButton); - await tester.pumpAndSettle(); - - await tester.tap(plusButton); - await tester.pumpAndSettle(); - - expect(find.text(' ' + '${Settings.maxNotificationLeadTime}'), findsOneWidget); - - verify(() => mockProfileService - .setNotificationLeadTime(Settings.maxNotificationLeadTime)).called(1); - }); - }); -} diff --git a/test/pages/sign_up_screen_test.dart b/test/pages/sign_up_screen_test.dart deleted file mode 100644 index 768e0a3..0000000 --- a/test/pages/sign_up_screen_test.dart +++ /dev/null @@ -1,408 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/pages/sign_up_screen.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mocktail/mocktail.dart'; -import 'mocks.dart'; - -void main() { - late MockAuthService mockAuthService; - late MockProfileService mockProfileService; - late GoRouter router; - - setUp(() { - mockAuthService = MockAuthService(); - mockProfileService = MockProfileService(); - - final getIt = GetIt.instance; - if (getIt.isRegistered()) { - getIt.unregister(); - } - if (getIt.isRegistered()) { - getIt.unregister(); - } - getIt.registerSingleton(mockAuthService); - getIt.registerSingleton(mockProfileService); - - router = GoRouter( - routes: [ - GoRoute(path: '/', builder: (context, state) => SignUpScreen()), - GoRoute( - path: '/login', - builder: (context, state) => const Scaffold(body: Text('Login Page')), - ), - GoRoute( - path: '/email-verification', - builder: (context, state) => - const Scaffold(body: Text('Email Verification Page')), - ), - ], - ); - }); - - tearDown(() { - GetIt.instance.reset(); - }); - - // Helper function to set up test viewport with enough height for SignUpScreen - void setupViewport(WidgetTester tester) { - tester.view.physicalSize = const Size(800, 1200); - tester.view.devicePixelRatio = 1.0; - addTearDown(() => tester.view.reset()); - } - - Widget createWidgetUnderTest() { - return MaterialApp.router(routerConfig: router); - } - - group('SignUpScreen Widget Tests', () { - testWidgets('should display logo', (WidgetTester tester) async { - setupViewport(tester); - await tester.pumpWidget(createWidgetUnderTest()); - - expect(find.byType(Image), findsOneWidget); - }); - testWidgets('should display all input fields', (WidgetTester tester) async { - setupViewport(tester); - await tester.pumpWidget(createWidgetUnderTest()); - - expect(find.byType(TextField), findsNWidgets(4)); - expect(find.text('Name'), findsOneWidget); - expect(find.text('Email'), findsOneWidget); - expect(find.text('Password'), findsOneWidget); - expect(find.text('Confirm Password'), findsOneWidget); - }); - - testWidgets( - 'should display Create Account button', (WidgetTester tester,) async { - setupViewport(tester); - await tester.pumpWidget(createWidgetUnderTest()); - await tester.pump(); - - expect( - find.widgetWithText(ElevatedButton, 'Create Account'), - findsOneWidget, - ); - }); - - testWidgets('should display login link', (WidgetTester tester) async { - setupViewport(tester); - await tester.pumpWidget(createWidgetUnderTest()); - - expect(find.text('Already have an account? Login'), findsOneWidget); - }); - - testWidgets('should navigate to login when login link is tapped', ( - WidgetTester tester,) async { - setupViewport(tester); - await tester.pumpWidget(createWidgetUnderTest()); - - await tester.tap(find.text('Already have an account? Login')); - await tester.pumpAndSettle(); - - expect(find.text('Login Page'), findsOneWidget); - }); - - testWidgets('should show error when fields are empty', ( - WidgetTester tester,) async { - setupViewport(tester); - await tester.pumpWidget(createWidgetUnderTest()); - - await tester.tap(find.widgetWithText(ElevatedButton, 'Create Account')); - await tester.pumpAndSettle(); - - expect(find.text('Please fill in all fields.'), findsOneWidget); - }); - - testWidgets('should show error when passwords do not match', ( - WidgetTester tester,) async { - setupViewport(tester); - await tester.pumpWidget(createWidgetUnderTest()); - - await tester.enterText(find.byType(TextField).at(0), 'Test User'); - await tester.enterText(find.byType(TextField).at(1), 'test@example.com'); - await tester.enterText(find.byType(TextField).at(2), 'Password123!'); - await tester.enterText(find.byType(TextField).at(3), 'Password456!'); - - await tester.tap(find.widgetWithText(ElevatedButton, 'Create Account')); - await tester.pumpAndSettle(); - - expect(find.text('Passwords do not match.'), findsOneWidget); - }); - - - testWidgets('should navigate to email verification on success', - (WidgetTester tester) async { - when(() => mockAuthService.signUp(any(), any(), any())) - .thenAnswer((_) async => AuthenticationResponses.success); - - when(() => mockAuthService.getLastSignedUpUserId()) - .thenReturn('test-user-id-123'); - - when(() => - mockProfileService.createAccount( - name: any(named: 'name'), id: any(named: 'id'))) - .thenAnswer((_) async => {}); - - setupViewport(tester); - await tester.pumpWidget(createWidgetUnderTest()); - - await tester.enterText(find.byType(TextField).at(0), 'Test User'); - await tester.enterText( - find.byType(TextField).at(1), 'test@example.com'); - await tester.enterText(find.byType(TextField).at(2), 'Password123!'); - await tester.enterText(find.byType(TextField).at(3), 'Password123!'); - - await tester.tap( - find.widgetWithText(ElevatedButton, 'Create Account')); - await tester.pump(); // start async work - await tester.pumpAndSettle( - const Duration(seconds: 2)); // wait for navigation - - expect(find.text('Email Verification Page'), findsOneWidget); - }); - - testWidgets('should show error for password without digit', ( - WidgetTester tester,) async { - setupViewport(tester); - when( - () => mockAuthService.signUp(any(), any(), any()), - ).thenAnswer((_) async => AuthenticationResponses.noDigit); - - await tester.pumpWidget(createWidgetUnderTest()); - - await tester.enterText(find.byType(TextField).at(0), 'Test User'); - await tester.enterText(find.byType(TextField).at(1), 'test@example.com'); - await tester.enterText(find.byType(TextField).at(2), 'Password!'); - await tester.enterText(find.byType(TextField).at(3), 'Password!'); - - await tester.tap(find.widgetWithText(ElevatedButton, 'Create Account')); - await tester.pumpAndSettle(); - - expect(find.text('Please include a digit'), findsAtLeastNWidgets(1)); - }); - - testWidgets('should show error for password too short', ( - WidgetTester tester,) async { - setupViewport(tester); - when( - () => mockAuthService.signUp(any(), any(), any()), - ).thenAnswer((_) async => AuthenticationResponses.lessThanMinLength); - - await tester.pumpWidget(createWidgetUnderTest()); - - await tester.enterText(find.byType(TextField).at(0), 'Test User'); - await tester.enterText(find.byType(TextField).at(1), 'test@example.com'); - await tester.enterText(find.byType(TextField).at(2), 'Pass1!'); - await tester.enterText(find.byType(TextField).at(3), 'Pass1!'); - - await tester.tap(find.widgetWithText(ElevatedButton, 'Create Account')); - await tester.pumpAndSettle(); - - expect( - find.text('Password length is too short'), - findsAtLeastNWidgets(1), - ); - }); - - testWidgets('should show error for password without special character', ( - WidgetTester tester,) async { - setupViewport(tester); - when( - () => mockAuthService.signUp(any(), any(), any()), - ).thenAnswer((_) async => AuthenticationResponses.noSpecialCharacter); - - await tester.pumpWidget(createWidgetUnderTest()); - - await tester.enterText(find.byType(TextField).at(0), 'Test User'); - await tester.enterText(find.byType(TextField).at(1), 'test@example.com'); - await tester.enterText(find.byType(TextField).at(2), 'Password123'); - await tester.enterText(find.byType(TextField).at(3), 'Password123'); - - await tester.tap(find.widgetWithText(ElevatedButton, 'Create Account')); - await tester.pumpAndSettle(); - - expect( - find.text('Please include a special character'), - findsAtLeastNWidgets(1), - ); - }); - - testWidgets('should show error for password without uppercase', ( - WidgetTester tester,) async { - setupViewport(tester); - when( - () => mockAuthService.signUp(any(), any(), any()), - ).thenAnswer((_) async => AuthenticationResponses.noUppercase); - - await tester.pumpWidget(createWidgetUnderTest()); - - await tester.enterText(find.byType(TextField).at(0), 'Test User'); - await tester.enterText(find.byType(TextField).at(1), 'test@example.com'); - await tester.enterText(find.byType(TextField).at(2), 'password123!'); - await tester.enterText(find.byType(TextField).at(3), 'password123!'); - - await tester.tap(find.widgetWithText(ElevatedButton, 'Create Account')); - await tester.pumpAndSettle(); - - expect( - find.text('Please include an uppercase letter'), - findsAtLeastNWidgets(1), - ); - }); - - testWidgets('should show error for invalid special character', ( - WidgetTester tester,) async { - setupViewport(tester); - when(() => mockAuthService.signUp(any(), any(), any())).thenAnswer( - (_) async => AuthenticationResponses.invalidSpecialCharacter, - ); - - await tester.pumpWidget(createWidgetUnderTest()); - - await tester.enterText(find.byType(TextField).at(0), 'Test User'); - await tester.enterText(find.byType(TextField).at(1), 'test@example.com'); - await tester.enterText(find.byType(TextField).at(2), 'Password123<'); - await tester.enterText(find.byType(TextField).at(3), 'Password123<'); - - await tester.tap(find.widgetWithText(ElevatedButton, 'Create Account')); - await tester.pumpAndSettle(); - - expect( - find.text('Please use a different special character'), - findsAtLeastNWidgets(1), - ); - }); - - testWidgets( - 'should have correct button styles', (WidgetTester tester,) async { - setupViewport(tester); - await tester.pumpWidget(createWidgetUnderTest()); - - final button = tester.widget( - find.widgetWithText(ElevatedButton, 'Create Account'), - ); - - expect(button.style, isNotNull); - }); - - testWidgets('should have all text fields with proper icons', ( - WidgetTester tester,) async { - setupViewport(tester); - await tester.pumpWidget(createWidgetUnderTest()); - - final nameField = tester.widget(find.byType(TextField).at(0)); - final emailField = tester.widget(find.byType(TextField).at(1)); - final passwordField = tester.widget( - find.byType(TextField).at(2), - ); - final confirmPasswordField = tester.widget( - find.byType(TextField).at(3), - ); - - expect((nameField.decoration as InputDecoration).prefixIcon, isA()); - expect( - (emailField.decoration as InputDecoration).prefixIcon, - isA(), - ); - expect( - (passwordField.decoration as InputDecoration).prefixIcon, - isA(), - ); - expect( - (confirmPasswordField.decoration as InputDecoration).prefixIcon, - isA(), - ); - }); - - testWidgets( - 'password fields should be obscured', (WidgetTester tester,) async { - setupViewport(tester); - await tester.pumpWidget(createWidgetUnderTest()); - - final passwordField = tester.widget( - find.byType(TextField).at(2), - ); - final confirmPasswordField = tester.widget( - find.byType(TextField).at(3), - ); - - expect(passwordField.obscureText, true); - expect(confirmPasswordField.obscureText, true); - }); - - testWidgets('Ensure proper display with completely improper password', ( - tester) async { - setupViewport(tester); - await tester.pumpWidget(createWidgetUnderTest()); - - await tester.enterText(find.byType(TextField).at(2), "abc"); - - await tester.pump(); - - expect(find.textContaining("Have 8 character length"), findsOneWidget); - expect(find.textContaining("Include special character"), findsOneWidget); - expect(find.textContaining("Include a digit"), findsOneWidget); - expect( - find.textContaining("Include an uppercase letter"), findsOneWidget); - }); - - testWidgets('Ensure proper display with partially improper password', ( - tester) async { - setupViewport(tester); - await tester.pumpWidget(createWidgetUnderTest()); - - await tester.enterText(find.byType(TextField).at(2), "Abcdefgh"); - - await tester.pump(); - - expect(find.textContaining("Have 8 character length"), findsNothing); - expect(find.textContaining("Include special character"), findsOneWidget); - expect(find.textContaining("Include a digit"), findsOneWidget); - expect(find.textContaining("Include an uppercase letter"), findsNothing); - }); - - testWidgets('Ensure no display with proper password', (tester) async { - setupViewport(tester); - await tester.pumpWidget(createWidgetUnderTest()); - - await tester.enterText(find.byType(TextField).at(2), "Abc1234!"); - - await tester.pump(); - - expect(find.textContaining("Have 8 character length"), findsNothing); - expect(find.textContaining("Include special character"), findsNothing); - expect(find.textContaining("Include a digit"), findsNothing); - expect(find.textContaining("Include an uppercase letter"), findsNothing); - }); - - testWidgets('Handle sign up is run when enter is clicked', (tester) async { - setupViewport(tester); - - // Pump the widgets - await tester.pumpWidget(createWidgetUnderTest()); - - // Find the KeyboardListener and grab its FocusNode - final keyboardListener = tester.widget( - find.byType(KeyboardListener), - ); - final focusNode = keyboardListener.focusNode; - - // Request focus - focusNode.requestFocus(); - await tester.pump(); - - // Send Enter key - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pumpAndSettle(); - - // Verify the SnackBar appears - expect(find.text('Please fill in all fields.'), findsOneWidget); - }); - }); -} diff --git a/test/pages/start_machine_page_test.dart b/test/pages/start_machine_page_test.dart deleted file mode 100644 index a5bab86..0000000 --- a/test/pages/start_machine_page_test.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; -import 'package:clean_stream_laundry_app/pages/start_machine_page.dart'; -import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; -import 'package:clean_stream_laundry_app/features/start_machine/widgets/qr_button.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mocktail/mocktail.dart'; - -class MockDoorUnlocker extends Mock implements DoorUnlocker {} -class MockLoyaltyViewModel extends Mock implements LoyaltyViewModel {} -class MockProfileService extends Mock implements ProfileService {} -class MockAuthService extends Mock implements AuthService {} - -void main() { - late MockDoorUnlocker mockUnlocker; - late MockLoyaltyViewModel mockViewModel; - late MockProfileService mockProfileService; - late MockAuthService mockAuthService; - - setUp(() { - mockUnlocker = MockDoorUnlocker(); - mockViewModel = MockLoyaltyViewModel(); - mockProfileService = MockProfileService(); - mockAuthService = MockAuthService(); - - final getIt = GetIt.instance; - - if (getIt.isRegistered()) getIt.unregister(); - if (getIt.isRegistered()) getIt.unregister(); - if (getIt.isRegistered()) getIt.unregister(); - - getIt.registerSingleton(mockViewModel); - getIt.registerSingleton(mockProfileService); - getIt.registerSingleton(mockAuthService); - - when(() => mockAuthService.getCurrentUserId).thenReturn("user123"); - when(() => mockProfileService.getUserBalanceById("user123")) - .thenAnswer((_) async => {"balance": 50.0}); - }); - - Widget createStartPageTestApp(DoorUnlocker unlocker) { - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (_, __) => StartPage(doorUnlocker: unlocker), - ), - GoRoute( - path: '/scanner', - builder: (_, __) => const Scaffold(body: Text('Scanner Page')), - ), - ], - ); - return MaterialApp.router(routerConfig: router); - } - - Future scrollToUnlockButton(WidgetTester tester) async { - final scrollViewFinder = find.descendant( - of: find.byType(StartPage), - matching: find.byType(SingleChildScrollView), - ); - expect(scrollViewFinder, findsOneWidget); - - await tester.drag(scrollViewFinder, const Offset(0, -500)); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - } - - testWidgets('Tapping QR button navigates to /scanner', (tester) async { - when(() => mockViewModel.userBalance).thenReturn(50.0); - - await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); - await tester.pumpAndSettle(); - - await scrollToUnlockButton(tester); - - final qrButton = find.widgetWithText(QRButton, "Scan QR code"); - expect(qrButton, findsOneWidget); - - await tester.tap(qrButton); - await tester.pumpAndSettle(); - - expect(find.text("Scanner Page"), findsOneWidget); - }); - - testWidgets('Unlock button shows searching dialog', (tester) async { - when(() => mockViewModel.userBalance).thenReturn(50.0); - when(() => mockUnlocker.unlockNearestDoor()).thenAnswer((_) async { - await Future.delayed(const Duration(milliseconds: 50)); - return true; - }); - - await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); - await tester.pumpAndSettle(); - await scrollToUnlockButton(tester); - - await tester.tap(find.text("Unlock Door")); - await tester.pump(const Duration(milliseconds: 20)); - - expect(find.byType(Dialog), findsOneWidget); - expect(find.textContaining("Finding Nearby Doors"), findsOneWidget); - await tester.pumpAndSettle(); - }); - - testWidgets('Successful unlock closes searching dialog and shows success dialog', - (tester) async { - when(() => mockViewModel.userBalance).thenReturn(50.0); - when(() => mockUnlocker.unlockNearestDoor()).thenAnswer((_) async => true); - - await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); - await tester.pumpAndSettle(); - - await scrollToUnlockButton(tester); - - await tester.tap(find.text("Unlock Door")); - await tester.pump(const Duration(milliseconds: 100)); - await tester.pump(const Duration(seconds: 2)); - - expect(find.text("Door Unlocked!"), findsOneWidget); - }); - - testWidgets('Failed unlock shows failure dialog', (tester) async { - when(() => mockViewModel.userBalance).thenReturn(50.0); - when(() => mockUnlocker.unlockNearestDoor()).thenAnswer((_) async => false); - - await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); - await tester.pumpAndSettle(); - - await scrollToUnlockButton(tester); - - await tester.tap(find.text("Unlock Door")); - await tester.pump(const Duration(milliseconds: 100)); - await tester.pump(const Duration(seconds: 2)); - - expect(find.text("No Nearby Doors Found"), findsOneWidget); - }); - - testWidgets('shows low balance dialog when balance is below 20', (tester) async { - const String testUid = "user123"; - when(() => mockAuthService.getCurrentUserId).thenReturn(testUid); - when(() => mockProfileService.getUserBalanceById(testUid)) - .thenAnswer((_) async => {"balance": 15.0}); - - await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); - await tester.pump(); - await tester.pumpAndSettle(); - await scrollToUnlockButton(tester); - - final unlockButton = find.text('Unlock Door'); - expect(unlockButton, findsOneWidget); - await tester.tap(unlockButton); - await tester.pumpAndSettle(); - - expect(find.text('Low Balance'), findsOneWidget); - expect(find.textContaining('at least 20.00'), findsOneWidget); - verifyNever(() => mockUnlocker.unlockNearestDoor()); - }); - - testWidgets('allows unlocking when balance is exactly 20', (tester) async { - when(() => mockViewModel.userBalance).thenReturn(20.0); - when(() => mockUnlocker.unlockNearestDoor()).thenAnswer((_) async => true); - - await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); - await tester.pumpAndSettle(); - - await scrollToUnlockButton(tester); - - await tester.tap(find.text("Unlock Door")); - await tester.pumpAndSettle(); - - verify(() => mockUnlocker.unlockNearestDoor()).called(1); - }); - - testWidgets('allows unlocking when balance is above 20', (tester) async { - when(() => mockViewModel.userBalance).thenReturn(25.0); - when(() => mockUnlocker.unlockNearestDoor()).thenAnswer((_) async => true); - - await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); - await tester.pumpAndSettle(); - - await scrollToUnlockButton(tester); - - await tester.tap(find.text("Unlock Door")); - await tester.pumpAndSettle(); - - verify(() => mockUnlocker.unlockNearestDoor()).called(1); - }); -} \ No newline at end of file diff --git a/test/pages/verify_code_page_test.dart b/test/pages/verify_code_page_test.dart deleted file mode 100644 index 1ddeb32..0000000 --- a/test/pages/verify_code_page_test.dart +++ /dev/null @@ -1,225 +0,0 @@ -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/pages/reset_protected_page.dart'; -import 'package:clean_stream_laundry_app/pages/verify_code_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mocktail/mocktail.dart'; - -import '../logic/viewmodels/mocks.dart'; - -void main() { - late MockAuthService mockAuthService; - - setUp(() { - mockAuthService = MockAuthService(); - - final getIt = GetIt.instance; - if (getIt.isRegistered()) { - getIt.unregister(); - } - - getIt.registerSingleton(mockAuthService); - }); - - tearDown(() => GetIt.instance.reset()); - - Widget createWidgetUnderTest() { - final router = GoRouter( - initialLocation: '/verify-code', - routes: [ - GoRoute( - path: '/verify-code', - builder: (context, state) => - CodeVerificationPage(email: "testEmail"), - ), - GoRoute( - path: '/reset-protected', - builder: (context, state) => - ResetProtectedPage(), - ) - ], - ); - - return MaterialApp.router(routerConfig: router); - } - - group("UI elements render correctly", (){ - - testWidgets('validates title renders', (tester) async { - - await tester.pumpWidget( - createWidgetUnderTest() - ); - - await tester.pumpAndSettle(); - - expect(find.text("Verify Code"), findsOneWidget); - - }); - - testWidgets('Subheading renders', (tester) async { - - await tester.pumpWidget( - createWidgetUnderTest() - ); - - await tester.pumpAndSettle(); - - expect(find.text("Enter Verification Code"), findsOneWidget); - - }); - - testWidgets('Instructions render', (tester) async { - - await tester.pumpWidget( - createWidgetUnderTest() - ); - - await tester.pumpAndSettle(); - - expect(find.text("We sent a 6-digit code to"), findsOneWidget); - - }); - - testWidgets('Text field renders', (tester) async { - - await tester.pumpWidget( - createWidgetUnderTest() - ); - - await tester.pumpAndSettle(); - - expect(find.byType(TextField), findsOneWidget); - - }); - - testWidgets('Verify Button renders', (tester) async { - - await tester.pumpWidget( - createWidgetUnderTest() - ); - - await tester.pumpAndSettle(); - - expect(find.byType(ElevatedButton), findsOneWidget); - expect(find.text("Verify"), findsOneWidget); - }); - - testWidgets('Resend Button renders', (tester) async { - - await tester.pumpWidget( - createWidgetUnderTest() - ); - - await tester.pumpAndSettle(); - - expect(find.byType(TextButton), findsOneWidget); - expect(find.text("Resend code"), findsOneWidget); - }); - - }); - - group("Logic tests", (){ - - testWidgets('Error is thrown if a code entered is too short', (tester) async { - - await tester.pumpWidget( - createWidgetUnderTest() - ); - - await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField).at(0), '1234'); - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - - expect(find.text('Please enter the 6-digit code'), findsWidgets); - - }); - - testWidgets('Navigates correctly if a correct code was entered', (tester) async { - - when(() => mockAuthService.verifyCode(email: any(named:"email"), code: any(named:"code"))).thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget( - createWidgetUnderTest() - ); - - await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), '123456'); - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - - expect(find.text('Reset Password'), findsWidgets); - - }); - - testWidgets('Shows error if the code verification was not correct', (tester) async { - - when(() => mockAuthService.verifyCode(email: any(named:"email"), code: any(named:"code"))).thenAnswer((_) async => AuthenticationResponses.failure); - - await tester.pumpWidget( - createWidgetUnderTest() - ); - - await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), '123456'); - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - - expect(find.text("Invalid or expired code"), findsWidgets); - - }); - - testWidgets('Shows error if exception was thrown', (tester) async { - - when(() => mockAuthService.verifyCode(email: any(named:"email"), code: any(named:"code"))).thenThrow(Exception()); - - await tester.pumpWidget( - createWidgetUnderTest() - ); - - await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), '123456'); - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - - expect(find.text("Something went wrong. Try again"), findsWidgets); - - }); - - testWidgets('Shows message if email reset was successful', (tester) async { - - when(() => mockAuthService.resetPassword(any())).thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget( - createWidgetUnderTest() - ); - - await tester.tap(find.byType(TextButton)); - await tester.pumpAndSettle(); - - expect(find.text("Password reset email sent! Check your email."), findsWidgets); - - }); - - testWidgets('Shows message if email reset was unsuccessful', (tester) async { - - when(() => mockAuthService.resetPassword(any())).thenAnswer((_) async => AuthenticationResponses.failure); - - await tester.pumpWidget( - createWidgetUnderTest() - ); - - await tester.tap(find.byType(TextButton)); - await tester.pumpAndSettle(); - - expect(find.text("Failed to send reset email."), findsWidgets); - - }); - - }); - -} \ No newline at end of file diff --git a/test/root_app_test.dart b/test/root_app_test.dart index d1a72dd..3f0ea6d 100644 --- a/test/root_app_test.dart +++ b/test/root_app_test.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:clean_stream_laundry_app/root_app.dart'; import 'package:go_router/go_router.dart'; -import 'pages/mocks.dart'; +import 'mocks.dart'; import 'package:clean_stream_laundry_app/main.dart'; import 'package:mocktail/mocktail.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; From 5e111f48a7a869e40c062ac68525e75cc175380e Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Mon, 23 Mar 2026 08:27:00 -0400 Subject: [PATCH 51/79] Moves widget --- .../refund_request/widgets/refund_form.dart | 2 +- .../widgets/transactions_search_sheet.dart | 0 lib/logic/viewmodels/loyalty_view_model.dart | 117 -------- lib/main.dart | 1 - .../refund_request/refund_request_test.dart | 2 +- .../widgets/refund_form_test.dart | 2 +- .../transaction_search_sheet_test.dart | 2 +- .../viewmodels/loyalty_view_model_test.dart | 279 ------------------ test/logic/viewmodels/mocks.dart | 13 - 9 files changed, 4 insertions(+), 414 deletions(-) rename lib/{ => features/refund_request}/widgets/transactions_search_sheet.dart (100%) delete mode 100644 lib/logic/viewmodels/loyalty_view_model.dart rename test/{ => features/refund_request}/widgets/transaction_search_sheet_test.dart (96%) delete mode 100644 test/logic/viewmodels/loyalty_view_model_test.dart delete mode 100644 test/logic/viewmodels/mocks.dart diff --git a/lib/features/refund_request/widgets/refund_form.dart b/lib/features/refund_request/widgets/refund_form.dart index b6a7f3a..96f9d71 100644 --- a/lib/features/refund_request/widgets/refund_form.dart +++ b/lib/features/refund_request/widgets/refund_form.dart @@ -1,6 +1,6 @@ import '../controller.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; -import 'package:clean_stream_laundry_app/widgets/transactions_search_sheet.dart'; +import 'package:clean_stream_laundry_app/features/refund_request/widgets/transactions_search_sheet.dart'; import 'package:flutter/material.dart'; class RefundForm extends StatelessWidget { diff --git a/lib/widgets/transactions_search_sheet.dart b/lib/features/refund_request/widgets/transactions_search_sheet.dart similarity index 100% rename from lib/widgets/transactions_search_sheet.dart rename to lib/features/refund_request/widgets/transactions_search_sheet.dart diff --git a/lib/logic/viewmodels/loyalty_view_model.dart b/lib/logic/viewmodels/loyalty_view_model.dart deleted file mode 100644 index a0f3c14..0000000 --- a/lib/logic/viewmodels/loyalty_view_model.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import '../services/auth_service.dart'; -import '../services/profile_service.dart'; -import '../services/transaction_service.dart'; -import '../parsing/transaction_parser.dart'; -import '../enums/payment_result_enum.dart'; -import '../payment/process_payment.dart'; - - -class LoyaltyViewModel extends ChangeNotifier { - final _authService = GetIt.instance(); - final _profileService = GetIt.instance(); - final _transactionService = GetIt.instance(); - final _paymentProcessor = GetIt.instance(); - - double? userBalance; - double? userReward; - String? userName; - String? errorMessage; - bool isLoading = true; - bool showPastTransactions = false; - - List recentTransactions = []; - - // Call once from loyalty page - Future initialize() async { - await Future.wait([_fetchBalance(), _fetchTransactions()]); - } - - Future _fetchBalance() async { - final userId = _authService.getCurrentUserId; - - if (userId == null) { - errorMessage = 'User not known'; - isLoading = false; - notifyListeners(); - return; - } - - try { - final data = await _profileService.getUserBalanceById(userId); - - userBalance = (data?['balance'] as num?)?.toDouble() ?? 0.0; - userName = data?['full_name'] ?? 'John Doe'; - userReward = (data?["reward_tracker"] as num?)?.toDouble() ?? 0.0; - } catch (_) { - errorMessage = 'Failed to fetch balance'; - } - - isLoading = false; - notifyListeners(); - } - - Future _fetchTransactions() async { - final transactions = await _transactionService.getTransactionsForUser(); - final limit = showPastTransactions ? 100 : 3; - - final thirtyDaysAgo = DateTime.now().subtract(const Duration(days: 30)); - - final filtered = transactions.where((transaction) { - final createdAt = DateTime.parse(transaction['created_at'] as String); - final type = transaction['type'] as String?; - return createdAt.isAfter(thirtyDaysAgo) && type != "Rewards"; - }); - - recentTransactions = TransactionParser.formatTransactionsList( - filtered.take(limit), - "transactionHistory", - )..removeWhere((e) => e.isEmpty); - - notifyListeners(); - } - - Future toggleTransactionView() async { - showPastTransactions = !showPastTransactions; - notifyListeners(); - await _fetchTransactions(); - } - - Future loadCard(double amount) async { - final userId = _authService.getCurrentUserId; - final result = await _paymentProcessor.processPayment( - amount, - "Loyalty Card", - ); - - if (result == PaymentResult.success) { - amount = checkRewards(amount); - final newBalance = (userBalance ?? 0) + amount; - await _profileService.updateBalanceById(userId!, newBalance); - userBalance = newBalance; - await _fetchTransactions(); - } - - notifyListeners(); - return result; - } - - Future fetchTransactions() async { - await _transactionService.getTransactionsForUser(); - } - - double checkRewards(double amount) { - double combined = (userReward ?? 0) + amount; - double remainder = combined % 20; - int rewardsEarned = combined ~/ 20; - - if (remainder != (userReward ?? 0)) { - _profileService.updateRewardsById(_authService.getCurrentUserId!, remainder); - } - - userReward = remainder; - - return amount + (rewardsEarned * 5); - } -} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index cb4db57..d3b7854 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,7 +26,6 @@ import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; import 'package:clean_stream_laundry_app/services/supabase/supabase_transaction_service.dart'; -import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; final getIt = GetIt.instance; diff --git a/test/features/refund_request/refund_request_test.dart b/test/features/refund_request/refund_request_test.dart index 7c30e06..52d8d8d 100644 --- a/test/features/refund_request/refund_request_test.dart +++ b/test/features/refund_request/refund_request_test.dart @@ -3,7 +3,7 @@ import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; -import 'package:clean_stream_laundry_app/widgets/transactions_search_sheet.dart'; +import 'package:clean_stream_laundry_app/features/refund_request/widgets/transactions_search_sheet.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/features/refund_request/widgets/refund_form_test.dart b/test/features/refund_request/widgets/refund_form_test.dart index 420fd0f..bd7515b 100644 --- a/test/features/refund_request/widgets/refund_form_test.dart +++ b/test/features/refund_request/widgets/refund_form_test.dart @@ -1,6 +1,6 @@ import 'package:clean_stream_laundry_app/features/refund_request/controller.dart'; import 'package:clean_stream_laundry_app/features/refund_request/widgets/refund_form.dart'; -import 'package:clean_stream_laundry_app/widgets/transactions_search_sheet.dart'; +import 'package:clean_stream_laundry_app/features/refund_request/widgets/transactions_search_sheet.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; diff --git a/test/widgets/transaction_search_sheet_test.dart b/test/features/refund_request/widgets/transaction_search_sheet_test.dart similarity index 96% rename from test/widgets/transaction_search_sheet_test.dart rename to test/features/refund_request/widgets/transaction_search_sheet_test.dart index 1a6da77..7f3f1d2 100644 --- a/test/widgets/transaction_search_sheet_test.dart +++ b/test/features/refund_request/widgets/transaction_search_sheet_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:clean_stream_laundry_app/widgets/transactions_search_sheet.dart'; +import 'package:clean_stream_laundry_app/features/refund_request/widgets/transactions_search_sheet.dart'; void main() { const transactions = [ diff --git a/test/logic/viewmodels/loyalty_view_model_test.dart b/test/logic/viewmodels/loyalty_view_model_test.dart deleted file mode 100644 index d5ea484..0000000 --- a/test/logic/viewmodels/loyalty_view_model_test.dart +++ /dev/null @@ -1,279 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:get_it/get_it.dart'; -import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; -import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; -import 'mocks.dart'; -import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; - -void main() { - late LoyaltyViewModel viewModel; - late MockAuthService mockAuthService; - late MockProfileService mockProfileService; - late MockTransactionService mockTransactionService; - late MockPaymentProcessor mockPaymentProcessor; - - setUp(() { - // Clear GetIt before each test - GetIt.instance.reset(); - - // Create mocks - mockAuthService = MockAuthService(); - mockProfileService = MockProfileService(); - mockTransactionService = MockTransactionService(); - mockPaymentProcessor = MockPaymentProcessor(); - - // Register mocks with GetIt - GetIt.instance.registerSingleton(mockAuthService); - GetIt.instance.registerSingleton(mockProfileService); - GetIt.instance.registerSingleton( - mockTransactionService, - ); - GetIt.instance.registerSingleton(mockPaymentProcessor); - - // Create viewModel - viewModel = LoyaltyViewModel(); - }); - - tearDown(() { - GetIt.instance.reset(); - }); - - group('initialize', () { - test('should fetch balance and transactions successfully', () async { - // Arrange - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - when( - () => mockProfileService.getUserBalanceById('user123'), - ).thenAnswer((_) async => {'balance': 100.0, 'full_name': 'Jane Doe'}); - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => []); - - // Act - await viewModel.initialize(); - - // Assert - expect(viewModel.userBalance, 100.0); - expect(viewModel.userName, 'Jane Doe'); - expect(viewModel.isLoading, false); - expect(viewModel.errorMessage, null); - - // Verify interactions - verify(() => mockAuthService.getCurrentUserId).called(1); - verify(() => mockProfileService.getUserBalanceById('user123')).called(1); - verify(() => mockTransactionService.getTransactionsForUser()).called(1); - }); - - test('should handle profile service error gracefully', () async { - // Arrange - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - when( - () => mockProfileService.getUserBalanceById('user123'), - ).thenThrow(Exception('Network error')); - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => []); - - // Act - await viewModel.initialize(); - - // Assert - expect(viewModel.errorMessage, 'Failed to fetch balance'); - expect(viewModel.isLoading, false); - }); - - test('initialize should handle null userId', () async { - // Arrange - when(() => mockAuthService.getCurrentUserId).thenReturn(null); - when(() => mockTransactionService.getTransactionsForUser()) - .thenAnswer((_) async => []); - - // Act - await viewModel.initialize(); - - // Assert - expect(viewModel.errorMessage, 'User not known'); - expect(viewModel.isLoading, false); - - verifyNever(() => mockProfileService.getUserBalanceById(any())); - }); - - test('should default to 0.0 balance when null', () async { - // Arrange - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - when( - () => mockProfileService.getUserBalanceById('user123'), - ).thenAnswer((_) async => {'balance': null, 'full_name': 'Jane Doe'}); - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => []); - - // Act - await viewModel.initialize(); - - // Assert - expect(viewModel.userBalance, 0.0); - expect(viewModel.userName, 'Jane Doe'); - }); - - test('should default to "John Doe" when name is null', () async { - // Arrange - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - when( - () => mockProfileService.getUserBalanceById('user123'), - ).thenAnswer((_) async => {'balance': 100.0, 'full_name': null}); - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => []); - - // Act - await viewModel.initialize(); - - // Assert - expect(viewModel.userName, 'John Doe'); - }); - }); - - group('toggleTransactionView', () { - test('should toggle showPastTransactions from false to true', () async { - // Arrange - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => []); - - expect(viewModel.showPastTransactions, false); - - // Act - await viewModel.toggleTransactionView(); - - // Assert - expect(viewModel.showPastTransactions, true); - verify(() => mockTransactionService.getTransactionsForUser()).called(1); - }); - - test('should toggle showPastTransactions from true to false', () async { - // Arrange - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => []); - - viewModel.showPastTransactions = true; - - // Act - await viewModel.toggleTransactionView(); - - // Assert - expect(viewModel.showPastTransactions, false); - }); - }); - - group('fetchTransactions', () { - test('should call transaction service', () async { - // Arrange - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => []); - - // Act - await viewModel.fetchTransactions(); - - // Assert - verify(() => mockTransactionService.getTransactionsForUser()).called(1); - }); - - test('fetchTransactions filters out Rewards and old transactions', () async { - // Arrange - final now = DateTime.now(); - when(() => mockTransactionService.getTransactionsForUser()).thenAnswer( - (_) async => [ - { - 'created_at': now.toIso8601String(), - 'type': 'Laundry', - 'amount': 10, - 'description': 'Wash', - }, - { - 'created_at': now.toIso8601String(), - 'type': 'Rewards', - 'amount': 1, - 'description': 'Reward', - }, - { - 'created_at': - now.subtract(const Duration(days: 40)).toIso8601String(), - 'type': 'Laundry', - 'amount': 5, - 'description': 'Old wash', - }, - ], - ); - - // Act - await viewModel.toggleTransactionView(); - - // Assert - expect(viewModel.recentTransactions.length, 1); - }); - }); - - group('loadCard', () { - test('loadCard should update balance and fetch transactions on success', - () async { - // Arrange - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - - viewModel.userBalance = 20.0; - - when(() => mockPaymentProcessor.processPayment( - 10.0, - 'Loyalty Card', - )).thenAnswer((_) async => PaymentResult.success); - - when(() => mockProfileService.updateBalanceById('user123', 30)) - .thenAnswer((_) async => Future.value()); - - // Stub for checkRewards -> updateRewardsById called internally - when(() => mockProfileService.updateRewardsById(any(), any())) - .thenAnswer((_) async => Future.value()); - - when(() => mockTransactionService.getTransactionsForUser()) - .thenAnswer((_) async => []); - - // Act - final result = await viewModel.loadCard(10.0); - - // Assert - expect(result, PaymentResult.success); - expect(viewModel.userBalance, 30); - - verify(() => mockProfileService.updateBalanceById('user123', 30)) - .called(1); - verify(() => mockTransactionService.getTransactionsForUser()).called(1); - }); - - test('loadCard should not update balance on failed payment', () async { - // Arrange - when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - - viewModel.userBalance = 20.0; - - when(() => mockPaymentProcessor.processPayment( - 10.0, - 'Loyalty Card', - )).thenAnswer((_) async => PaymentResult.failed); - - // Act - final result = await viewModel.loadCard(10.0); - - // Assert - expect(result, PaymentResult.failed); - expect(viewModel.userBalance, 20.0); - - verifyNever(() => mockProfileService.updateBalanceById(any(), any())); - }); - }); -} \ No newline at end of file diff --git a/test/logic/viewmodels/mocks.dart b/test/logic/viewmodels/mocks.dart deleted file mode 100644 index 5e6daac..0000000 --- a/test/logic/viewmodels/mocks.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:mocktail/mocktail.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; -import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; - -class MockAuthService extends Mock implements AuthService {} - -class MockProfileService extends Mock implements ProfileService {} - -class MockTransactionService extends Mock implements TransactionService {} - -class MockPaymentProcessor extends Mock implements PaymentProcessor {} From 2f5f08748694d2574ed6f3c6f4573c5a156a4ffe Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Mon, 23 Mar 2026 08:57:37 -0400 Subject: [PATCH 52/79] Cleans up entry point --- lib/core/di/di.dart | 99 ++++++++++++++ lib/features/edit_profile/edit_profile.dart | 2 +- lib/features/home/home.dart | 2 +- lib/features/loyalty/loyalty.dart | 4 +- .../machine_payment/machine_payment.dart | 4 +- .../refund_request/refund_request.dart | 2 +- lib/features/scanner/scanner.dart | 4 +- lib/features/settings/settings.dart | 2 +- lib/features/start_machine/start_machine.dart | 6 +- lib/{ => features}/widgets/base_page.dart | 4 +- .../widgets/custom_app_bar.dart | 0 lib/{ => features}/widgets/map_marker.dart | 0 .../widgets/navigation_bar.dart | 0 .../widgets/section_banner.dart | 0 .../widgets/status_dialog_box.dart | 0 lib/logic/parsing/location_parser.dart | 2 +- lib/main.dart | 124 ++---------------- lib/root_app.dart | 28 ++-- test/features/loyalty/loyalty_test.dart | 2 +- .../widgets/ base_page_test.dart | 6 +- .../widgets/custom_app_bar_test.dart | 2 +- .../widgets/map_marker_test.dart | 2 +- .../widgets/navigation_bar_test.dart | 2 +- .../widgets/status_dialog_box_test.dart | 2 +- test/logic/parsing/location_parser_test.dart | 2 +- 25 files changed, 151 insertions(+), 150 deletions(-) create mode 100644 lib/core/di/di.dart rename lib/{ => features}/widgets/base_page.dart (73%) rename lib/{ => features}/widgets/custom_app_bar.dart (100%) rename lib/{ => features}/widgets/map_marker.dart (100%) rename lib/{ => features}/widgets/navigation_bar.dart (100%) rename lib/{ => features}/widgets/section_banner.dart (100%) rename lib/{ => features}/widgets/status_dialog_box.dart (100%) rename test/{ => features}/widgets/ base_page_test.dart (91%) rename test/{ => features}/widgets/custom_app_bar_test.dart (95%) rename test/{ => features}/widgets/map_marker_test.dart (94%) rename test/{ => features}/widgets/navigation_bar_test.dart (98%) rename test/{ => features}/widgets/status_dialog_box_test.dart (96%) diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart new file mode 100644 index 0000000..a674331 --- /dev/null +++ b/lib/core/di/di.dart @@ -0,0 +1,99 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:get_it/get_it.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:flutter_stripe/flutter_stripe.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:go_router/go_router.dart'; + +// services +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/payment_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; + +// implementations +import 'package:clean_stream_laundry_app/services/supabase/supabase_auth_service.dart'; +import 'package:clean_stream_laundry_app/services/supabase/supabase_edge_function_service.dart'; +import 'package:clean_stream_laundry_app/services/supabase/supabase_location_service.dart'; +import 'package:clean_stream_laundry_app/services/supabase/supabase_machine_service.dart'; +import 'package:clean_stream_laundry_app/services/supabase/supabase_profile_service.dart'; +import 'package:clean_stream_laundry_app/services/supabase/supabase_transaction_service.dart'; +import 'package:clean_stream_laundry_app/services/stripe/stripe_service.dart'; +import 'package:clean_stream_laundry_app/services/nayax/machine_communicator.dart'; + +// misc +import 'package:clean_stream_laundry_app/middleware/app_router.dart'; +import 'package:clean_stream_laundry_app/services/notification_service.dart'; +import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; + +final getIt = GetIt.instance; + +Future setupDependencies() async { + await dotenv.load(fileName: '.env'); + + await Supabase.initialize( + url: dotenv.env['SUPABASE_URL']!, + anonKey: dotenv.env['ANON_KEY']!, + ); + + final supabase = Supabase.instance.client; + + Stripe.publishableKey = dotenv.env['STRIPE_PUBLISHABLE_KEY']!; + + getIt.registerLazySingleton( + () => SupabaseTransactionService(client: supabase), + ); + + getIt.registerLazySingleton( + () => SupabaseProfileService(client: supabase), + ); + + getIt.registerLazySingleton( + () => SupabaseMachineService(client: supabase), + ); + + getIt.registerLazySingleton( + () => SupabaseLocationHandler(client: supabase), + ); + + getIt.registerLazySingleton( + () => SupabaseEdgeFunctionService(client: supabase), + ); + + getIt.registerLazySingleton( + () => SupabaseAuthService(client: supabase), + ); + + getIt.registerLazySingleton(() => StripeService()); + + getIt.registerLazySingleton(() => Stripe.instance); + + getIt.registerLazySingleton( + () => MachineCommunicator(), + ); + + getIt.registerLazySingleton(() => RouterService()); + + getIt.registerLazySingleton( + () => NotificationService(), + ); + + getIt.registerSingleton( + FlutterLocalNotificationsPlugin(), + ); + + getIt.registerLazySingleton( + () => PaymentProcessor(), + ); + + getIt.registerLazySingleton(() { + final authService = getIt(); + final routerService = getIt(); + + return routerService.createRouter(authService); + }); +} \ No newline at end of file diff --git a/lib/features/edit_profile/edit_profile.dart b/lib/features/edit_profile/edit_profile.dart index 199b483..7f9a2c6 100644 --- a/lib/features/edit_profile/edit_profile.dart +++ b/lib/features/edit_profile/edit_profile.dart @@ -6,7 +6,7 @@ import 'package:clean_stream_laundry_app/features/edit_profile/widgets/name_form import 'package:clean_stream_laundry_app/features/edit_profile/widgets/save_button.dart'; import 'package:clean_stream_laundry_app/features/edit_profile/widgets/section_header.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; -import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; +import 'package:clean_stream_laundry_app/features/widgets/status_dialog_box.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index e869ed5..95741ef 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -3,7 +3,7 @@ import 'package:clean_stream_laundry_app/features/home/widgets/availability_card import 'package:clean_stream_laundry_app/features/home/widgets/map.dart'; import 'package:clean_stream_laundry_app/features/home/widgets/location_selector.dart'; import 'package:clean_stream_laundry_app/features/home/widgets/header.dart'; -import 'package:clean_stream_laundry_app/widgets/base_page.dart'; +import 'package:clean_stream_laundry_app/features/widgets/base_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; diff --git a/lib/features/loyalty/loyalty.dart b/lib/features/loyalty/loyalty.dart index 6fcc497..aa10118 100644 --- a/lib/features/loyalty/loyalty.dart +++ b/lib/features/loyalty/loyalty.dart @@ -3,8 +3,8 @@ import 'controller.dart'; import 'widgets/load_card_dialog.dart'; import 'widgets/header.dart'; import 'widgets/transaction_list.dart'; -import 'package:clean_stream_laundry_app/widgets/base_page.dart'; -import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; +import 'package:clean_stream_laundry_app/features/widgets/base_page.dart'; +import 'package:clean_stream_laundry_app/features/widgets/status_dialog_box.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/features/machine_payment/machine_payment.dart b/lib/features/machine_payment/machine_payment.dart index 096396e..95ebf14 100644 --- a/lib/features/machine_payment/machine_payment.dart +++ b/lib/features/machine_payment/machine_payment.dart @@ -2,9 +2,9 @@ import 'controller.dart'; import 'widgets/amount_card.dart'; import 'widgets/back_to_home.dart'; import 'widgets/payment_buttons.dart'; -import 'package:clean_stream_laundry_app/widgets/base_page.dart'; +import 'package:clean_stream_laundry_app/features/widgets/base_page.dart'; import 'widgets/dryer_controls_card.dart'; -import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; +import 'package:clean_stream_laundry_app/features/widgets/status_dialog_box.dart'; import 'widgets/washer_controls_card.dart'; import 'package:flutter/material.dart'; diff --git a/lib/features/refund_request/refund_request.dart b/lib/features/refund_request/refund_request.dart index 132a8c3..ebd6550 100644 --- a/lib/features/refund_request/refund_request.dart +++ b/lib/features/refund_request/refund_request.dart @@ -3,7 +3,7 @@ import 'widgets/disclaimer_card.dart'; import 'widgets/refund_form.dart'; import 'widgets/header.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; -import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; +import 'package:clean_stream_laundry_app/features/widgets/status_dialog_box.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; diff --git a/lib/features/scanner/scanner.dart b/lib/features/scanner/scanner.dart index 8939387..101d2a8 100644 --- a/lib/features/scanner/scanner.dart +++ b/lib/features/scanner/scanner.dart @@ -1,7 +1,7 @@ import 'controller.dart'; import 'widgets/scanner_overlay.dart'; -import 'package:clean_stream_laundry_app/widgets/base_page.dart'; -import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; +import 'package:clean_stream_laundry_app/features/widgets/base_page.dart'; +import 'package:clean_stream_laundry_app/features/widgets/status_dialog_box.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; diff --git a/lib/features/settings/settings.dart b/lib/features/settings/settings.dart index 04cd7d1..ca1447d 100644 --- a/lib/features/settings/settings.dart +++ b/lib/features/settings/settings.dart @@ -3,7 +3,7 @@ import 'widgets/notification_lead.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme_manager.dart'; import 'widgets/settings_card.dart'; -import 'package:clean_stream_laundry_app/widgets/base_page.dart'; +import 'package:clean_stream_laundry_app/features/widgets/base_page.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; diff --git a/lib/features/start_machine/start_machine.dart b/lib/features/start_machine/start_machine.dart index 9b04764..46e677d 100644 --- a/lib/features/start_machine/start_machine.dart +++ b/lib/features/start_machine/start_machine.dart @@ -3,9 +3,9 @@ import 'widgets/qr_button.dart'; import 'widgets/searching_dialog.dart'; import 'widgets/tap_card.dart'; import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; -import 'package:clean_stream_laundry_app/widgets/base_page.dart'; -import 'package:clean_stream_laundry_app/widgets/section_banner.dart'; -import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; +import 'package:clean_stream_laundry_app/features/widgets/base_page.dart'; +import 'package:clean_stream_laundry_app/features/widgets/section_banner.dart'; +import 'package:clean_stream_laundry_app/features/widgets/status_dialog_box.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/widgets/base_page.dart b/lib/features/widgets/base_page.dart similarity index 73% rename from lib/widgets/base_page.dart rename to lib/features/widgets/base_page.dart index 932f9d9..d48e0b8 100644 --- a/lib/widgets/base_page.dart +++ b/lib/features/widgets/base_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:clean_stream_laundry_app/widgets/custom_app_bar.dart'; -import 'package:clean_stream_laundry_app/widgets/navigation_bar.dart'; +import 'custom_app_bar.dart'; +import 'navigation_bar.dart'; class BasePage extends StatelessWidget { final Widget body; diff --git a/lib/widgets/custom_app_bar.dart b/lib/features/widgets/custom_app_bar.dart similarity index 100% rename from lib/widgets/custom_app_bar.dart rename to lib/features/widgets/custom_app_bar.dart diff --git a/lib/widgets/map_marker.dart b/lib/features/widgets/map_marker.dart similarity index 100% rename from lib/widgets/map_marker.dart rename to lib/features/widgets/map_marker.dart diff --git a/lib/widgets/navigation_bar.dart b/lib/features/widgets/navigation_bar.dart similarity index 100% rename from lib/widgets/navigation_bar.dart rename to lib/features/widgets/navigation_bar.dart diff --git a/lib/widgets/section_banner.dart b/lib/features/widgets/section_banner.dart similarity index 100% rename from lib/widgets/section_banner.dart rename to lib/features/widgets/section_banner.dart diff --git a/lib/widgets/status_dialog_box.dart b/lib/features/widgets/status_dialog_box.dart similarity index 100% rename from lib/widgets/status_dialog_box.dart rename to lib/features/widgets/status_dialog_box.dart diff --git a/lib/logic/parsing/location_parser.dart b/lib/logic/parsing/location_parser.dart index 90a4a2f..9acc51e 100644 --- a/lib/logic/parsing/location_parser.dart +++ b/lib/logic/parsing/location_parser.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/widgets/map_marker.dart'; +import 'package:clean_stream_laundry_app/features/widgets/map_marker.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:geolocator/geolocator.dart'; diff --git a/lib/main.dart b/lib/main.dart index d3b7854..797375c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,121 +1,25 @@ -import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/payment_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/middleware/app_router.dart'; -import 'package:clean_stream_laundry_app/services/nayax/machine_communicator.dart'; -import 'package:clean_stream_laundry_app/services/stripe/stripe_service.dart'; -import 'package:clean_stream_laundry_app/services/supabase/supabase_auth_service.dart'; -import 'package:clean_stream_laundry_app/services/supabase/supabase_edge_function_service.dart'; -import 'package:clean_stream_laundry_app/services/supabase/supabase_location_service.dart'; -import 'package:clean_stream_laundry_app/services/supabase/supabase_machine_service.dart'; -import 'package:clean_stream_laundry_app/services/supabase/supabase_profile_service.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:flutter_stripe/flutter_stripe.dart'; -import 'package:get_it/get_it.dart'; -import 'services/notification_service.dart'; -import 'logic/theme/theme_manager.dart'; -import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; -import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; -import 'package:clean_stream_laundry_app/services/supabase/supabase_transaction_service.dart'; - -final getIt = GetIt.instance; +import 'package:go_router/go_router.dart'; +import 'root_app.dart'; +import 'core/di/di.dart'; +import 'logic/theme/theme_manager.dart'; late final GoRouter pageRouter; void main() async { WidgetsFlutterBinding.ensureInitialized(); - await dotenv.load(fileName: '.env'); await setupDependencies(); - final authService = getIt(); - final routerService = getIt(); - pageRouter = routerService.createRouter(authService); - - runApp( - ChangeNotifierProvider(create: (_) => ThemeManager(), child: const MyApp()), - ); -} - -Future setupDependencies() async { - await Supabase.initialize( - url: '${dotenv.env['SUPABASE_URL']}', - anonKey: '${dotenv.env['ANON_KEY']}', - ); - final supabase = Supabase.instance.client; - - Stripe.publishableKey = "${dotenv.env['STRIPE_PUBLISHABLE_KEY']}"; - - getIt.registerLazySingleton( - () => SupabaseTransactionService(client: supabase), - ); - - getIt.registerLazySingleton( - () => SupabaseProfileService(client: supabase), - ); - - getIt.registerLazySingleton( - () => SupabaseMachineService(client: supabase), - ); - - getIt.registerLazySingleton( - () => SupabaseLocationHandler(client: supabase), - ); - - getIt.registerLazySingleton( - () => SupabaseEdgeFunctionService(client: supabase), - ); - - getIt.registerLazySingleton( - () => SupabaseAuthService(client: supabase), - ); - - getIt.registerLazySingleton(() => StripeService()); - - getIt.registerLazySingleton(() => Stripe.instance); - - getIt.registerLazySingleton( - () => MachineCommunicator(), - ); - getIt.registerLazySingleton( - () => RouterService() - ); - - getIt.registerLazySingleton( - () => NotificationService(), - ); - - GetIt.instance.registerSingleton( - FlutterLocalNotificationsPlugin(), - ); + final router = getIt(); - //getIt.registerLazySingleton(() => LoyaltyViewModel()); - - getIt.registerLazySingleton(() => PaymentProcessor()); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, themeManager, _) { - return MaterialApp.router( - routerConfig: pageRouter, - theme: themeManager.themeData, - debugShowCheckedModeBanner: false, - ); - }, - ); - } -} + runApp( + ChangeNotifierProvider( + create: (_) => ThemeManager(), + child: RootApp( + router: router, + ), + ), + ); +} \ No newline at end of file diff --git a/lib/root_app.dart b/lib/root_app.dart index 5625485..0af13c4 100644 --- a/lib/root_app.dart +++ b/lib/root_app.dart @@ -1,27 +1,25 @@ -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/middleware/app_router.dart'; -import 'package:get_it/get_it.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme_manager.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; -class RootApp extends StatefulWidget { - final ThemeData theme; - const RootApp({super.key, required this.theme}); +class RootApp extends StatelessWidget { + final GoRouter router; - @override - State createState() => _RootAppState(); -} - -class _RootAppState extends State { - late final AuthService _authenticator = GetIt.instance(); - late final RouterService _routerService = GetIt.instance(); + const RootApp({ + super.key, + required this.router, + }); @override Widget build(BuildContext context) { + final theme = context.watch().themeData; + return MaterialApp.router( debugShowCheckedModeBanner: false, title: 'Clean Stream Laundry Solutions', - theme: widget.theme, - routerConfig: _routerService.createRouter(_authenticator), + theme: theme, + routerConfig: router, ); } } \ No newline at end of file diff --git a/test/features/loyalty/loyalty_test.dart b/test/features/loyalty/loyalty_test.dart index a5ac794..df1fff6 100644 --- a/test/features/loyalty/loyalty_test.dart +++ b/test/features/loyalty/loyalty_test.dart @@ -1,6 +1,6 @@ import 'package:clean_stream_laundry_app/features/loyalty/loyalty.dart'; import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; -import 'package:clean_stream_laundry_app/widgets/base_page.dart'; +import 'package:clean_stream_laundry_app/features/widgets/base_page.dart'; import 'package:clean_stream_laundry_app/features/loyalty/widgets/credit_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/widgets/ base_page_test.dart b/test/features/widgets/ base_page_test.dart similarity index 91% rename from test/widgets/ base_page_test.dart rename to test/features/widgets/ base_page_test.dart index c0a3e6f..8727694 100644 --- a/test/widgets/ base_page_test.dart +++ b/test/features/widgets/ base_page_test.dart @@ -1,6 +1,6 @@ -import 'package:clean_stream_laundry_app/widgets/base_page.dart'; -import 'package:clean_stream_laundry_app/widgets/custom_app_bar.dart'; -import 'package:clean_stream_laundry_app/widgets/navigation_bar.dart'; +import 'package:clean_stream_laundry_app/features/widgets/base_page.dart'; +import 'package:clean_stream_laundry_app/features/widgets/custom_app_bar.dart'; +import 'package:clean_stream_laundry_app/features/widgets/navigation_bar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; diff --git a/test/widgets/custom_app_bar_test.dart b/test/features/widgets/custom_app_bar_test.dart similarity index 95% rename from test/widgets/custom_app_bar_test.dart rename to test/features/widgets/custom_app_bar_test.dart index e39028a..3c67d07 100644 --- a/test/widgets/custom_app_bar_test.dart +++ b/test/features/widgets/custom_app_bar_test.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/widgets/custom_app_bar.dart'; +import 'package:clean_stream_laundry_app/features/widgets/custom_app_bar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/widgets/map_marker_test.dart b/test/features/widgets/map_marker_test.dart similarity index 94% rename from test/widgets/map_marker_test.dart rename to test/features/widgets/map_marker_test.dart index ad6aa24..99ca8b8 100644 --- a/test/widgets/map_marker_test.dart +++ b/test/features/widgets/map_marker_test.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/widgets/map_marker.dart'; +import 'package:clean_stream_laundry_app/features/widgets/map_marker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/widgets/navigation_bar_test.dart b/test/features/widgets/navigation_bar_test.dart similarity index 98% rename from test/widgets/navigation_bar_test.dart rename to test/features/widgets/navigation_bar_test.dart index dc465dc..eca80f7 100644 --- a/test/widgets/navigation_bar_test.dart +++ b/test/features/widgets/navigation_bar_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; -import 'package:clean_stream_laundry_app/widgets/navigation_bar.dart'; +import 'package:clean_stream_laundry_app/features/widgets/navigation_bar.dart'; void main() { Widget wrapWithRouter(String initialLocation) { diff --git a/test/widgets/status_dialog_box_test.dart b/test/features/widgets/status_dialog_box_test.dart similarity index 96% rename from test/widgets/status_dialog_box_test.dart rename to test/features/widgets/status_dialog_box_test.dart index 8ca2eb4..677185b 100644 --- a/test/widgets/status_dialog_box_test.dart +++ b/test/features/widgets/status_dialog_box_test.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; +import 'package:clean_stream_laundry_app/features/widgets/status_dialog_box.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; diff --git a/test/logic/parsing/location_parser_test.dart b/test/logic/parsing/location_parser_test.dart index 27e0350..8b7a117 100644 --- a/test/logic/parsing/location_parser_test.dart +++ b/test/logic/parsing/location_parser_test.dart @@ -1,6 +1,6 @@ import 'package:clean_stream_laundry_app/logic/parsing/location_parser.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:clean_stream_laundry_app/widgets/map_marker.dart'; +import 'package:clean_stream_laundry_app/features/widgets/map_marker.dart'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; import 'package:mocktail/mocktail.dart'; From bbb8f26cd3fe9b24d510b7303d36ea59e9b440bd Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Mon, 23 Mar 2026 09:02:02 -0400 Subject: [PATCH 53/79] Fixes DI in tests --- lib/core/di/di.dart | 4 +- lib/services/nayax/machine_communicator.dart | 8 ++- test/mocks.dart | 6 -- test/root_app_test.dart | 57 ------------------- .../nayax/machine_communicator_test.dart | 18 ++---- 5 files changed, 14 insertions(+), 79 deletions(-) delete mode 100644 test/mocks.dart delete mode 100644 test/root_app_test.dart diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart index a674331..c639350 100644 --- a/lib/core/di/di.dart +++ b/lib/core/di/di.dart @@ -73,7 +73,9 @@ Future setupDependencies() async { getIt.registerLazySingleton(() => Stripe.instance); getIt.registerLazySingleton( - () => MachineCommunicator(), + () => MachineCommunicator( + edgeFunctionService: getIt(), + ), ); getIt.registerLazySingleton(() => RouterService()); diff --git a/lib/services/nayax/machine_communicator.dart b/lib/services/nayax/machine_communicator.dart index b83f27f..0cfccc9 100644 --- a/lib/services/nayax/machine_communicator.dart +++ b/lib/services/nayax/machine_communicator.dart @@ -1,10 +1,12 @@ import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; -import 'package:get_it/get_it.dart'; -import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; class MachineCommunicator implements MachineCommunicationService { - final edgeFunctionService = GetIt.instance(); + final edgeFunctionService; + + MachineCommunicator({ + required this.edgeFunctionService, + }); @override Future wakeDevice(String deviceId) async { diff --git a/test/mocks.dart b/test/mocks.dart deleted file mode 100644 index 5f814cc..0000000 --- a/test/mocks.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:mocktail/mocktail.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/middleware/app_router.dart'; - -class MockAuthService extends Mock implements AuthService {} -class MockRouterService extends Mock implements RouterService {} \ No newline at end of file diff --git a/test/root_app_test.dart b/test/root_app_test.dart deleted file mode 100644 index 3f0ea6d..0000000 --- a/test/root_app_test.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:clean_stream_laundry_app/middleware/app_router.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:clean_stream_laundry_app/root_app.dart'; -import 'package:go_router/go_router.dart'; -import 'mocks.dart'; -import 'package:clean_stream_laundry_app/main.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; - - -void main() { - late MockAuthService auth; - late MockRouterService routerService; - - setUp(() { - getIt.reset(); - routerService = MockRouterService(); - auth = MockAuthService(); - - getIt.registerSingleton(routerService); - getIt.registerSingleton(auth); - - final fakeRouter = GoRouter( - initialLocation: '/', - routes: [ - GoRoute( - path: '/', - builder: (_, __) => const Placeholder(key: Key('fake-home')), - ), - ], - ); - - when(() => routerService.createRouter(auth)).thenReturn(fakeRouter); - }); - - tearDown(() { - getIt.reset(); - }); - - testWidgets('RootApp builds correctly using mocked services & fake GoRouter', - (tester) async { - final theme = ThemeData( - primaryColor: Colors.orange, - ); - - await tester.pumpWidget(RootApp(theme: theme)); - await tester.pumpAndSettle(); - - expect(find.byType(MaterialApp), findsOneWidget); - - expect(find.byKey(const Key('fake-home')), findsOneWidget); - - final MaterialApp app = tester.widget(find.byType(MaterialApp)); - expect(app.theme!.primaryColor, Colors.orange); - }); -} \ No newline at end of file diff --git a/test/services/nayax/machine_communicator_test.dart b/test/services/nayax/machine_communicator_test.dart index b257ce0..9f10d78 100644 --- a/test/services/nayax/machine_communicator_test.dart +++ b/test/services/nayax/machine_communicator_test.dart @@ -1,6 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; import 'package:clean_stream_laundry_app/services/nayax/machine_communicator.dart'; -import 'package:clean_stream_laundry_app/main.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -12,13 +10,11 @@ void main() { group("Awake machine Tests", () { setUp(() { - getIt.reset(); edgeFunctionMock = EdgeFunctionMock(); - getIt.registerLazySingleton( - () => edgeFunctionMock); - - machineCommunicator = MachineCommunicator(); + machineCommunicator = MachineCommunicator( + edgeFunctionService: edgeFunctionMock, + ); }); test("machine successfully wakes up", () async { @@ -69,13 +65,11 @@ void main() { group("Check Availability Tests", () { setUp(() { - getIt.reset(); edgeFunctionMock = EdgeFunctionMock(); - getIt.registerLazySingleton( - () => edgeFunctionMock); - - machineCommunicator = MachineCommunicator(); + machineCommunicator = MachineCommunicator( + edgeFunctionService: edgeFunctionMock, + ); }); test('Check idle availability', () async { From 4820534dff4fab847adce9afa922e005d94ebc31 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Mon, 23 Mar 2026 09:10:05 -0400 Subject: [PATCH 54/79] Moves router to core/ --- lib/core/di/di.dart | 2 +- lib/{middleware => core/router}/app_router.dart | 0 test/services/supabase/profile/mocks.dart | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename lib/{middleware => core/router}/app_router.dart (100%) diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart index c639350..d1bb751 100644 --- a/lib/core/di/di.dart +++ b/lib/core/di/di.dart @@ -26,7 +26,7 @@ import 'package:clean_stream_laundry_app/services/stripe/stripe_service.dart'; import 'package:clean_stream_laundry_app/services/nayax/machine_communicator.dart'; // misc -import 'package:clean_stream_laundry_app/middleware/app_router.dart'; +import 'package:clean_stream_laundry_app/core/router/app_router.dart'; import 'package:clean_stream_laundry_app/services/notification_service.dart'; import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; diff --git a/lib/middleware/app_router.dart b/lib/core/router/app_router.dart similarity index 100% rename from lib/middleware/app_router.dart rename to lib/core/router/app_router.dart diff --git a/test/services/supabase/profile/mocks.dart b/test/services/supabase/profile/mocks.dart index 76d1b42..ed083ed 100644 --- a/test/services/supabase/profile/mocks.dart +++ b/test/services/supabase/profile/mocks.dart @@ -3,7 +3,7 @@ import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; -import 'package:clean_stream_laundry_app/middleware/app_router.dart'; +import 'package:clean_stream_laundry_app/core/router/app_router.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme_manager.dart'; import 'package:mocktail/mocktail.dart'; import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; From 1fb461f9f20241ab930d914bdcd8b2a705503340 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Mon, 23 Mar 2026 09:11:28 -0400 Subject: [PATCH 55/79] Moves theme to core/ --- lib/{logic => core}/theme/theme.dart | 0 lib/{logic => core}/theme/theme_manager.dart | 2 +- lib/features/edit_profile/edit_profile.dart | 2 +- lib/features/edit_profile/widgets/danger_zone.dart | 2 +- lib/features/edit_profile/widgets/email_form.dart | 2 +- lib/features/edit_profile/widgets/name_form.dart | 2 +- lib/features/email_verification/email_verification.dart | 2 +- .../email_verification/widgets/resend_verification.dart | 2 +- lib/features/home/widgets/availability_card.dart | 2 +- lib/features/home/widgets/header.dart | 2 +- lib/features/home/widgets/location_selector.dart | 2 +- lib/features/login/widgets/form_fields.dart | 2 +- lib/features/loyalty/widgets/credit_card.dart | 2 +- lib/features/loyalty/widgets/header.dart | 2 +- lib/features/loyalty/widgets/load_card_dialog.dart | 2 +- lib/features/loyalty/widgets/transaction_list.dart | 2 +- lib/features/machine_payment/widgets/amount_card.dart | 2 +- lib/features/machine_payment/widgets/dryer_controls_card.dart | 2 +- .../machine_payment/widgets/washer_controls_card.dart | 2 +- lib/features/monthly_report/monthly_report.dart | 2 +- lib/features/password_reset/password_reset.dart | 2 +- lib/features/refund_request/refund_request.dart | 2 +- lib/features/refund_request/widgets/header.dart | 2 +- lib/features/refund_request/widgets/refund_form.dart | 2 +- lib/features/settings/settings.dart | 4 ++-- lib/features/settings/widgets/notification_lead.dart | 2 +- lib/features/settings/widgets/settings_card.dart | 2 +- lib/features/sign_up/widgets/form_fields.dart | 2 +- lib/features/start_machine/widgets/searching_dialog.dart | 2 +- lib/features/start_machine/widgets/tap_card.dart | 2 +- lib/features/verify_code/verify_code.dart | 2 +- lib/features/widgets/custom_app_bar.dart | 2 +- lib/features/widgets/status_dialog_box.dart | 2 +- lib/main.dart | 2 +- lib/root_app.dart | 2 +- test/features/settings/mocks.dart | 2 +- test/features/settings/settings_test.dart | 2 +- test/features/settings/widgets/notification_lead_test.dart | 2 +- test/logic/theme/theme_test.dart | 2 +- test/services/supabase/profile/mocks.dart | 2 +- 40 files changed, 40 insertions(+), 40 deletions(-) rename lib/{logic => core}/theme/theme.dart (100%) rename lib/{logic => core}/theme/theme_manager.dart (93%) diff --git a/lib/logic/theme/theme.dart b/lib/core/theme/theme.dart similarity index 100% rename from lib/logic/theme/theme.dart rename to lib/core/theme/theme.dart diff --git a/lib/logic/theme/theme_manager.dart b/lib/core/theme/theme_manager.dart similarity index 93% rename from lib/logic/theme/theme_manager.dart rename to lib/core/theme/theme_manager.dart index 0b91a31..c9d422a 100644 --- a/lib/logic/theme/theme_manager.dart +++ b/lib/core/theme/theme_manager.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:clean_stream_laundry_app/middleware/storage_service.dart'; class ThemeManager with ChangeNotifier { diff --git a/lib/features/edit_profile/edit_profile.dart b/lib/features/edit_profile/edit_profile.dart index 7f9a2c6..d9a55c0 100644 --- a/lib/features/edit_profile/edit_profile.dart +++ b/lib/features/edit_profile/edit_profile.dart @@ -5,7 +5,7 @@ import 'package:clean_stream_laundry_app/features/edit_profile/widgets/info_card import 'package:clean_stream_laundry_app/features/edit_profile/widgets/name_form.dart'; import 'package:clean_stream_laundry_app/features/edit_profile/widgets/save_button.dart'; import 'package:clean_stream_laundry_app/features/edit_profile/widgets/section_header.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:clean_stream_laundry_app/features/widgets/status_dialog_box.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/features/edit_profile/widgets/danger_zone.dart b/lib/features/edit_profile/widgets/danger_zone.dart index 03873b7..7efca0c 100644 --- a/lib/features/edit_profile/widgets/danger_zone.dart +++ b/lib/features/edit_profile/widgets/danger_zone.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; class DangerZoneSection extends StatelessWidget { diff --git a/lib/features/edit_profile/widgets/email_form.dart b/lib/features/edit_profile/widgets/email_form.dart index 52e9594..c3bf6b3 100644 --- a/lib/features/edit_profile/widgets/email_form.dart +++ b/lib/features/edit_profile/widgets/email_form.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; class EmailFormField extends StatelessWidget { diff --git a/lib/features/edit_profile/widgets/name_form.dart b/lib/features/edit_profile/widgets/name_form.dart index 9469ece..c34159c 100644 --- a/lib/features/edit_profile/widgets/name_form.dart +++ b/lib/features/edit_profile/widgets/name_form.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; diff --git a/lib/features/email_verification/email_verification.dart b/lib/features/email_verification/email_verification.dart index 7595997..1acaf1d 100644 --- a/lib/features/email_verification/email_verification.dart +++ b/lib/features/email_verification/email_verification.dart @@ -1,7 +1,7 @@ import 'package:app_links/app_links.dart'; import 'package:clean_stream_laundry_app/features/email_verification/controller.dart'; import 'package:clean_stream_laundry_app/features/email_verification/widgets/resend_verification.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; class EmailVerificationPage extends StatefulWidget { diff --git a/lib/features/email_verification/widgets/resend_verification.dart b/lib/features/email_verification/widgets/resend_verification.dart index db5f0cf..e81bad0 100644 --- a/lib/features/email_verification/widgets/resend_verification.dart +++ b/lib/features/email_verification/widgets/resend_verification.dart @@ -1,6 +1,6 @@ import 'package:clean_stream_laundry_app/features/email_verification/controller.dart'; import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; class ResendVerificationWidget extends StatefulWidget { diff --git a/lib/features/home/widgets/availability_card.dart b/lib/features/home/widgets/availability_card.dart index 14334ee..8fcfe6e 100644 --- a/lib/features/home/widgets/availability_card.dart +++ b/lib/features/home/widgets/availability_card.dart @@ -1,5 +1,5 @@ import 'package:clean_stream_laundry_app/features/home/controller.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; class AvailabilityCard extends StatelessWidget { diff --git a/lib/features/home/widgets/header.dart b/lib/features/home/widgets/header.dart index 7d294fb..63c89fb 100644 --- a/lib/features/home/widgets/header.dart +++ b/lib/features/home/widgets/header.dart @@ -1,5 +1,5 @@ import 'package:clean_stream_laundry_app/features/home/controller.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; diff --git a/lib/features/home/widgets/location_selector.dart b/lib/features/home/widgets/location_selector.dart index 50771ff..91e3fbd 100644 --- a/lib/features/home/widgets/location_selector.dart +++ b/lib/features/home/widgets/location_selector.dart @@ -1,5 +1,5 @@ import 'package:clean_stream_laundry_app/features/home/controller.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; class LocationSelector extends StatelessWidget { diff --git a/lib/features/login/widgets/form_fields.dart b/lib/features/login/widgets/form_fields.dart index c368844..380cf82 100644 --- a/lib/features/login/widgets/form_fields.dart +++ b/lib/features/login/widgets/form_fields.dart @@ -1,5 +1,5 @@ import 'package:clean_stream_laundry_app/features/login/controller.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; class FormFields extends StatelessWidget { diff --git a/lib/features/loyalty/widgets/credit_card.dart b/lib/features/loyalty/widgets/credit_card.dart index 7e13e5b..e2f2afa 100644 --- a/lib/features/loyalty/widgets/credit_card.dart +++ b/lib/features/loyalty/widgets/credit_card.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter_svg/flutter_svg.dart'; class CreditCard extends StatelessWidget { diff --git a/lib/features/loyalty/widgets/header.dart b/lib/features/loyalty/widgets/header.dart index 50e6f19..4eded98 100644 --- a/lib/features/loyalty/widgets/header.dart +++ b/lib/features/loyalty/widgets/header.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import '../controller.dart'; import 'credit_card.dart'; import 'package:flutter/material.dart'; diff --git a/lib/features/loyalty/widgets/load_card_dialog.dart b/lib/features/loyalty/widgets/load_card_dialog.dart index 87707e5..4742a34 100644 --- a/lib/features/loyalty/widgets/load_card_dialog.dart +++ b/lib/features/loyalty/widgets/load_card_dialog.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; class LoadCardDialog extends StatefulWidget { diff --git a/lib/features/loyalty/widgets/transaction_list.dart b/lib/features/loyalty/widgets/transaction_list.dart index e3ec95b..19e4531 100644 --- a/lib/features/loyalty/widgets/transaction_list.dart +++ b/lib/features/loyalty/widgets/transaction_list.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import '../controller.dart'; import 'package:flutter/material.dart'; diff --git a/lib/features/machine_payment/widgets/amount_card.dart b/lib/features/machine_payment/widgets/amount_card.dart index 545a030..bb49bb3 100644 --- a/lib/features/machine_payment/widgets/amount_card.dart +++ b/lib/features/machine_payment/widgets/amount_card.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; class AmountCard extends StatelessWidget { diff --git a/lib/features/machine_payment/widgets/dryer_controls_card.dart b/lib/features/machine_payment/widgets/dryer_controls_card.dart index 4b8bd3f..cbba3a8 100644 --- a/lib/features/machine_payment/widgets/dryer_controls_card.dart +++ b/lib/features/machine_payment/widgets/dryer_controls_card.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; class DryerControlsCard extends StatefulWidget { final void Function(double price, int minutes) onChanged; diff --git a/lib/features/machine_payment/widgets/washer_controls_card.dart b/lib/features/machine_payment/widgets/washer_controls_card.dart index 775d410..cdec91d 100644 --- a/lib/features/machine_payment/widgets/washer_controls_card.dart +++ b/lib/features/machine_payment/widgets/washer_controls_card.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; class WasherControlsCard extends StatefulWidget { final void Function(double addedCost) onCycleChanged; diff --git a/lib/features/monthly_report/monthly_report.dart b/lib/features/monthly_report/monthly_report.dart index aa2f00d..79e6c4e 100644 --- a/lib/features/monthly_report/monthly_report.dart +++ b/lib/features/monthly_report/monthly_report.dart @@ -1,6 +1,6 @@ import 'controller.dart'; import 'widgets/month_card.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/features/password_reset/password_reset.dart b/lib/features/password_reset/password_reset.dart index 3cd26c0..e0116a1 100644 --- a/lib/features/password_reset/password_reset.dart +++ b/lib/features/password_reset/password_reset.dart @@ -1,6 +1,6 @@ import 'package:clean_stream_laundry_app/features/password_reset/controller.dart'; import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/features/refund_request/refund_request.dart b/lib/features/refund_request/refund_request.dart index ebd6550..8ce6fce 100644 --- a/lib/features/refund_request/refund_request.dart +++ b/lib/features/refund_request/refund_request.dart @@ -2,7 +2,7 @@ import 'controller.dart'; import 'widgets/disclaimer_card.dart'; import 'widgets/refund_form.dart'; import 'widgets/header.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:clean_stream_laundry_app/features/widgets/status_dialog_box.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; diff --git a/lib/features/refund_request/widgets/header.dart b/lib/features/refund_request/widgets/header.dart index b5490bb..2bd6938 100644 --- a/lib/features/refund_request/widgets/header.dart +++ b/lib/features/refund_request/widgets/header.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; class Header extends StatelessWidget { diff --git a/lib/features/refund_request/widgets/refund_form.dart b/lib/features/refund_request/widgets/refund_form.dart index 96f9d71..b784a8d 100644 --- a/lib/features/refund_request/widgets/refund_form.dart +++ b/lib/features/refund_request/widgets/refund_form.dart @@ -1,5 +1,5 @@ import '../controller.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:clean_stream_laundry_app/features/refund_request/widgets/transactions_search_sheet.dart'; import 'package:flutter/material.dart'; diff --git a/lib/features/settings/settings.dart b/lib/features/settings/settings.dart index ca1447d..4e62392 100644 --- a/lib/features/settings/settings.dart +++ b/lib/features/settings/settings.dart @@ -1,7 +1,7 @@ import 'controller.dart'; import 'widgets/notification_lead.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme_manager.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme_manager.dart'; import 'widgets/settings_card.dart'; import 'package:clean_stream_laundry_app/features/widgets/base_page.dart'; import 'package:flutter/material.dart'; diff --git a/lib/features/settings/widgets/notification_lead.dart b/lib/features/settings/widgets/notification_lead.dart index 51a015b..746af3a 100644 --- a/lib/features/settings/widgets/notification_lead.dart +++ b/lib/features/settings/widgets/notification_lead.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; class NotificationLead extends StatelessWidget { diff --git a/lib/features/settings/widgets/settings_card.dart b/lib/features/settings/widgets/settings_card.dart index 9cf98da..0014015 100644 --- a/lib/features/settings/widgets/settings_card.dart +++ b/lib/features/settings/widgets/settings_card.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; class SettingsCard extends StatelessWidget { diff --git a/lib/features/sign_up/widgets/form_fields.dart b/lib/features/sign_up/widgets/form_fields.dart index 102b0d2..41c985c 100644 --- a/lib/features/sign_up/widgets/form_fields.dart +++ b/lib/features/sign_up/widgets/form_fields.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; diff --git a/lib/features/start_machine/widgets/searching_dialog.dart b/lib/features/start_machine/widgets/searching_dialog.dart index 070b231..0b99b2b 100644 --- a/lib/features/start_machine/widgets/searching_dialog.dart +++ b/lib/features/start_machine/widgets/searching_dialog.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; late bool cancelSearch = false; diff --git a/lib/features/start_machine/widgets/tap_card.dart b/lib/features/start_machine/widgets/tap_card.dart index aad9953..931ca69 100644 --- a/lib/features/start_machine/widgets/tap_card.dart +++ b/lib/features/start_machine/widgets/tap_card.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; class TapToPayCard extends StatelessWidget { diff --git a/lib/features/verify_code/verify_code.dart b/lib/features/verify_code/verify_code.dart index e5f3372..bb4e7a3 100644 --- a/lib/features/verify_code/verify_code.dart +++ b/lib/features/verify_code/verify_code.dart @@ -1,6 +1,6 @@ import 'controller.dart'; import 'widgets/code_field.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/features/widgets/custom_app_bar.dart b/lib/features/widgets/custom_app_bar.dart index 6d75708..8aea8f2 100644 --- a/lib/features/widgets/custom_app_bar.dart +++ b/lib/features/widgets/custom_app_bar.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/features/widgets/status_dialog_box.dart b/lib/features/widgets/status_dialog_box.dart index b5e779f..901b14a 100644 --- a/lib/features/widgets/status_dialog_box.dart +++ b/lib/features/widgets/status_dialog_box.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; Future statusDialog( diff --git a/lib/main.dart b/lib/main.dart index 797375c..f55c045 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,7 +3,7 @@ import 'package:provider/provider.dart'; import 'package:go_router/go_router.dart'; import 'root_app.dart'; import 'core/di/di.dart'; -import 'logic/theme/theme_manager.dart'; +import 'core/theme/theme_manager.dart'; late final GoRouter pageRouter; diff --git a/lib/root_app.dart b/lib/root_app.dart index 0af13c4..5aab5f7 100644 --- a/lib/root_app.dart +++ b/lib/root_app.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/theme/theme_manager.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme_manager.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; diff --git a/test/features/settings/mocks.dart b/test/features/settings/mocks.dart index 3d0e09a..035a976 100644 --- a/test/features/settings/mocks.dart +++ b/test/features/settings/mocks.dart @@ -1,7 +1,7 @@ import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme_manager.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme_manager.dart'; import 'package:mocktail/mocktail.dart'; class MockAuthService extends Mock implements AuthService {} diff --git a/test/features/settings/settings_test.dart b/test/features/settings/settings_test.dart index 0e06cf8..34aae78 100644 --- a/test/features/settings/settings_test.dart +++ b/test/features/settings/settings_test.dart @@ -3,7 +3,7 @@ import 'package:clean_stream_laundry_app/features/settings/widgets/settings_card import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme_manager.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme_manager.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; diff --git a/test/features/settings/widgets/notification_lead_test.dart b/test/features/settings/widgets/notification_lead_test.dart index ad6561f..b0b6ac7 100644 --- a/test/features/settings/widgets/notification_lead_test.dart +++ b/test/features/settings/widgets/notification_lead_test.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:clean_stream_laundry_app/features/settings/widgets/notification_lead.dart'; diff --git a/test/logic/theme/theme_test.dart b/test/logic/theme/theme_test.dart index bc563f5..eec3e78 100644 --- a/test/logic/theme/theme_test.dart +++ b/test/logic/theme/theme_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; void main() { group("ColorScheme extension tests", () { diff --git a/test/services/supabase/profile/mocks.dart b/test/services/supabase/profile/mocks.dart index ed083ed..36589b3 100644 --- a/test/services/supabase/profile/mocks.dart +++ b/test/services/supabase/profile/mocks.dart @@ -4,7 +4,7 @@ import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; import 'package:clean_stream_laundry_app/core/router/app_router.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme_manager.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme_manager.dart'; import 'package:mocktail/mocktail.dart'; import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; From 02a77a9d32f5a984870db6db577b179232b89450 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Mon, 23 Mar 2026 09:13:55 -0400 Subject: [PATCH 56/79] Adjusts imports and tests --- lib/{middleware => core/storage}/storage_service.dart | 0 lib/core/theme/theme_manager.dart | 2 +- lib/features/home/controller.dart | 2 +- test/{middleware => core/storage}/mocks.dart | 2 +- test/{middleware => core/storage}/storage_service_test.dart | 2 +- test/{logic => core}/theme/mocks.dart | 2 +- test/{logic => core}/theme/theme_test.dart | 0 7 files changed, 5 insertions(+), 5 deletions(-) rename lib/{middleware => core/storage}/storage_service.dart (100%) rename test/{middleware => core/storage}/mocks.dart (58%) rename test/{middleware => core/storage}/storage_service_test.dart (91%) rename test/{logic => core}/theme/mocks.dart (58%) rename test/{logic => core}/theme/theme_test.dart (100%) diff --git a/lib/middleware/storage_service.dart b/lib/core/storage/storage_service.dart similarity index 100% rename from lib/middleware/storage_service.dart rename to lib/core/storage/storage_service.dart diff --git a/lib/core/theme/theme_manager.dart b/lib/core/theme/theme_manager.dart index c9d422a..4ddef44 100644 --- a/lib/core/theme/theme_manager.dart +++ b/lib/core/theme/theme_manager.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:clean_stream_laundry_app/core/theme/theme.dart'; -import 'package:clean_stream_laundry_app/middleware/storage_service.dart'; +import 'package:clean_stream_laundry_app/core/storage/storage_service.dart'; class ThemeManager with ChangeNotifier { ThemeData _themeData = lightMode; diff --git a/lib/features/home/controller.dart b/lib/features/home/controller.dart index fa959d3..8209354 100644 --- a/lib/features/home/controller.dart +++ b/lib/features/home/controller.dart @@ -3,7 +3,7 @@ import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/middleware/storage_service.dart'; +import 'package:clean_stream_laundry_app/core/storage/storage_service.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; diff --git a/test/middleware/mocks.dart b/test/core/storage/mocks.dart similarity index 58% rename from test/middleware/mocks.dart rename to test/core/storage/mocks.dart index 71f8d81..4f63026 100644 --- a/test/middleware/mocks.dart +++ b/test/core/storage/mocks.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/middleware/storage_service.dart'; +import 'package:clean_stream_laundry_app/core/storage/storage_service.dart'; import 'package:mocktail/mocktail.dart'; class StorageServiceMock extends Mock implements StorageService{} \ No newline at end of file diff --git a/test/middleware/storage_service_test.dart b/test/core/storage/storage_service_test.dart similarity index 91% rename from test/middleware/storage_service_test.dart rename to test/core/storage/storage_service_test.dart index 65e0a58..4b1565f 100644 --- a/test/middleware/storage_service_test.dart +++ b/test/core/storage/storage_service_test.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/middleware/storage_service.dart'; +import 'package:clean_stream_laundry_app/core/storage/storage_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shared_preferences/shared_preferences.dart'; diff --git a/test/logic/theme/mocks.dart b/test/core/theme/mocks.dart similarity index 58% rename from test/logic/theme/mocks.dart rename to test/core/theme/mocks.dart index a6aaa20..e10189b 100644 --- a/test/logic/theme/mocks.dart +++ b/test/core/theme/mocks.dart @@ -1,4 +1,4 @@ import 'package:mocktail/mocktail.dart'; -import 'package:clean_stream_laundry_app/middleware/storage_service.dart'; +import 'package:clean_stream_laundry_app/core/storage/storage_service.dart'; class MockStorageService extends Mock implements StorageService {} diff --git a/test/logic/theme/theme_test.dart b/test/core/theme/theme_test.dart similarity index 100% rename from test/logic/theme/theme_test.dart rename to test/core/theme/theme_test.dart From 2e7ffa9a105f32ca9bdfa841853e77df591171a8 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Mon, 23 Mar 2026 10:50:20 -0400 Subject: [PATCH 57/79] Adjusts imports --- .../change_email_verification/change_email_verification.dart | 2 +- .../change_email_verification/widgets/verification_error.dart | 2 +- .../refund_request/widgets/transactions_search_sheet.dart | 2 +- lib/features/reset_protected/widgets/password_field.dart | 2 +- lib/features/verify_code/widgets/code_field.dart | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/features/change_email_verification/change_email_verification.dart b/lib/features/change_email_verification/change_email_verification.dart index d14f6c3..e4ec645 100644 --- a/lib/features/change_email_verification/change_email_verification.dart +++ b/lib/features/change_email_verification/change_email_verification.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/Logic/Theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; import 'package:app_links/app_links.dart'; import 'controller.dart'; diff --git a/lib/features/change_email_verification/widgets/verification_error.dart b/lib/features/change_email_verification/widgets/verification_error.dart index 454ad2a..5e3e102 100644 --- a/lib/features/change_email_verification/widgets/verification_error.dart +++ b/lib/features/change_email_verification/widgets/verification_error.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/Logic/Theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; class VerificationError extends StatelessWidget { diff --git a/lib/features/refund_request/widgets/transactions_search_sheet.dart b/lib/features/refund_request/widgets/transactions_search_sheet.dart index ac59376..2da2ea3 100644 --- a/lib/features/refund_request/widgets/transactions_search_sheet.dart +++ b/lib/features/refund_request/widgets/transactions_search_sheet.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/Logic/Theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; class TransactionSearchSheet extends StatefulWidget { diff --git a/lib/features/reset_protected/widgets/password_field.dart b/lib/features/reset_protected/widgets/password_field.dart index 194ec57..9598e60 100644 --- a/lib/features/reset_protected/widgets/password_field.dart +++ b/lib/features/reset_protected/widgets/password_field.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/Logic/Theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; class PasswordField extends StatelessWidget { diff --git a/lib/features/verify_code/widgets/code_field.dart b/lib/features/verify_code/widgets/code_field.dart index b07e908..2aeb0ca 100644 --- a/lib/features/verify_code/widgets/code_field.dart +++ b/lib/features/verify_code/widgets/code_field.dart @@ -1,4 +1,4 @@ -import 'package:clean_stream_laundry_app/Logic/Theme/theme.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; class VerificationCodeField extends StatelessWidget { From e50bc3ebba4fc44adbaca79e62ce0e231d6b6067 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 6 Apr 2026 13:12:18 -0400 Subject: [PATCH 58/79] Added Maintenance Request Page Based on refund request page --- lib/core/router/app_router.dart | 11 ++ .../maintenance_request/controller.dart | 119 ++++++++++++++ .../maintenance_request.dart | 154 ++++++++++++++++++ .../widgets/disclaimer_card.dart | 36 ++++ .../maintenance_request/widgets/header.dart | 62 +++++++ .../widgets/maintenance_form.dart | 126 ++++++++++++++ .../widgets/transactions_search_sheet.dart | 86 ++++++++++ lib/features/settings/settings.dart | 6 + 8 files changed, 600 insertions(+) create mode 100644 lib/features/maintenance_request/controller.dart create mode 100644 lib/features/maintenance_request/maintenance_request.dart create mode 100644 lib/features/maintenance_request/widgets/disclaimer_card.dart create mode 100644 lib/features/maintenance_request/widgets/header.dart create mode 100644 lib/features/maintenance_request/widgets/maintenance_form.dart create mode 100644 lib/features/maintenance_request/widgets/transactions_search_sheet.dart diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index d7d40fa..efce9b0 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -15,6 +15,7 @@ import 'package:clean_stream_laundry_app/features/start_machine/start_machine.da import 'package:clean_stream_laundry_app/features/machine_payment/machine_payment.dart'; import 'package:clean_stream_laundry_app/features/monthly_report/monthly_report.dart'; import 'package:clean_stream_laundry_app/features/refund_request/refund_request.dart'; +import 'package:clean_stream_laundry_app/features/maintenance_request/maintenance_request.dart'; import 'package:clean_stream_laundry_app/features/password_reset/password_reset.dart'; import 'package:clean_stream_laundry_app/features/reset_protected/reset_protected.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; @@ -151,6 +152,16 @@ class RouterService { transitionsBuilder: (_, _, _, child) => child, ), ), + GoRoute( + path: '/maintenancePage', + pageBuilder: (context, state) => CustomTransitionPage( + key: state.pageKey, + child: MaintenancePage(), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + transitionsBuilder: (_, _, _, child) => child, + ), + ), GoRoute( path: '/editProfile', pageBuilder: (context, state) => CustomTransitionPage( diff --git a/lib/features/maintenance_request/controller.dart b/lib/features/maintenance_request/controller.dart new file mode 100644 index 0000000..2223b64 --- /dev/null +++ b/lib/features/maintenance_request/controller.dart @@ -0,0 +1,119 @@ +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +class MaintenanceController extends ChangeNotifier { + final TransactionService transactionService; + final EdgeFunctionService edgeFunctionService; + final ProfileService profileService; + final AuthService authService; + + MaintenanceController({ + TransactionService? transactionService, + EdgeFunctionService? edgeFunctionService, + ProfileService? profileService, + AuthService? authService, + }) : transactionService = + transactionService ?? GetIt.instance(), + edgeFunctionService = + edgeFunctionService ?? GetIt.instance(), + profileService = profileService ?? GetIt.instance(), + authService = authService ?? GetIt.instance(); + + final TextEditingController descriptionController = TextEditingController(); + + List recentTransactions = []; + List recentTransactionIDs = []; + + String? selectedTransaction; + int? selectedTransactionIndex; + + bool attemptedSubmit = false; + bool isLoading = false; + bool isFetchingTransactions = true; + + void disposeController() { + descriptionController.dispose(); + } + + bool get isFormValid => + selectedTransaction != null && + descriptionController.text.trim().isNotEmpty; + + Future fetchTransactions() async { + try { + final result = + await transactionService.getRefundableTransactionsForUser(); + recentTransactions = result.transactions; + recentTransactionIDs = result.ids; + + recentTransactions.removeWhere( + (t) => t.contains('added to Loyalty Card'), + ); + } catch (e) { + // silently ignore — page stays usable with empty list + } finally { + isFetchingTransactions = false; + notifyListeners(); + } + } + + void selectTransaction(String transaction) { + selectedTransaction = transaction; + selectedTransactionIndex = recentTransactions.indexOf(transaction); + notifyListeners(); + } + + String getTransactionID() { + return recentTransactionIDs[selectedTransactionIndex!].toString(); + } + + Future getUserName() async { + final userId = authService.getCurrentUserId; + if (userId == null) return null; + return profileService.getUserNameById(userId); + } + + Future submitMaintenance() async { + final userId = authService.getCurrentUserId; + if (userId == null) return false; + + isLoading = true; + notifyListeners(); + + try { + final transactionId = getTransactionID(); + final description = descriptionController.text; + final username = await getUserName(); + + final amount = await transactionService.recordRefundRequest( + transaction_id: transactionId, + description: description, + ); + + await edgeFunctionService.runEdgeFunction( + name: 'refund-email', + body: { + 'username': username, + 'user_id': userId, + 'transaction_id': transactionId, + 'amount': amount, + 'description': description, + }, + ); + + return true; + } finally { + isLoading = false; + notifyListeners(); + } + } + + void markAttemptedSubmit() { + attemptedSubmit = true; + notifyListeners(); + } +} \ No newline at end of file diff --git a/lib/features/maintenance_request/maintenance_request.dart b/lib/features/maintenance_request/maintenance_request.dart new file mode 100644 index 0000000..a51a737 --- /dev/null +++ b/lib/features/maintenance_request/maintenance_request.dart @@ -0,0 +1,154 @@ +import 'controller.dart'; +import 'widgets/disclaimer_card.dart'; +import 'widgets/maintenance_form.dart'; +import 'widgets/header.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; +import 'package:clean_stream_laundry_app/features/widgets/status_dialog_box.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; + +class MaintenancePage extends StatefulWidget { + const MaintenancePage({super.key}); + + @override + State createState() => MaintenancePageState(); +} + +class MaintenancePageState extends State { + late final MaintenanceController _controller; + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _controller = MaintenanceController(); + _controller.addListener(() { + if (mounted) setState(() {}); + }); + _controller.descriptionController.addListener(() { + if (mounted) setState(() {}); + }); + _controller.fetchTransactions(); + } + + @override + void dispose() { + _controller.disposeController(); + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + Future _onSubmitPressed() async { + _controller.markAttemptedSubmit(); + if (!_controller.isFormValid) return; + await _handleMaintenance(); + } + + Future _handleMaintenance() async { + try { + final success = await _controller.submitMaintenance(); + if (!mounted) return; + + if (!success) return; + + _showMaintenanceDialog(); + } catch (e) { + if (!mounted) return; + } + } + + void _showMaintenanceDialog() { + statusDialog( + context, + title: 'Success', + message: 'Your maintenance request has been submitted', + isSuccess: true, + ).then((_) { + if (mounted) context.go('/settings'); + }); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + leading: IconButton( + onPressed: () => context.pop(), + icon: const Icon(Icons.arrow_back, color: Colors.white), + ), + backgroundColor: Colors.transparent, + title: const Text('Request Maintenance', + style: TextStyle(color: Colors.white)), + centerTitle: true, + flexibleSpace: Container( + decoration: BoxDecoration( + gradient: colorScheme.primaryGradient, + ), + ), + ), + body: KeyboardListener( + focusNode: _focusNode, + autofocus: kIsWeb, + onKeyEvent: (keyEvent) { + if (keyEvent is KeyDownEvent && + keyEvent.logicalKey == LogicalKeyboardKey.enter) { + _handleMaintenance(); + } + }, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Header(), + const SizedBox(height: 28), + MaintenanceForm(controller: _controller), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _controller.isLoading ? null : _onSubmitPressed, + style: ElevatedButton.styleFrom( + backgroundColor: _controller.isFormValid + ? colorScheme.primary + : Colors.grey, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: _controller.isFormValid ? 2 : 0, + ), + child: _controller.isLoading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.5, + ), + ) + : const Text( + 'Submit Maintenance Request', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 12), + const DisclaimerCard(), + const SizedBox(height: 4), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/maintenance_request/widgets/disclaimer_card.dart b/lib/features/maintenance_request/widgets/disclaimer_card.dart new file mode 100644 index 0000000..601aba0 --- /dev/null +++ b/lib/features/maintenance_request/widgets/disclaimer_card.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +class DisclaimerCard extends StatelessWidget { + const DisclaimerCard({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFFFFFDE7).withOpacity(0.8), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFF9A825).withOpacity(0.4)), + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline_rounded, size: 16, color: Colors.black), + SizedBox(width: 10), + Expanded( + child: Text( + 'Maintenance requests are reviewed within 3–5 business days. ' + 'Approved refunds will be returned to your loyalty card balance. ' + 'We reserve the right to deny requests that do not meet our refund policy criteria.', + style: TextStyle( + fontSize: 12, + color: Colors.black, + height: 1.5, + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/maintenance_request/widgets/header.dart b/lib/features/maintenance_request/widgets/header.dart new file mode 100644 index 0000000..cc4c52d --- /dev/null +++ b/lib/features/maintenance_request/widgets/header.dart @@ -0,0 +1,62 @@ +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; +import 'package:flutter/material.dart'; + +class Header extends StatelessWidget { + const Header({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colorScheme.primary.withOpacity(0.08), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colorScheme.primary.withOpacity(0.2)), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primary.withOpacity(0.15), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + Icons.receipt_long_rounded, + color: colorScheme.primary, + size: 28, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Submit a Maintenance Request', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: colorScheme.fontInverted, + ), + ), + const SizedBox(height: 4), + Text( + 'Select a transaction and describe your issue. ' + 'Our team will review it shortly.', + style: TextStyle( + fontSize: 13, + color: colorScheme.fontSecondary, + ), + ), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/maintenance_request/widgets/maintenance_form.dart b/lib/features/maintenance_request/widgets/maintenance_form.dart new file mode 100644 index 0000000..819efbb --- /dev/null +++ b/lib/features/maintenance_request/widgets/maintenance_form.dart @@ -0,0 +1,126 @@ +import '../controller.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; +import 'package:clean_stream_laundry_app/features/maintenance_request/widgets/transactions_search_sheet.dart'; +import 'package:flutter/material.dart'; + +class MaintenanceForm extends StatelessWidget { + final MaintenanceController controller; + + const MaintenanceForm({super.key, required this.controller}); + + InputDecoration _inputDecoration(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: colorScheme.fontSecondary, width: 1.5), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: colorScheme.fontSecondary, width: 1.5), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Colors.blue, width: 2), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Select a Transaction', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: colorScheme.fontInverted, + ), + ), + const SizedBox(height: 8), + + controller.isFetchingTransactions + ? const Center(child: CircularProgressIndicator()) + : GestureDetector( + onTap: () async { + final selected = + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => TransactionSearchSheet( + transactions: controller.recentTransactions, + ), + ); + if (selected != null) { + controller.selectTransaction(selected); + } + }, + child: AbsorbPointer( + child: TextFormField( + decoration: _inputDecoration(context).copyWith( + hintText: 'Select a transaction', + hintStyle: + TextStyle(color: colorScheme.fontSecondary), + ), + controller: TextEditingController( + text: controller.selectedTransaction, + ), + style: TextStyle(color: colorScheme.fontInverted), + ), + ), + ), + + const SizedBox(height: 24), + + Text( + 'Reason for Maintenance', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: colorScheme.fontInverted, + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller.descriptionController, + minLines: 4, + maxLines: null, + keyboardType: TextInputType.multiline, + style: TextStyle(color: colorScheme.fontInverted), + decoration: _inputDecoration(context).copyWith( + hintText: 'Describe the issue with your transaction...', + hintStyle: TextStyle(color: colorScheme.fontSecondary), + ), + ), + + if (controller.attemptedSubmit && !controller.isFormValid) + const Padding( + padding: EdgeInsets.only(top: 12), + child: Row( + children: [ + Icon(Icons.info_outline, color: Colors.red, size: 16), + SizedBox(width: 6), + Text( + 'Please fill in all fields', + style: TextStyle(color: Colors.red, fontSize: 13), + ), + ], + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/maintenance_request/widgets/transactions_search_sheet.dart b/lib/features/maintenance_request/widgets/transactions_search_sheet.dart new file mode 100644 index 0000000..2da2ea3 --- /dev/null +++ b/lib/features/maintenance_request/widgets/transactions_search_sheet.dart @@ -0,0 +1,86 @@ +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; +import 'package:flutter/material.dart'; + +class TransactionSearchSheet extends StatefulWidget { + final List transactions; + + const TransactionSearchSheet({ + super.key, + required this.transactions, + }); + + @override + State createState() => + _TransactionSearchSheetState(); +} + +class _TransactionSearchSheetState + extends State { + late List filtered; + String query = ''; + + @override + void initState() { + super.initState(); + filtered = widget.transactions; + } + + void _filter(String value) { + setState(() { + query = value; + filtered = widget.transactions + .where((transaction) => + transaction.toLowerCase().contains(value.toLowerCase())) + .toList(); + }); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SafeArea( + child: SizedBox( + height: 500, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: TextField( + autofocus: true, + decoration: InputDecoration( + hintText: 'Search...', + hintStyle: TextStyle(color: Theme.of(context).colorScheme.fontSecondary), + prefixIcon: Icon( + Icons.search, + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + onChanged: _filter, + ), + ), + Expanded( + child: ListView.builder( + itemCount: filtered.length, + itemBuilder: (_, index) { + final transaction = filtered[index]; + + return ListTile( + textColor: Theme.of(context).colorScheme.fontInverted, + title: Text(transaction), + onTap: () { + Navigator.pop(context, transaction); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/settings/settings.dart b/lib/features/settings/settings.dart index 4e62392..a6272f0 100644 --- a/lib/features/settings/settings.dart +++ b/lib/features/settings/settings.dart @@ -113,6 +113,12 @@ class _SettingsState extends State { onTap: () => context.push('/refundPage'), ), const SizedBox(height: 14), + SettingsCard( + icon: Icons.handyman_outlined, + title: 'Request Facility Maintenance', + onTap: () => context.push('/maintenancePage'), + ), + const SizedBox(height: 14), SettingsCard( icon: Icons.person, title: 'Edit Profile', From d80093f4429045d87253a8b6674fae7316687674 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 6 Apr 2026 13:41:12 -0400 Subject: [PATCH 59/79] Added Photo Field Added Photo Field to maintenance form --- .../maintenance_request/controller.dart | 43 +++++++++++++++ .../widgets/maintenance_form.dart | 52 ++++++++++++++++++- pubspec.yaml | 1 + 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/lib/features/maintenance_request/controller.dart b/lib/features/maintenance_request/controller.dart index 2223b64..37298b5 100644 --- a/lib/features/maintenance_request/controller.dart +++ b/lib/features/maintenance_request/controller.dart @@ -1,3 +1,5 @@ +import 'package:image_picker/image_picker.dart'; +import 'dart:io'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; @@ -35,6 +37,47 @@ class MaintenanceController extends ChangeNotifier { bool isLoading = false; bool isFetchingTransactions = true; + File? selectedImage; + + Future pickImage(BuildContext context) async { + final picker = ImagePicker(); + + final source = await showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.camera_alt), + title: const Text('Take Photo'), + onTap: () => Navigator.pop(ctx, ImageSource.camera), + ), + ListTile( + leading: const Icon(Icons.photo_library), + title: const Text('Choose from Library'), + onTap: () => Navigator.pop(ctx, ImageSource.gallery), + ), + ], + ), + ); + }, + ); + + if (source == null) return; + + final picked = await picker.pickImage(source: source); + + if (picked != null) { + selectedImage = File(picked.path); + notifyListeners(); + } + } + void disposeController() { descriptionController.dispose(); } diff --git a/lib/features/maintenance_request/widgets/maintenance_form.dart b/lib/features/maintenance_request/widgets/maintenance_form.dart index 819efbb..62064ae 100644 --- a/lib/features/maintenance_request/widgets/maintenance_form.dart +++ b/lib/features/maintenance_request/widgets/maintenance_form.dart @@ -69,7 +69,7 @@ class MaintenanceForm extends StatelessWidget { child: AbsorbPointer( child: TextFormField( decoration: _inputDecoration(context).copyWith( - hintText: 'Select a transaction', + hintText: 'Select a category', hintStyle: TextStyle(color: colorScheme.fontSecondary), ), @@ -99,11 +99,59 @@ class MaintenanceForm extends StatelessWidget { keyboardType: TextInputType.multiline, style: TextStyle(color: colorScheme.fontInverted), decoration: _inputDecoration(context).copyWith( - hintText: 'Describe the issue with your transaction...', + hintText: 'Describe the issue...', hintStyle: TextStyle(color: colorScheme.fontSecondary), ), ), + const SizedBox(height: 24), + Text( + 'Attach a Photo (Optional)', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: colorScheme.fontInverted, + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: GestureDetector( + onTap: () => controller.pickImage(context), + child: Container( + height: 140, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: colorScheme.fontSecondary, + width: 1.5, + ), + ), + child: controller.selectedImage == null + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.camera_alt, + size: 40, color: colorScheme.fontSecondary), + const SizedBox(height: 8), + Text( + 'Tap to take or upload a photo', + style: TextStyle(color: colorScheme.fontSecondary), + ), + ], + ) + : ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.file( + controller.selectedImage!, + fit: BoxFit.cover, + width: double.infinity, + ), + ), + ), + ), + ), + if (controller.attemptedSubmit && !controller.isFormValid) const Padding( padding: EdgeInsets.only(top: 12), diff --git a/pubspec.yaml b/pubspec.yaml index 3f60d83..e19df99 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: flutter_map: ^8.2.2 latlong2: ^0.9.1 geolocator: ^14.0.2 + image_picker: ^1.0.7 dev_dependencies: flutter_test: From f6e1b7266dd4670c3fa7ebc4c594eb6320cefd5d Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 6 Apr 2026 14:08:56 -0400 Subject: [PATCH 60/79] Added maintenance categories --- .../maintenance_request/controller.dart | 85 ++++++------------ .../maintenance_request.dart | 1 - .../widgets/maintenance_form.dart | 53 +++++------- .../widgets/transactions_search_sheet.dart | 86 ------------------- 4 files changed, 48 insertions(+), 177 deletions(-) delete mode 100644 lib/features/maintenance_request/widgets/transactions_search_sheet.dart diff --git a/lib/features/maintenance_request/controller.dart b/lib/features/maintenance_request/controller.dart index 37298b5..fa23022 100644 --- a/lib/features/maintenance_request/controller.dart +++ b/lib/features/maintenance_request/controller.dart @@ -3,41 +3,36 @@ import 'dart:io'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; class MaintenanceController extends ChangeNotifier { - final TransactionService transactionService; final EdgeFunctionService edgeFunctionService; final ProfileService profileService; final AuthService authService; MaintenanceController({ - TransactionService? transactionService, EdgeFunctionService? edgeFunctionService, ProfileService? profileService, AuthService? authService, - }) : transactionService = - transactionService ?? GetIt.instance(), - edgeFunctionService = - edgeFunctionService ?? GetIt.instance(), + }) : edgeFunctionService = + edgeFunctionService ?? GetIt.instance(), profileService = profileService ?? GetIt.instance(), authService = authService ?? GetIt.instance(); final TextEditingController descriptionController = TextEditingController(); - List recentTransactions = []; - List recentTransactionIDs = []; + final List categories = const [ + 'Washer/Dryer Maintenance', + 'App Maintenance', + 'Other', + ]; - String? selectedTransaction; - int? selectedTransactionIndex; + String? selectedCategory; + File? selectedImage; bool attemptedSubmit = false; bool isLoading = false; - bool isFetchingTransactions = true; - - File? selectedImage; Future pickImage(BuildContext context) async { final picker = ImagePicker(); @@ -78,48 +73,20 @@ class MaintenanceController extends ChangeNotifier { } } - void disposeController() { - descriptionController.dispose(); + void selectCategory(String category) { + selectedCategory = category; + notifyListeners(); } bool get isFormValid => - selectedTransaction != null && + selectedCategory != null && descriptionController.text.trim().isNotEmpty; - Future fetchTransactions() async { - try { - final result = - await transactionService.getRefundableTransactionsForUser(); - recentTransactions = result.transactions; - recentTransactionIDs = result.ids; - - recentTransactions.removeWhere( - (t) => t.contains('added to Loyalty Card'), - ); - } catch (e) { - // silently ignore — page stays usable with empty list - } finally { - isFetchingTransactions = false; - notifyListeners(); - } - } - - void selectTransaction(String transaction) { - selectedTransaction = transaction; - selectedTransactionIndex = recentTransactions.indexOf(transaction); + void markAttemptedSubmit() { + attemptedSubmit = true; notifyListeners(); } - String getTransactionID() { - return recentTransactionIDs[selectedTransactionIndex!].toString(); - } - - Future getUserName() async { - final userId = authService.getCurrentUserId; - if (userId == null) return null; - return profileService.getUserNameById(userId); - } - Future submitMaintenance() async { final userId = authService.getCurrentUserId; if (userId == null) return false; @@ -128,23 +95,17 @@ class MaintenanceController extends ChangeNotifier { notifyListeners(); try { - final transactionId = getTransactionID(); final description = descriptionController.text; final username = await getUserName(); - final amount = await transactionService.recordRefundRequest( - transaction_id: transactionId, - description: description, - ); - await edgeFunctionService.runEdgeFunction( - name: 'refund-email', + name: 'maintenance-request', body: { 'username': username, 'user_id': userId, - 'transaction_id': transactionId, - 'amount': amount, + 'category': selectedCategory, 'description': description, + 'has_image': selectedImage != null, }, ); @@ -154,9 +115,13 @@ class MaintenanceController extends ChangeNotifier { notifyListeners(); } } + Future getUserName() async { + final userId = authService.getCurrentUserId; + if (userId == null) return null; + return profileService.getUserNameById(userId); + } - void markAttemptedSubmit() { - attemptedSubmit = true; - notifyListeners(); + void disposeController() { + descriptionController.dispose(); } } \ No newline at end of file diff --git a/lib/features/maintenance_request/maintenance_request.dart b/lib/features/maintenance_request/maintenance_request.dart index a51a737..069a5c0 100644 --- a/lib/features/maintenance_request/maintenance_request.dart +++ b/lib/features/maintenance_request/maintenance_request.dart @@ -30,7 +30,6 @@ class MaintenancePageState extends State { _controller.descriptionController.addListener(() { if (mounted) setState(() {}); }); - _controller.fetchTransactions(); } @override diff --git a/lib/features/maintenance_request/widgets/maintenance_form.dart b/lib/features/maintenance_request/widgets/maintenance_form.dart index 62064ae..aec50a9 100644 --- a/lib/features/maintenance_request/widgets/maintenance_form.dart +++ b/lib/features/maintenance_request/widgets/maintenance_form.dart @@ -1,6 +1,5 @@ import '../controller.dart'; import 'package:clean_stream_laundry_app/core/theme/theme.dart'; -import 'package:clean_stream_laundry_app/features/maintenance_request/widgets/transactions_search_sheet.dart'; import 'package:flutter/material.dart'; class MaintenanceForm extends StatelessWidget { @@ -41,7 +40,7 @@ class MaintenanceForm extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Select a Transaction', + 'Select a Category', style: TextStyle( fontWeight: FontWeight.w600, fontSize: 14, @@ -50,35 +49,29 @@ class MaintenanceForm extends StatelessWidget { ), const SizedBox(height: 8), - controller.isFetchingTransactions - ? const Center(child: CircularProgressIndicator()) - : GestureDetector( - onTap: () async { - final selected = - await showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => TransactionSearchSheet( - transactions: controller.recentTransactions, - ), - ); - if (selected != null) { - controller.selectTransaction(selected); - } - }, - child: AbsorbPointer( - child: TextFormField( - decoration: _inputDecoration(context).copyWith( - hintText: 'Select a category', - hintStyle: - TextStyle(color: colorScheme.fontSecondary), - ), - controller: TextEditingController( - text: controller.selectedTransaction, - ), - style: TextStyle(color: colorScheme.fontInverted), - ), + DropdownButtonFormField( + value: controller.selectedCategory, + decoration: _inputDecoration(context), + dropdownColor: Theme.of(context).colorScheme.surface, + iconEnabledColor: colorScheme.fontSecondary, + style: TextStyle(color: colorScheme.fontInverted), + hint: Text( + 'Select a category', + style: TextStyle(color: colorScheme.fontSecondary), ), + + items: controller.categories + .map( + (cat) => DropdownMenuItem( + value: cat, + child: Text(cat), + ), + ) + .toList(), + + onChanged: (value) { + controller.selectCategory(value!); + }, ), const SizedBox(height: 24), diff --git a/lib/features/maintenance_request/widgets/transactions_search_sheet.dart b/lib/features/maintenance_request/widgets/transactions_search_sheet.dart deleted file mode 100644 index 2da2ea3..0000000 --- a/lib/features/maintenance_request/widgets/transactions_search_sheet.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:clean_stream_laundry_app/core/theme/theme.dart'; -import 'package:flutter/material.dart'; - -class TransactionSearchSheet extends StatefulWidget { - final List transactions; - - const TransactionSearchSheet({ - super.key, - required this.transactions, - }); - - @override - State createState() => - _TransactionSearchSheetState(); -} - -class _TransactionSearchSheetState - extends State { - late List filtered; - String query = ''; - - @override - void initState() { - super.initState(); - filtered = widget.transactions; - } - - void _filter(String value) { - setState(() { - query = value; - filtered = widget.transactions - .where((transaction) => - transaction.toLowerCase().contains(value.toLowerCase())) - .toList(); - }); - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: SafeArea( - child: SizedBox( - height: 500, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: TextField( - autofocus: true, - decoration: InputDecoration( - hintText: 'Search...', - hintStyle: TextStyle(color: Theme.of(context).colorScheme.fontSecondary), - prefixIcon: Icon( - Icons.search, - color: Theme.of(context).colorScheme.fontSecondary, - ), - ), - onChanged: _filter, - ), - ), - Expanded( - child: ListView.builder( - itemCount: filtered.length, - itemBuilder: (_, index) { - final transaction = filtered[index]; - - return ListTile( - textColor: Theme.of(context).colorScheme.fontInverted, - title: Text(transaction), - onTap: () { - Navigator.pop(context, transaction); - }, - ); - }, - ), - ), - ], - ), - ), - ), - ); - } -} From 07612d1a06a77909d38fd9fb424ebc2e66f46fb7 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 6 Apr 2026 15:02:22 -0400 Subject: [PATCH 61/79] Removed maintenance disclaimer --- .../maintenance_request.dart | 2 -- .../widgets/disclaimer_card.dart | 36 ------------------- 2 files changed, 38 deletions(-) delete mode 100644 lib/features/maintenance_request/widgets/disclaimer_card.dart diff --git a/lib/features/maintenance_request/maintenance_request.dart b/lib/features/maintenance_request/maintenance_request.dart index 069a5c0..64bc918 100644 --- a/lib/features/maintenance_request/maintenance_request.dart +++ b/lib/features/maintenance_request/maintenance_request.dart @@ -1,5 +1,4 @@ import 'controller.dart'; -import 'widgets/disclaimer_card.dart'; import 'widgets/maintenance_form.dart'; import 'widgets/header.dart'; import 'package:clean_stream_laundry_app/core/theme/theme.dart'; @@ -142,7 +141,6 @@ class MaintenancePageState extends State { ), ), const SizedBox(height: 12), - const DisclaimerCard(), const SizedBox(height: 4), ], ), diff --git a/lib/features/maintenance_request/widgets/disclaimer_card.dart b/lib/features/maintenance_request/widgets/disclaimer_card.dart deleted file mode 100644 index 601aba0..0000000 --- a/lib/features/maintenance_request/widgets/disclaimer_card.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:flutter/material.dart'; - -class DisclaimerCard extends StatelessWidget { - const DisclaimerCard({super.key}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: const Color(0xFFFFFDE7).withOpacity(0.8), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFFF9A825).withOpacity(0.4)), - ), - child: const Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.info_outline_rounded, size: 16, color: Colors.black), - SizedBox(width: 10), - Expanded( - child: Text( - 'Maintenance requests are reviewed within 3–5 business days. ' - 'Approved refunds will be returned to your loyalty card balance. ' - 'We reserve the right to deny requests that do not meet our refund policy criteria.', - style: TextStyle( - fontSize: 12, - color: Colors.black, - height: 1.5, - ), - ), - ), - ], - ), - ); - } -} \ No newline at end of file From bf4454b40f89cec1661813faeba78f455dfeacfb Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Tue, 7 Apr 2026 10:57:32 -0400 Subject: [PATCH 62/79] Aquired Permissions --- android/app/src/main/AndroidManifest.xml | 2 ++ lib/features/maintenance_request/controller.dart | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a5c1071..a5810d9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ xmlns:tools="http://schemas.android.com/tools"> + + diff --git a/lib/features/maintenance_request/controller.dart b/lib/features/maintenance_request/controller.dart index fa23022..cfafb1b 100644 --- a/lib/features/maintenance_request/controller.dart +++ b/lib/features/maintenance_request/controller.dart @@ -5,6 +5,7 @@ import 'package:clean_stream_laundry_app/logic/services/edge_function_service.da import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:permission_handler/permission_handler.dart'; class MaintenanceController extends ChangeNotifier { final EdgeFunctionService edgeFunctionService; @@ -34,9 +35,17 @@ class MaintenanceController extends ChangeNotifier { bool attemptedSubmit = false; bool isLoading = false; + Future _ensurePermissions() async { + final camera = await Permission.camera.request(); + final photos = await Permission.photos.request(); + return camera.isGranted && photos.isGranted; + } + Future pickImage(BuildContext context) async { - final picker = ImagePicker(); + // Ensure permissions first + if (!await _ensurePermissions()) return; + // Ask user for source final source = await showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( @@ -65,6 +74,7 @@ class MaintenanceController extends ChangeNotifier { if (source == null) return; + final picker = ImagePicker(); final picked = await picker.pickImage(source: source); if (picked != null) { From 09669ff2449c5248081deaeb0638631d65deca6d Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 13 Apr 2026 10:14:55 -0400 Subject: [PATCH 63/79] Added tests --- .../maintenance_request.dart | 5 +- .../maintenance_request/controller_test.dart | 97 ++++++++ .../maintenance_request_test.dart | 128 +++++++++++ test/features/maintenance_request/mocks.dart | 17 ++ .../widgets/header_test.dart | 126 +++++++++++ .../widgets/maintenance_form_test.dart | 209 ++++++++++++++++++ 6 files changed, 580 insertions(+), 2 deletions(-) create mode 100644 test/features/maintenance_request/controller_test.dart create mode 100644 test/features/maintenance_request/maintenance_request_test.dart create mode 100644 test/features/maintenance_request/mocks.dart create mode 100644 test/features/maintenance_request/widgets/header_test.dart create mode 100644 test/features/maintenance_request/widgets/maintenance_form_test.dart diff --git a/lib/features/maintenance_request/maintenance_request.dart b/lib/features/maintenance_request/maintenance_request.dart index 64bc918..962fc4e 100644 --- a/lib/features/maintenance_request/maintenance_request.dart +++ b/lib/features/maintenance_request/maintenance_request.dart @@ -9,7 +9,8 @@ import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; class MaintenancePage extends StatefulWidget { - const MaintenancePage({super.key}); + final MaintenanceController? controller; + const MaintenancePage({super.key, this.controller}); @override State createState() => MaintenancePageState(); @@ -22,7 +23,7 @@ class MaintenancePageState extends State { @override void initState() { super.initState(); - _controller = MaintenanceController(); + _controller = widget.controller ?? MaintenanceController(); _controller.addListener(() { if (mounted) setState(() {}); }); diff --git a/test/features/maintenance_request/controller_test.dart b/test/features/maintenance_request/controller_test.dart new file mode 100644 index 0000000..73187ba --- /dev/null +++ b/test/features/maintenance_request/controller_test.dart @@ -0,0 +1,97 @@ +import 'dart:io'; + +import 'package:clean_stream_laundry_app/features/maintenance_request/controller.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthService extends Mock implements AuthService {} +class MockProfileService extends Mock implements ProfileService {} +class MockEdgeFunctionService extends Mock implements EdgeFunctionService {} + +void main() { + late MaintenanceController controller; + late MockAuthService auth; + late MockProfileService profile; + late MockEdgeFunctionService edge; + + setUp(() { + auth = MockAuthService(); + profile = MockProfileService(); + edge = MockEdgeFunctionService(); + + controller = MaintenanceController( + authService: auth, + profileService: profile, + edgeFunctionService: edge, + ); + }); + + group('MaintenanceController Tests', () { + test('Initial state is correct', () { + expect(controller.selectedCategory, isNull); + expect(controller.selectedImage, isNull); + expect(controller.isLoading, false); + expect(controller.isFormValid, false); + }); + + test('Selecting a category updates state', () { + controller.selectCategory('App Maintenance'); + expect(controller.selectedCategory, 'App Maintenance'); + expect(controller.isFormValid, false); + }); + + test('Form becomes valid when category + description are set', () { + controller.selectCategory('Other'); + controller.descriptionController.text = 'Something is broken'; + + expect(controller.isFormValid, true); + }); + + test('submitMaintenance returns false when userId is null', () async { + when(() => auth.getCurrentUserId).thenReturn(null); + + final result = await controller.submitMaintenance(); + expect(result, false); + }); + + test('submitMaintenance calls edge function with correct payload', () async { + when(() => auth.getCurrentUserId).thenReturn('user123'); + when(() => profile.getUserNameById('user123')) + .thenAnswer((_) async => 'John Doe'); + + when(() => edge.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )).thenAnswer((_) async => null); + + controller.selectCategory('Washer/Dryer Maintenance'); + controller.descriptionController.text = 'Machine leaking'; + + final result = await controller.submitMaintenance(); + + expect(result, true); + + verify(() => edge.runEdgeFunction( + name: 'maintenance-request', + body: { + 'username': 'John Doe', + 'user_id': 'user123', + 'category': 'Washer/Dryer Maintenance', + 'description': 'Machine leaking', + 'has_image': false, + }, + )).called(1); + }); + + test('Image selection updates selectedImage', () { + // Simulate a picked file + final fakeFile = File('test_assets/fake_image.jpg'); + controller.selectedImage = fakeFile; + + expect(controller.selectedImage!.path, contains('fake_image.jpg')); + }); + }); +} \ No newline at end of file diff --git a/test/features/maintenance_request/maintenance_request_test.dart b/test/features/maintenance_request/maintenance_request_test.dart new file mode 100644 index 0000000..dd3d665 --- /dev/null +++ b/test/features/maintenance_request/maintenance_request_test.dart @@ -0,0 +1,128 @@ +import 'package:clean_stream_laundry_app/features/maintenance_request/maintenance_request.dart'; +import 'package:clean_stream_laundry_app/features/maintenance_request/controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockMaintenanceController extends Mock + implements MaintenanceController {} + +void main() { + late MockMaintenanceController controller; + + setUp(() { + controller = MockMaintenanceController(); + + when(() => controller.isLoading).thenReturn(false); + when(() => controller.isFormValid).thenReturn(false); + when(() => controller.descriptionController) + .thenReturn(TextEditingController()); + when(() => controller.addListener(any())).thenAnswer((_) {}); + when(() => controller.dispose()).thenAnswer((_) {}); + when(() => controller.disposeController()).thenAnswer((_) {}); + }); + + Widget _wrap(Widget child) { + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, __) => child, + ), + GoRoute( + path: '/settings', + builder: (_, __) => const Scaffold(body: Text('Settings Page')), + ), + ], + ); + + return MaterialApp.router( + routerConfig: router, + ); + } + + testWidgets('Page renders correctly', (tester) async { + await tester.pumpWidget(_wrap(MaintenancePage(controller: controller))); + + expect(find.text('Request Maintenance'), findsOneWidget); + expect(find.text('Submit Maintenance Request'), findsOneWidget); + }); + + testWidgets('Submit button disabled when form invalid', (tester) async { + when(() => controller.isFormValid).thenReturn(false); + + await tester.pumpWidget(_wrap(MaintenancePage(controller: controller))); + + final button = find.text('Submit Maintenance Request'); + final widget = tester.widget(button); + + expect(widget.onPressed, isNull); + }); + + testWidgets('Submit triggers controller when form valid', (tester) async { + when(() => controller.isFormValid).thenReturn(true); + when(() => controller.submitMaintenance()) + .thenAnswer((_) async => true); + + await tester.pumpWidget(_wrap(MaintenancePage(controller: controller))); + + final button = find.text('Submit Maintenance Request'); + await tester.tap(button); + await tester.pump(); + + verify(() => controller.markAttemptedSubmit()).called(1); + verify(() => controller.submitMaintenance()).called(1); + }); + + testWidgets('Submit button disabled when form invalid', (tester) async { + when(() => controller.isFormValid).thenReturn(false); + + await tester.pumpWidget(_wrap(MaintenancePage())); + + final button = find.text('Submit Maintenance Request'); + final widget = tester.widget(button); + + expect(widget.onPressed, isNull); + }); + + testWidgets('Submit triggers controller when form valid', (tester) async { + when(() => controller.isFormValid).thenReturn(true); + when(() => controller.submitMaintenance()) + .thenAnswer((_) async => true); + + await tester.pumpWidget(_wrap(MaintenancePage())); + + final button = find.text('Submit Maintenance Request'); + await tester.tap(button); + await tester.pump(); + + verify(() => controller.markAttemptedSubmit()).called(1); + verify(() => controller.submitMaintenance()).called(1); + }); + + testWidgets('Success dialog appears and navigates to settings', + (tester) async { + when(() => controller.isFormValid).thenReturn(true); + when(() => controller.submitMaintenance()) + .thenAnswer((_) async => true); + + await tester.pumpWidget(_wrap(MaintenancePage())); + + // Tap submit + await tester.tap(find.text('Submit Maintenance Request')); + await tester.pumpAndSettle(); + + // Dialog should appear + expect(find.text('Success'), findsOneWidget); + expect(find.text('Your maintenance request has been submitted'), + findsOneWidget); + + // Close dialog + await tester.tap(find.text('OK')); // assuming your dialog uses OK button + await tester.pumpAndSettle(); + + // Should navigate to /settings + expect(find.text('Settings Page'), findsOneWidget); + }); +} \ No newline at end of file diff --git a/test/features/maintenance_request/mocks.dart b/test/features/maintenance_request/mocks.dart new file mode 100644 index 0000000..35bc2eb --- /dev/null +++ b/test/features/maintenance_request/mocks.dart @@ -0,0 +1,17 @@ +import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_communication_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; +import 'package:clean_stream_laundry_app/services/notification_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthService extends Mock implements AuthService {} +class MockMachineService extends Mock implements MachineService {} +class MockProfileService extends Mock implements ProfileService {} +class MockTransactionService extends Mock implements TransactionService {} +class MockMachineCommunicationService extends Mock implements MachineCommunicationService {} +class MockNotificationService extends Mock implements NotificationService {} +class MockPaymentProcessor extends Mock implements PaymentProcessor {} +class FakeAuthService extends Fake implements AuthService {} \ No newline at end of file diff --git a/test/features/maintenance_request/widgets/header_test.dart b/test/features/maintenance_request/widgets/header_test.dart new file mode 100644 index 0000000..996a6aa --- /dev/null +++ b/test/features/maintenance_request/widgets/header_test.dart @@ -0,0 +1,126 @@ +import 'package:clean_stream_laundry_app/features/machine_payment/widgets/washer_controls_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late double? receivedCost; + + Widget createTestWidget() { + return MaterialApp( + home: Scaffold( + body: WasherControlsCard( + onCycleChanged: (cost) => receivedCost = cost, + ), + ), + ); + } + + setUp(() { + receivedCost = null; + }); + + testWidgets('renders all four washer cycle buttons', (tester) async { + await tester.pumpWidget(createTestWidget()); + + expect(find.text('Hot Heavy'), findsOneWidget); + expect(find.text('Hot Normal'), findsOneWidget); + expect(find.text('Cold Heavy'), findsOneWidget); + expect(find.text('Cold Normal'), findsOneWidget); + }); + + testWidgets('selecting Hot Heavy updates state and cost', (tester) async { + await tester.pumpWidget(createTestWidget()); + + await tester.tap(find.text('Hot Heavy')); + await tester.pump(); + + expect(receivedCost, 0.5); + + final button = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Heavy')); + expect((button.style?.backgroundColor?.resolve({})) , Colors.green); + }); + + testWidgets('selecting Hot Normal updates state and cost', (tester) async { + await tester.pumpWidget(createTestWidget()); + + await tester.tap(find.text('Hot Normal')); + await tester.pump(); + + expect(receivedCost, 0.25); + + final button = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Normal')); + expect((button.style?.backgroundColor?.resolve({})), Colors.green); + }); + + testWidgets('selecting Cold Heavy updates state and cost', (tester) async { + await tester.pumpWidget(createTestWidget()); + + await tester.tap(find.text('Cold Heavy')); + await tester.pump(); + + expect(receivedCost, 0.25); + + final button = tester.widget(find.widgetWithText(ElevatedButton, 'Cold Heavy')); + expect((button.style?.backgroundColor?.resolve({})), Colors.green); + }); + + testWidgets('selecting Cold Normal updates state and cost', (tester) async { + await tester.pumpWidget(createTestWidget()); + + await tester.tap(find.text('Cold Normal')); + await tester.pump(); + + expect(receivedCost, 0); + + final button = tester.widget(find.widgetWithText(ElevatedButton, 'Cold Normal')); + expect((button.style?.backgroundColor?.resolve({})), Colors.green); + }); + + testWidgets('only one button is selected at a time', (tester) async { + await tester.pumpWidget(createTestWidget()); + + // Select Hot Heavy + await tester.tap(find.text('Hot Heavy')); + await tester.pump(); + + ElevatedButton hotHeavy = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Heavy')); + ElevatedButton hotNormal = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Normal')); + + expect(hotHeavy.style?.backgroundColor?.resolve({}), Colors.green); + expect(hotNormal.style?.backgroundColor?.resolve({}), isNot(Colors.green)); + + // Select Hot Normal + await tester.tap(find.text('Hot Normal')); + await tester.pump(); + + hotHeavy = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Heavy')); + hotNormal = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Normal')); + + expect(hotHeavy.style?.backgroundColor?.resolve({}), isNot(Colors.green)); + expect(hotNormal.style?.backgroundColor?.resolve({}), Colors.green); + }); + + testWidgets('callback fires exactly once per tap', (tester) async { + int callCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: WasherControlsCard( + onCycleChanged: (_) => callCount++, + ), + ), + ), + ); + + await tester.tap(find.text('Hot Heavy')); + await tester.pump(); + + expect(callCount, 1); + + await tester.tap(find.text('Cold Normal')); + await tester.pump(); + + expect(callCount, 2); + }); +} \ No newline at end of file diff --git a/test/features/maintenance_request/widgets/maintenance_form_test.dart b/test/features/maintenance_request/widgets/maintenance_form_test.dart new file mode 100644 index 0000000..70cb126 --- /dev/null +++ b/test/features/maintenance_request/widgets/maintenance_form_test.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:clean_stream_laundry_app/features/machine_payment/widgets/dryer_controls_card.dart'; + +ThemeData _testTheme() => ThemeData.light(); + +Widget _wrap({required void Function(double, int) onChanged}) { + return MaterialApp( + theme: _testTheme(), + home: Scaffold( + body: DryerControlsCard(onChanged: onChanged), + ), + ); +} + +void main() { + group('DryerControlsCard', () { + + testWidgets('renders title text', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + expect(find.text('Set Dry Time'), findsOneWidget); + }); + + testWidgets('renders default 30 min label', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + expect(find.text('30 min'), findsOneWidget); + }); + + testWidgets('renders pricing hint text', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + expect(find.text('\$0.25 per 5 minutes'), findsOneWidget); + }); + + testWidgets('renders min and max labels', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + expect(find.text('5 min'), findsOneWidget); + expect(find.text('90 min'), findsOneWidget); + }); + + testWidgets('renders a Slider', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + expect(find.byType(Slider), findsOneWidget); + }); + + testWidgets('renders as a Card with elevation', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + final card = tester.widget(find.byType(Card)); + expect(card.elevation, 4); + }); + + + testWidgets('fires onChanged after first frame with default values', + (tester) async { + double? receivedPrice; + int? receivedMinutes; + + await tester.pumpWidget(_wrap(onChanged: (price, minutes) { + receivedPrice = price; + receivedMinutes = minutes; + })); + + await tester.pump(); + + expect(receivedMinutes, 30); + expect(receivedPrice, closeTo(1.50, 0.001)); + }); + + + group('price calculation', () { + Future dragToMinutes(WidgetTester tester, int minutes) async { + final slider = find.byType(Slider); + final sliderRect = tester.getRect(slider); + + const thumbPadding = 24.0; + final trackLeft = sliderRect.left + thumbPadding; + final trackWidth = sliderRect.width - thumbPadding * 2; + + final fraction = (minutes - 5) / 85.0; + final x = trackLeft + fraction * trackWidth; + + await tester.tapAt(Offset(x, sliderRect.center.dy)); + await tester.pump(); + } + + testWidgets('5 min → \$0.25', (tester) async { + double? price; + await tester.pumpWidget( + _wrap(onChanged: (p, _) => price = p)); + await dragToMinutes(tester, 5); + expect(price, closeTo(0.25, 0.001)); + }); + + testWidgets('30 min → \$1.50', (tester) async { + double? price; + await tester.pumpWidget( + _wrap(onChanged: (p, _) => price = p)); + await dragToMinutes(tester, 30); + expect(price, closeTo(1.50, 0.001)); + }); + + testWidgets('60 min → \$3.00', (tester) async { + double? price; + await tester.pumpWidget( + _wrap(onChanged: (p, _) => price = p)); + await dragToMinutes(tester, 60); + expect(price, closeTo(3.00, 0.001)); + }); + + testWidgets('90 min → \$4.50', (tester) async { + double? price; + await tester.pumpWidget( + _wrap(onChanged: (p, _) => price = p)); + await dragToMinutes(tester, 90); + expect(price, closeTo(4.50, 0.001)); + }); + }); + + + testWidgets('slider value snaps to multiples of 5', (tester) async { + final capturedMinutes = []; + + await tester.pumpWidget( + _wrap(onChanged: (_, minutes) => capturedMinutes.add(minutes))); + + final slider = find.byType(Slider); + final sliderBox = tester.renderObject(slider) as RenderBox; + final sliderWidth = sliderBox.size.width; + final center = tester.getCenter(slider); + + await tester.timedDragFrom( + Offset(center.dx - sliderWidth / 2, center.dy), + Offset(sliderWidth, 0), + const Duration(milliseconds: 500), + ); + await tester.pump(); + + for (final m in capturedMinutes) { + expect(m % 5, 0, + reason: '$m is not a multiple of 5'); + } + }); + + testWidgets('minute label updates when slider moves', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + + final slider = find.byType(Slider); + final sliderBox = tester.renderObject(slider) as RenderBox; + final sliderWidth = sliderBox.size.width; + final center = tester.getCenter(slider); + + await tester.dragFrom( + Offset(center.dx - sliderWidth / 2, center.dy), + Offset(sliderWidth, 0), + ); + await tester.pump(); + + expect(find.text('90 min'), findsWidgets); + }); + + + testWidgets('onChanged is called when slider moves', (tester) async { + int callCount = 0; + await tester.pumpWidget( + _wrap(onChanged: (_, __) => callCount++)); + + await tester.pump(); + callCount = 0; + + final slider = find.byType(Slider); + final sliderBox = tester.renderObject(slider) as RenderBox; + final sliderWidth = sliderBox.size.width; + final center = tester.getCenter(slider); + + await tester.dragFrom( + Offset(center.dx - sliderWidth / 2, center.dy), + Offset(sliderWidth * 0.5, 0), + ); + await tester.pump(); + + expect(callCount, greaterThan(0)); + }); + + testWidgets('onChanged price and minutes are consistent', (tester) async { + double? lastPrice; + int? lastMinutes; + + await tester.pumpWidget(_wrap(onChanged: (price, minutes) { + lastPrice = price; + lastMinutes = minutes; + })); + + final slider = find.byType(Slider); + final sliderBox = tester.renderObject(slider) as RenderBox; + final sliderWidth = sliderBox.size.width; + final center = tester.getCenter(slider); + + await tester.dragFrom( + Offset(center.dx - sliderWidth / 2, center.dy), + Offset(sliderWidth * 0.35, 0), // somewhere in the middle + ); + await tester.pump(); + + expect(lastMinutes, isNotNull); + expect(lastPrice, isNotNull); + final expectedPrice = (lastMinutes! / 5) * 0.25; + expect(lastPrice, closeTo(expectedPrice, 0.001)); + }); + }); +} \ No newline at end of file From 984866a4428aef5e0a5f3105d71625098d700464 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 13 Apr 2026 10:30:48 -0400 Subject: [PATCH 64/79] Added tests for maintenance_form --- .../widgets/maintenance_form_test.dart | 282 +++++++----------- 1 file changed, 101 insertions(+), 181 deletions(-) diff --git a/test/features/maintenance_request/widgets/maintenance_form_test.dart b/test/features/maintenance_request/widgets/maintenance_form_test.dart index 70cb126..9229f50 100644 --- a/test/features/maintenance_request/widgets/maintenance_form_test.dart +++ b/test/features/maintenance_request/widgets/maintenance_form_test.dart @@ -1,209 +1,129 @@ +import 'dart:io'; + +import 'package:clean_stream_laundry_app/features/maintenance_request/widgets/maintenance_form.dart'; +import 'package:clean_stream_laundry_app/features/maintenance_request/controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:clean_stream_laundry_app/features/machine_payment/widgets/dryer_controls_card.dart'; - -ThemeData _testTheme() => ThemeData.light(); +import 'package:mocktail/mocktail.dart'; -Widget _wrap({required void Function(double, int) onChanged}) { - return MaterialApp( - theme: _testTheme(), - home: Scaffold( - body: DryerControlsCard(onChanged: onChanged), - ), - ); -} +class MockMaintenanceController extends Mock implements MaintenanceController {} +class FakeBuildContext extends Fake implements BuildContext {} void main() { - group('DryerControlsCard', () { - - testWidgets('renders title text', (tester) async { - await tester.pumpWidget(_wrap(onChanged: (_, __) {})); - expect(find.text('Set Dry Time'), findsOneWidget); - }); + late MockMaintenanceController controller; + + + Widget buildForm() { + return MaterialApp( + home: Scaffold( + body: MaintenanceForm(controller: controller), + ), + ); + } + + setUp(() { + controller = MockMaintenanceController(); + + when(() => controller.categories).thenReturn(['Electrical', 'Washer', 'Dryer']); + when(() => controller.selectedCategory).thenReturn(null); + when(() => controller.descriptionController) + .thenReturn(TextEditingController()); + when(() => controller.selectedImage).thenReturn(null); + when(() => controller.attemptedSubmit).thenReturn(false); + when(() => controller.isFormValid).thenReturn(true); + + when(() => controller.selectCategory(any())).thenReturn(null); + when(() => controller.pickImage(any())).thenAnswer((_) async {}); + }); - testWidgets('renders default 30 min label', (tester) async { - await tester.pumpWidget(_wrap(onChanged: (_, __) {})); - expect(find.text('30 min'), findsOneWidget); - }); + setUpAll(() { + registerFallbackValue(FakeBuildContext()); + }); - testWidgets('renders pricing hint text', (tester) async { - await tester.pumpWidget(_wrap(onChanged: (_, __) {})); - expect(find.text('\$0.25 per 5 minutes'), findsOneWidget); - }); + testWidgets('MaintenanceForm renders all fields', (tester) async { + await tester.pumpWidget(buildForm()); - testWidgets('renders min and max labels', (tester) async { - await tester.pumpWidget(_wrap(onChanged: (_, __) {})); - expect(find.text('5 min'), findsOneWidget); - expect(find.text('90 min'), findsOneWidget); - }); + expect(find.text('Select a Category'), findsOneWidget); + expect(find.byType(DropdownButtonFormField), findsOneWidget); - testWidgets('renders a Slider', (tester) async { - await tester.pumpWidget(_wrap(onChanged: (_, __) {})); - expect(find.byType(Slider), findsOneWidget); - }); + expect(find.text('Reason for Maintenance'), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); - testWidgets('renders as a Card with elevation', (tester) async { - await tester.pumpWidget(_wrap(onChanged: (_, __) {})); - final card = tester.widget(find.byType(Card)); - expect(card.elevation, 4); - }); + expect(find.text('Attach a Photo (Optional)'), findsOneWidget); + expect(find.byIcon(Icons.camera_alt), findsOneWidget); + }); + testWidgets('Dropdown shows categories from controller', (tester) async { + await tester.pumpWidget(buildForm()); - testWidgets('fires onChanged after first frame with default values', - (tester) async { - double? receivedPrice; - int? receivedMinutes; + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); - await tester.pumpWidget(_wrap(onChanged: (price, minutes) { - receivedPrice = price; - receivedMinutes = minutes; - })); + expect(find.text('Electrical'), findsOneWidget); + expect(find.text('Washer'), findsOneWidget); + expect(find.text('Dryer'), findsOneWidget); + }); - await tester.pump(); + testWidgets('Selecting a category triggers controller.selectCategory', + (tester) async { + await tester.pumpWidget(buildForm()); - expect(receivedMinutes, 30); - expect(receivedPrice, closeTo(1.50, 0.001)); - }); + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Washer').last); + await tester.pumpAndSettle(); - group('price calculation', () { - Future dragToMinutes(WidgetTester tester, int minutes) async { - final slider = find.byType(Slider); - final sliderRect = tester.getRect(slider); + verify(() => controller.selectCategory('Washer')).called(1); + }); - const thumbPadding = 24.0; - final trackLeft = sliderRect.left + thumbPadding; - final trackWidth = sliderRect.width - thumbPadding * 2; + testWidgets('Typing in description updates controller.descriptionController', + (tester) async { + final textController = TextEditingController(); + when(() => controller.descriptionController).thenReturn(textController); - final fraction = (minutes - 5) / 85.0; - final x = trackLeft + fraction * trackWidth; + await tester.pumpWidget(buildForm()); - await tester.tapAt(Offset(x, sliderRect.center.dy)); - await tester.pump(); - } - - testWidgets('5 min → \$0.25', (tester) async { - double? price; - await tester.pumpWidget( - _wrap(onChanged: (p, _) => price = p)); - await dragToMinutes(tester, 5); - expect(price, closeTo(0.25, 0.001)); + await tester.enterText(find.byType(TextField), 'Motor is making noise'); + expect(textController.text, 'Motor is making noise'); }); - testWidgets('30 min → \$1.50', (tester) async { - double? price; - await tester.pumpWidget( - _wrap(onChanged: (p, _) => price = p)); - await dragToMinutes(tester, 30); - expect(price, closeTo(1.50, 0.001)); + testWidgets('Shows placeholder when no image selected', (tester) async { + when(() => controller.selectedImage).thenReturn(null); + + await tester.pumpWidget(buildForm()); + + expect(find.byIcon(Icons.camera_alt), findsOneWidget); + expect(find.text('Tap to take or upload a photo'), findsOneWidget); + }); + + testWidgets('Shows image preview when selectedImage is not null', + (tester) async { + final fakeImage = File('fake_path.jpg'); + when(() => controller.selectedImage).thenReturn(fakeImage); + + await tester.pumpWidget(buildForm()); + + expect(find.byType(Image), findsOneWidget); }); - testWidgets('60 min → \$3.00', (tester) async { - double? price; - await tester.pumpWidget( - _wrap(onChanged: (p, _) => price = p)); - await dragToMinutes(tester, 60); - expect(price, closeTo(3.00, 0.001)); + testWidgets('Shows error message when form invalid and attemptedSubmit', + (tester) async { + when(() => controller.attemptedSubmit).thenReturn(true); + when(() => controller.isFormValid).thenReturn(false); + + await tester.pumpWidget(buildForm()); + + expect(find.text('Please fill in all fields'), findsOneWidget); }); - testWidgets('90 min → \$4.50', (tester) async { - double? price; - await tester.pumpWidget( - _wrap(onChanged: (p, _) => price = p)); - await dragToMinutes(tester, 90); - expect(price, closeTo(4.50, 0.001)); + testWidgets('Tapping image picker calls controller.pickImage', + (tester) async { + await tester.pumpWidget(buildForm()); + + await tester.tap(find.text('Tap to take or upload a photo')); + await tester.pump(); + + verify(() => controller.pickImage(any())).called(1); }); - }); - - - testWidgets('slider value snaps to multiples of 5', (tester) async { - final capturedMinutes = []; - - await tester.pumpWidget( - _wrap(onChanged: (_, minutes) => capturedMinutes.add(minutes))); - - final slider = find.byType(Slider); - final sliderBox = tester.renderObject(slider) as RenderBox; - final sliderWidth = sliderBox.size.width; - final center = tester.getCenter(slider); - - await tester.timedDragFrom( - Offset(center.dx - sliderWidth / 2, center.dy), - Offset(sliderWidth, 0), - const Duration(milliseconds: 500), - ); - await tester.pump(); - - for (final m in capturedMinutes) { - expect(m % 5, 0, - reason: '$m is not a multiple of 5'); - } - }); - - testWidgets('minute label updates when slider moves', (tester) async { - await tester.pumpWidget(_wrap(onChanged: (_, __) {})); - - final slider = find.byType(Slider); - final sliderBox = tester.renderObject(slider) as RenderBox; - final sliderWidth = sliderBox.size.width; - final center = tester.getCenter(slider); - - await tester.dragFrom( - Offset(center.dx - sliderWidth / 2, center.dy), - Offset(sliderWidth, 0), - ); - await tester.pump(); - - expect(find.text('90 min'), findsWidgets); - }); - - - testWidgets('onChanged is called when slider moves', (tester) async { - int callCount = 0; - await tester.pumpWidget( - _wrap(onChanged: (_, __) => callCount++)); - - await tester.pump(); - callCount = 0; - - final slider = find.byType(Slider); - final sliderBox = tester.renderObject(slider) as RenderBox; - final sliderWidth = sliderBox.size.width; - final center = tester.getCenter(slider); - - await tester.dragFrom( - Offset(center.dx - sliderWidth / 2, center.dy), - Offset(sliderWidth * 0.5, 0), - ); - await tester.pump(); - - expect(callCount, greaterThan(0)); - }); - - testWidgets('onChanged price and minutes are consistent', (tester) async { - double? lastPrice; - int? lastMinutes; - - await tester.pumpWidget(_wrap(onChanged: (price, minutes) { - lastPrice = price; - lastMinutes = minutes; - })); - - final slider = find.byType(Slider); - final sliderBox = tester.renderObject(slider) as RenderBox; - final sliderWidth = sliderBox.size.width; - final center = tester.getCenter(slider); - - await tester.dragFrom( - Offset(center.dx - sliderWidth / 2, center.dy), - Offset(sliderWidth * 0.35, 0), // somewhere in the middle - ); - await tester.pump(); - - expect(lastMinutes, isNotNull); - expect(lastPrice, isNotNull); - final expectedPrice = (lastMinutes! / 5) * 0.25; - expect(lastPrice, closeTo(expectedPrice, 0.001)); - }); - }); } \ No newline at end of file From 4a6874115cec822060ec7af292f7b120c4b0495d Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 13 Apr 2026 10:33:04 -0400 Subject: [PATCH 65/79] Added header tests --- .../widgets/header_test.dart | 143 ++++-------------- 1 file changed, 26 insertions(+), 117 deletions(-) diff --git a/test/features/maintenance_request/widgets/header_test.dart b/test/features/maintenance_request/widgets/header_test.dart index 996a6aa..0007e63 100644 --- a/test/features/maintenance_request/widgets/header_test.dart +++ b/test/features/maintenance_request/widgets/header_test.dart @@ -1,126 +1,35 @@ -import 'package:clean_stream_laundry_app/features/machine_payment/widgets/washer_controls_card.dart'; +import 'package:clean_stream_laundry_app/features/maintenance_request/widgets/header.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - late double? receivedCost; - - Widget createTestWidget() { - return MaterialApp( - home: Scaffold( - body: WasherControlsCard( - onCycleChanged: (cost) => receivedCost = cost, - ), - ), + Widget buildWidget() { + return const MaterialApp( + home: Scaffold(body: Header()), ); } - setUp(() { - receivedCost = null; - }); - - testWidgets('renders all four washer cycle buttons', (tester) async { - await tester.pumpWidget(createTestWidget()); - - expect(find.text('Hot Heavy'), findsOneWidget); - expect(find.text('Hot Normal'), findsOneWidget); - expect(find.text('Cold Heavy'), findsOneWidget); - expect(find.text('Cold Normal'), findsOneWidget); - }); - - testWidgets('selecting Hot Heavy updates state and cost', (tester) async { - await tester.pumpWidget(createTestWidget()); - - await tester.tap(find.text('Hot Heavy')); - await tester.pump(); - - expect(receivedCost, 0.5); - - final button = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Heavy')); - expect((button.style?.backgroundColor?.resolve({})) , Colors.green); - }); - - testWidgets('selecting Hot Normal updates state and cost', (tester) async { - await tester.pumpWidget(createTestWidget()); - - await tester.tap(find.text('Hot Normal')); - await tester.pump(); - - expect(receivedCost, 0.25); - - final button = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Normal')); - expect((button.style?.backgroundColor?.resolve({})), Colors.green); - }); - - testWidgets('selecting Cold Heavy updates state and cost', (tester) async { - await tester.pumpWidget(createTestWidget()); - - await tester.tap(find.text('Cold Heavy')); - await tester.pump(); - - expect(receivedCost, 0.25); - - final button = tester.widget(find.widgetWithText(ElevatedButton, 'Cold Heavy')); - expect((button.style?.backgroundColor?.resolve({})), Colors.green); - }); - - testWidgets('selecting Cold Normal updates state and cost', (tester) async { - await tester.pumpWidget(createTestWidget()); - - await tester.tap(find.text('Cold Normal')); - await tester.pump(); - - expect(receivedCost, 0); - - final button = tester.widget(find.widgetWithText(ElevatedButton, 'Cold Normal')); - expect((button.style?.backgroundColor?.resolve({})), Colors.green); - }); - - testWidgets('only one button is selected at a time', (tester) async { - await tester.pumpWidget(createTestWidget()); - - // Select Hot Heavy - await tester.tap(find.text('Hot Heavy')); - await tester.pump(); - - ElevatedButton hotHeavy = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Heavy')); - ElevatedButton hotNormal = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Normal')); - - expect(hotHeavy.style?.backgroundColor?.resolve({}), Colors.green); - expect(hotNormal.style?.backgroundColor?.resolve({}), isNot(Colors.green)); - - // Select Hot Normal - await tester.tap(find.text('Hot Normal')); - await tester.pump(); - - hotHeavy = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Heavy')); - hotNormal = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Normal')); - - expect(hotHeavy.style?.backgroundColor?.resolve({}), isNot(Colors.green)); - expect(hotNormal.style?.backgroundColor?.resolve({}), Colors.green); - }); - - testWidgets('callback fires exactly once per tap', (tester) async { - int callCount = 0; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: WasherControlsCard( - onCycleChanged: (_) => callCount++, - ), - ), - ), - ); - - await tester.tap(find.text('Hot Heavy')); - await tester.pump(); - - expect(callCount, 1); - - await tester.tap(find.text('Cold Normal')); - await tester.pump(); - - expect(callCount, 2); + group('Header', () { + testWidgets('displays receipt_long_rounded icon', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byIcon(Icons.receipt_long_rounded), findsOneWidget); + }); + + testWidgets('displays Submit a Maintenance Request title', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.text('Submit a Maintenance Request'), findsOneWidget); + }); + + + testWidgets('title has bold font weight', (tester) async { + await tester.pumpWidget(buildWidget()); + final text = tester.widget(find.text('Submit a Maintenance Request')); + expect(text.style?.fontWeight, FontWeight.bold); + }); + + testWidgets('renders inside a Row', (tester) async { + await tester.pumpWidget(buildWidget()); + expect(find.byType(Row), findsOneWidget); + }); }); } \ No newline at end of file From 1695e9fcff0eca9587675410d1fd26ea6802a626 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 13 Apr 2026 11:01:07 -0400 Subject: [PATCH 66/79] Added tests for maintenance card --- test/features/settings/settings_test.dart | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/test/features/settings/settings_test.dart b/test/features/settings/settings_test.dart index 34aae78..4cf2d0e 100644 --- a/test/features/settings/settings_test.dart +++ b/test/features/settings/settings_test.dart @@ -54,6 +54,10 @@ void main() { path: '/refundPage', builder: (_, __) => const Scaffold(body: Text('Refund Page')), ), + GoRoute( + path: '/maintenancePage', + builder: (_, __) => const Scaffold(body: Text('Maintenance Page')), + ), ], ); }); @@ -85,15 +89,16 @@ void main() { ); }); - testWidgets('displays all six SettingsCard widgets', (tester) async { + testWidgets('displays all seven SettingsCard widgets', (tester) async { await tester.pumpWidget(createWidget()); - expect(find.byType(SettingsCard), findsNWidgets(6)); + expect(find.byType(SettingsCard), findsNWidgets(7)); expect(find.text('Sign Out'), findsOneWidget); expect(find.text('Monthly Report'), findsOneWidget); expect(find.text('Request Refund'), findsOneWidget); expect(find.text('Edit Profile'), findsOneWidget); expect(find.text('Notify Before Finish'), findsOneWidget); + expect(find.text('Request Facility Maintenance'), findsOneWidget); }); testWidgets('displays correct icons for each card', (tester) async { @@ -105,6 +110,7 @@ void main() { expect(find.byIcon(Icons.logout), findsOneWidget); expect(find.byIcon(Icons.person), findsOneWidget); expect(find.byIcon(Icons.timer), findsOneWidget); + expect(find.byIcon(Icons.handyman_outlined), findsOneWidget); }); testWidgets('centers content inside SingleChildScrollView', (tester) async { @@ -299,5 +305,18 @@ void main() { expect(find.text('Refund Page'), findsOneWidget); }); + + testWidgets('navigates to /maintenancePage when Request Refund is tapped', + (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.ensureVisible( + find.widgetWithText(SettingsCard, 'Request Facility Maintenance')); + await tester.tap(find.widgetWithText(SettingsCard, 'Request Facility Maintenance')); + await tester.pumpAndSettle(); + + expect(find.text('Maintenance Page'), findsOneWidget); + }); }); } \ No newline at end of file From 92872271017f2e59ef3b08479aae7d7833a2b172 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 13 Apr 2026 11:08:02 -0400 Subject: [PATCH 67/79] Fixed maintenance_request_test --- .../maintenance_request_test.dart | 185 +++++++++--------- 1 file changed, 91 insertions(+), 94 deletions(-) diff --git a/test/features/maintenance_request/maintenance_request_test.dart b/test/features/maintenance_request/maintenance_request_test.dart index dd3d665..f7f3cdd 100644 --- a/test/features/maintenance_request/maintenance_request_test.dart +++ b/test/features/maintenance_request/maintenance_request_test.dart @@ -1,128 +1,125 @@ -import 'package:clean_stream_laundry_app/features/maintenance_request/maintenance_request.dart'; -import 'package:clean_stream_laundry_app/features/maintenance_request/controller.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:clean_stream_laundry_app/features/maintenance_request/maintenance_request.dart'; +import 'package:clean_stream_laundry_app/features/maintenance_request/controller.dart'; -class MockMaintenanceController extends Mock - implements MaintenanceController {} +class MockMaintenanceController extends Mock implements MaintenanceController {} void main() { - late MockMaintenanceController controller; + late MockMaintenanceController mockController; + late TextEditingController descriptionController; setUp(() { - controller = MockMaintenanceController(); - - when(() => controller.isLoading).thenReturn(false); - when(() => controller.isFormValid).thenReturn(false); - when(() => controller.descriptionController) - .thenReturn(TextEditingController()); - when(() => controller.addListener(any())).thenAnswer((_) {}); - when(() => controller.dispose()).thenAnswer((_) {}); - when(() => controller.disposeController()).thenAnswer((_) {}); + mockController = MockMaintenanceController(); + descriptionController = TextEditingController(); + + when(() => mockController.attemptedSubmit).thenReturn(false); + when(() => mockController.categories).thenReturn( + ['Washer/Dryer Maintenance', 'App Maintenance', 'Other']); + when(() => mockController.selectedCategory).thenReturn(null); + when(() => mockController.isLoading).thenReturn(false); + when(() => mockController.isFormValid).thenReturn(false); + when(() => mockController.selectedImage).thenReturn(null); + when(() => mockController.descriptionController).thenReturn(descriptionController); + + when(() => mockController.addListener(any())).thenReturn(null); + when(() => mockController.removeListener(any())).thenReturn(null); + when(() => mockController.disposeController()).thenReturn(null); + when(() => mockController.dispose()).thenReturn(null); }); - Widget _wrap(Widget child) { - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (_, __) => child, - ), - GoRoute( - path: '/settings', - builder: (_, __) => const Scaffold(body: Text('Settings Page')), - ), - ], - ); - + Widget createWidget() { return MaterialApp.router( - routerConfig: router, + routerConfig: GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => MaintenancePage(controller: mockController), + ), + GoRoute( + path: '/settings', + builder: (_, _) => const Scaffold(body: Text('Settings Page')), + ), + ], + ), ); } - testWidgets('Page renders correctly', (tester) async { - await tester.pumpWidget(_wrap(MaintenancePage(controller: controller))); + group('Maintenance Page Tests', () { + testWidgets('renders all required UI elements', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); - expect(find.text('Request Maintenance'), findsOneWidget); - expect(find.text('Submit Maintenance Request'), findsOneWidget); - }); + expect(find.text('Request Maintenance'), findsOneWidget); + expect(find.text('Submit Maintenance Request'), findsOneWidget); + expect(find.byIcon(Icons.arrow_back), findsOneWidget); + }); - testWidgets('Submit button disabled when form invalid', (tester) async { - when(() => controller.isFormValid).thenReturn(false); + testWidgets('submit button shows grey background when form is invalid', (tester) async { + when(() => mockController.isFormValid).thenReturn(false); - await tester.pumpWidget(_wrap(MaintenancePage(controller: controller))); + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); - final button = find.text('Submit Maintenance Request'); - final widget = tester.widget(button); + final button = tester.widget(find.byType(ElevatedButton)); + final color = button.style?.backgroundColor?.resolve({}); + expect(color, Colors.grey); + }); - expect(widget.onPressed, isNull); - }); + testWidgets('calls markAttemptedSubmit on button press with auto-scroll', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); - testWidgets('Submit triggers controller when form valid', (tester) async { - when(() => controller.isFormValid).thenReturn(true); - when(() => controller.submitMaintenance()) - .thenAnswer((_) async => true); + final submitButton = find.text('Submit Maintenance Request'); - await tester.pumpWidget(_wrap(MaintenancePage(controller: controller))); + await tester.dragUntilVisible( + submitButton, + find.byType(SingleChildScrollView), + const Offset(0, -200), + ); + await tester.pumpAndSettle(); - final button = find.text('Submit Maintenance Request'); - await tester.tap(button); - await tester.pump(); + await tester.tap(submitButton); + await tester.pump(); - verify(() => controller.markAttemptedSubmit()).called(1); - verify(() => controller.submitMaintenance()).called(1); - }); + verify(() => mockController.markAttemptedSubmit()).called(1); + }); - testWidgets('Submit button disabled when form invalid', (tester) async { - when(() => controller.isFormValid).thenReturn(false); + testWidgets('shows loading indicator when controller is loading', (tester) async { + when(() => mockController.isLoading).thenReturn(true); - await tester.pumpWidget(_wrap(MaintenancePage())); + await tester.pumpWidget(createWidget()); + await tester.pump(); - final button = find.text('Submit Maintenance Request'); - final widget = tester.widget(button); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); - expect(widget.onPressed, isNull); - }); + testWidgets('shows success dialog on successful submission', (tester) async { + when(() => mockController.isFormValid).thenReturn(true); + when(() => mockController.submitMaintenance()).thenAnswer((_) async => true); + when(() => mockController.markAttemptedSubmit()).thenReturn(null); - testWidgets('Submit triggers controller when form valid', (tester) async { - when(() => controller.isFormValid).thenReturn(true); - when(() => controller.submitMaintenance()) - .thenAnswer((_) async => true); + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); - await tester.pumpWidget(_wrap(MaintenancePage())); + final submitButton = find.text('Submit Maintenance Request'); - final button = find.text('Submit Maintenance Request'); - await tester.tap(button); - await tester.pump(); + await tester.dragUntilVisible( + submitButton, + find.byType(SingleChildScrollView), + const Offset(0, -200), + ); + await tester.pumpAndSettle(); - verify(() => controller.markAttemptedSubmit()).called(1); - verify(() => controller.submitMaintenance()).called(1); + await tester.tap(submitButton); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Success'), findsOneWidget); + expect(find.text('Your maintenance request has been submitted'), findsOneWidget); + }); }); - - testWidgets('Success dialog appears and navigates to settings', - (tester) async { - when(() => controller.isFormValid).thenReturn(true); - when(() => controller.submitMaintenance()) - .thenAnswer((_) async => true); - - await tester.pumpWidget(_wrap(MaintenancePage())); - - // Tap submit - await tester.tap(find.text('Submit Maintenance Request')); - await tester.pumpAndSettle(); - - // Dialog should appear - expect(find.text('Success'), findsOneWidget); - expect(find.text('Your maintenance request has been submitted'), - findsOneWidget); - - // Close dialog - await tester.tap(find.text('OK')); // assuming your dialog uses OK button - await tester.pumpAndSettle(); - - // Should navigate to /settings - expect(find.text('Settings Page'), findsOneWidget); - }); } \ No newline at end of file From 1576e014dc8361f1ddfd9c6e78d28c98bdca4cba Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 13 Apr 2026 11:12:22 -0400 Subject: [PATCH 68/79] Removed enter key submission Removed enter key submission so that if a user has a long reason for maintenance and wants to create bullet points, they will not submit an incomplete form --- .../maintenance_request.dart | 93 ++++++++----------- 1 file changed, 41 insertions(+), 52 deletions(-) diff --git a/lib/features/maintenance_request/maintenance_request.dart b/lib/features/maintenance_request/maintenance_request.dart index 962fc4e..ed4c824 100644 --- a/lib/features/maintenance_request/maintenance_request.dart +++ b/lib/features/maintenance_request/maintenance_request.dart @@ -3,7 +3,6 @@ import 'widgets/maintenance_form.dart'; import 'widgets/header.dart'; import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:clean_stream_laundry_app/features/widgets/status_dialog_box.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; @@ -90,61 +89,51 @@ class MaintenancePageState extends State { ), ), ), - body: KeyboardListener( - focusNode: _focusNode, - autofocus: kIsWeb, - onKeyEvent: (keyEvent) { - if (keyEvent is KeyDownEvent && - keyEvent.logicalKey == LogicalKeyboardKey.enter) { - _handleMaintenance(); - } - }, - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Header(), - const SizedBox(height: 28), - MaintenanceForm(controller: _controller), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - height: 52, - child: ElevatedButton( - onPressed: _controller.isLoading ? null : _onSubmitPressed, - style: ElevatedButton.styleFrom( - backgroundColor: _controller.isFormValid - ? colorScheme.primary - : Colors.grey, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - elevation: _controller.isFormValid ? 2 : 0, + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Header(), + const SizedBox(height: 28), + MaintenanceForm(controller: _controller), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _controller.isLoading ? null : _onSubmitPressed, + style: ElevatedButton.styleFrom( + backgroundColor: _controller.isFormValid + ? colorScheme.primary + : Colors.grey, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), ), - child: _controller.isLoading - ? const SizedBox( - height: 22, - width: 22, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2.5, - ), - ) - : const Text( - 'Submit Maintenance Request', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), + elevation: _controller.isFormValid ? 2 : 0, + ), + child: _controller.isLoading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.5, + ), + ) + : const Text( + 'Submit Maintenance Request', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, ), ), ), - const SizedBox(height: 12), - const SizedBox(height: 4), - ], - ), + ), + const SizedBox(height: 12), + const SizedBox(height: 4), + ], ), ), ); From 2f7439e7c68121630a966b191eb7e78c72f09691 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Wed, 15 Apr 2026 00:42:51 -0400 Subject: [PATCH 69/79] Adjusted for supabase upload --- .../maintenance_request/controller.dart | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/lib/features/maintenance_request/controller.dart b/lib/features/maintenance_request/controller.dart index cfafb1b..2c08d63 100644 --- a/lib/features/maintenance_request/controller.dart +++ b/lib/features/maintenance_request/controller.dart @@ -6,6 +6,7 @@ import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; class MaintenanceController extends ChangeNotifier { final EdgeFunctionService edgeFunctionService; @@ -42,10 +43,8 @@ class MaintenanceController extends ChangeNotifier { } Future pickImage(BuildContext context) async { - // Ensure permissions first if (!await _ensurePermissions()) return; - // Ask user for source final source = await showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( @@ -89,8 +88,7 @@ class MaintenanceController extends ChangeNotifier { } bool get isFormValid => - selectedCategory != null && - descriptionController.text.trim().isNotEmpty; + selectedCategory != null && descriptionController.text.trim().isNotEmpty; void markAttemptedSubmit() { attemptedSubmit = true; @@ -105,31 +103,50 @@ class MaintenanceController extends ChangeNotifier { notifyListeners(); try { - final description = descriptionController.text; - final username = await getUserName(); + String? imageUrl; + if (selectedImage != null) { + print("Step 1: Image detected, starting upload..."); + + // Use a more unique name to avoid conflicts + final fileName = '${DateTime.now().millisecondsSinceEpoch}_${userId.hashCode}.jpg'; + final path = 'requests/$fileName'; + + // Attempt the upload + await Supabase.instance.client.storage + .from('maintenance-images') + .upload(path, selectedImage!); + + print("Step 2: Upload successful, getting URL..."); + + imageUrl = Supabase.instance.client.storage + .from('maintenance-images') + .getPublicUrl(path); + + print("Step 3: URL generated: $imageUrl"); + } + + print("Step 4: Calling Edge Function..."); await edgeFunctionService.runEdgeFunction( name: 'maintenance-request', body: { - 'username': username, 'user_id': userId, 'category': selectedCategory, - 'description': description, - 'has_image': selectedImage != null, + 'description': descriptionController.text, + 'image': imageUrl, }, ); + print("Step 5: Submission Complete!"); return true; + } catch (e) { + print("FATAL ERROR during submission: $e"); + return false; } finally { isLoading = false; notifyListeners(); } } - Future getUserName() async { - final userId = authService.getCurrentUserId; - if (userId == null) return null; - return profileService.getUserNameById(userId); - } void disposeController() { descriptionController.dispose(); From 71d46e2e2651a038be15b6636d44e102f051509a Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Wed, 15 Apr 2026 01:34:50 -0400 Subject: [PATCH 70/79] Added locations to maint_requests --- .../maintenance_request/controller.dart | 47 +++-- .../maintenance_request.dart | 19 +- .../widgets/location_selector.dart | 85 ++++++++ .../widgets/maintenance_form.dart | 16 +- .../maintenance_request/controller_test.dart | 194 ++++++++++-------- .../maintenance_request_test.dart | 12 +- .../widgets/location_selector_test.dart | 74 +++++++ .../widgets/maintenance_form_test.dart | 110 +++++----- 8 files changed, 391 insertions(+), 166 deletions(-) create mode 100644 lib/features/maintenance_request/widgets/location_selector.dart create mode 100644 test/features/maintenance_request/widgets/location_selector_test.dart diff --git a/lib/features/maintenance_request/controller.dart b/lib/features/maintenance_request/controller.dart index 2c08d63..7b0ccc9 100644 --- a/lib/features/maintenance_request/controller.dart +++ b/lib/features/maintenance_request/controller.dart @@ -2,6 +2,7 @@ import 'package:image_picker/image_picker.dart'; import 'dart:io'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; @@ -12,6 +13,7 @@ class MaintenanceController extends ChangeNotifier { final EdgeFunctionService edgeFunctionService; final ProfileService profileService; final AuthService authService; + final LocationService locationService = GetIt.instance(); MaintenanceController({ EdgeFunctionService? edgeFunctionService, @@ -31,11 +33,33 @@ class MaintenanceController extends ChangeNotifier { ]; String? selectedCategory; + List locations = []; + String? selectedLocation; File? selectedImage; bool attemptedSubmit = false; bool isLoading = false; + Future init() async { + isLoading = true; + await _loadLocations(); + isLoading = false; + notifyListeners(); + } + + Future _loadLocations() async { + try { + final List fetchedLocations = await locationService.getLocations(); + + locations = fetchedLocations + .map((loc) => loc['Address']?.toString() ?? '') + .where((address) => address.isNotEmpty) + .toList(); + } catch (e) { + locations = []; + } + } + Future _ensurePermissions() async { final camera = await Permission.camera.request(); final photos = await Permission.photos.request(); @@ -88,7 +112,14 @@ class MaintenanceController extends ChangeNotifier { } bool get isFormValid => - selectedCategory != null && descriptionController.text.trim().isNotEmpty; + selectedCategory != null && + selectedLocation != null && + descriptionController.text.trim().isNotEmpty; + + void selectLocation(String location) { + selectedLocation = location; + notifyListeners(); + } void markAttemptedSubmit() { attemptedSubmit = true; @@ -106,41 +137,29 @@ class MaintenanceController extends ChangeNotifier { String? imageUrl; if (selectedImage != null) { - print("Step 1: Image detected, starting upload..."); - - // Use a more unique name to avoid conflicts final fileName = '${DateTime.now().millisecondsSinceEpoch}_${userId.hashCode}.jpg'; final path = 'requests/$fileName'; - - // Attempt the upload await Supabase.instance.client.storage .from('maintenance-images') .upload(path, selectedImage!); - print("Step 2: Upload successful, getting URL..."); - imageUrl = Supabase.instance.client.storage .from('maintenance-images') .getPublicUrl(path); - - print("Step 3: URL generated: $imageUrl"); } - print("Step 4: Calling Edge Function..."); await edgeFunctionService.runEdgeFunction( name: 'maintenance-request', body: { 'user_id': userId, 'category': selectedCategory, + 'location': selectedLocation, 'description': descriptionController.text, 'image': imageUrl, }, ); - - print("Step 5: Submission Complete!"); return true; } catch (e) { - print("FATAL ERROR during submission: $e"); return false; } finally { isLoading = false; diff --git a/lib/features/maintenance_request/maintenance_request.dart b/lib/features/maintenance_request/maintenance_request.dart index ed4c824..791c8ef 100644 --- a/lib/features/maintenance_request/maintenance_request.dart +++ b/lib/features/maintenance_request/maintenance_request.dart @@ -4,7 +4,6 @@ import 'widgets/header.dart'; import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:clean_stream_laundry_app/features/widgets/status_dialog_box.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; class MaintenancePage extends StatefulWidget { @@ -23,18 +22,24 @@ class MaintenancePageState extends State { void initState() { super.initState(); _controller = widget.controller ?? MaintenanceController(); - _controller.addListener(() { - if (mounted) setState(() {}); - }); - _controller.descriptionController.addListener(() { - if (mounted) setState(() {}); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _controller.init(); }); + _controller.addListener(_onControllerChanged); + } + + void _onControllerChanged() { + if (mounted) setState(() {}); } @override void dispose() { + _controller.removeListener(_onControllerChanged); _controller.disposeController(); - _controller.dispose(); + if (widget.controller == null) { + _controller.dispose(); + } _focusNode.dispose(); super.dispose(); } diff --git a/lib/features/maintenance_request/widgets/location_selector.dart b/lib/features/maintenance_request/widgets/location_selector.dart new file mode 100644 index 0000000..4de58a6 --- /dev/null +++ b/lib/features/maintenance_request/widgets/location_selector.dart @@ -0,0 +1,85 @@ +import 'package:clean_stream_laundry_app/features/maintenance_request/controller.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.dart'; +import 'package:flutter/material.dart'; + +class LocationSelector extends StatelessWidget { + final MaintenanceController controller; + + const LocationSelector({required this.controller}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colorScheme.fontSecondary, width: 1.5), + color: colorScheme.surface, + ), + child: Row( + children: [ + const SizedBox(width: 8), + Expanded( + child: GestureDetector( + onTap: () => _showLocationPicker(context), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + controller.selectedLocation ?? 'Select Location', + style: TextStyle( + fontSize: 16, + color: controller.selectedLocation == null + ? colorScheme.fontSecondary + : colorScheme.fontInverted, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + const Icon(Icons.arrow_drop_down, color: Colors.grey), + ], + ), + ); + } + + void _showLocationPicker(BuildContext context) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 12), + itemCount: controller.locations.length + 1, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, index) { + if (index == 0) { + return ListTile( + title: const Text( + 'No Applicable Location', + style: TextStyle(fontWeight: FontWeight.bold, color: Colors.redAccent), + ), + onTap: () { + controller.selectLocation('No Applicable Location'); + Navigator.pop(context); + }, + ); + } + final item = controller.locations[index - 1]; + final String address = item is String ? item : item['Address']; + + return ListTile( + title: Text(address), + onTap: () { + controller.selectLocation(address); + Navigator.pop(context); + }, + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/maintenance_request/widgets/maintenance_form.dart b/lib/features/maintenance_request/widgets/maintenance_form.dart index aec50a9..baf4fb6 100644 --- a/lib/features/maintenance_request/widgets/maintenance_form.dart +++ b/lib/features/maintenance_request/widgets/maintenance_form.dart @@ -1,3 +1,4 @@ +import '../widgets/location_selector.dart'; import '../controller.dart'; import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; @@ -50,7 +51,7 @@ class MaintenanceForm extends StatelessWidget { const SizedBox(height: 8), DropdownButtonFormField( - value: controller.selectedCategory, + initialValue: controller.selectedCategory, decoration: _inputDecoration(context), dropdownColor: Theme.of(context).colorScheme.surface, iconEnabledColor: colorScheme.fontSecondary, @@ -76,6 +77,19 @@ class MaintenanceForm extends StatelessWidget { const SizedBox(height: 24), + Text( + 'Location', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: colorScheme.fontInverted, + ), + ), + const SizedBox(height: 8), + LocationSelector(controller: controller), + + const SizedBox(height: 24), + Text( 'Reason for Maintenance', style: TextStyle( diff --git a/test/features/maintenance_request/controller_test.dart b/test/features/maintenance_request/controller_test.dart index 73187ba..bf5c199 100644 --- a/test/features/maintenance_request/controller_test.dart +++ b/test/features/maintenance_request/controller_test.dart @@ -1,97 +1,123 @@ import 'dart:io'; - +import 'package:clean_stream_laundry_app/features/maintenance_request/widgets/maintenance_form.dart'; import 'package:clean_stream_laundry_app/features/maintenance_request/controller.dart'; -import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -class MockAuthService extends Mock implements AuthService {} -class MockProfileService extends Mock implements ProfileService {} -class MockEdgeFunctionService extends Mock implements EdgeFunctionService {} +class MockMaintenanceController extends Mock implements MaintenanceController {} +class FakeBuildContext extends Fake implements BuildContext {} void main() { - late MaintenanceController controller; - late MockAuthService auth; - late MockProfileService profile; - late MockEdgeFunctionService edge; + late MockMaintenanceController controller; + + Widget buildForm() { + return MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MaintenanceForm(controller: controller), + ), + ), + ); + } setUp(() { - auth = MockAuthService(); - profile = MockProfileService(); - edge = MockEdgeFunctionService(); - - controller = MaintenanceController( - authService: auth, - profileService: profile, - edgeFunctionService: edge, - ); + controller = MockMaintenanceController(); + + when(() => controller.categories).thenReturn(['Electrical', 'Washer', 'Dryer']); + when(() => controller.selectedCategory).thenReturn(null); + when(() => controller.descriptionController).thenReturn(TextEditingController()); + when(() => controller.selectedImage).thenReturn(null); + when(() => controller.attemptedSubmit).thenReturn(false); + when(() => controller.isFormValid).thenReturn(true); + when(() => controller.locations).thenReturn(['123 Main St', '456 Oak Ave']); + when(() => controller.selectedLocation).thenReturn(null); + + when(() => controller.selectCategory(any())).thenReturn(null); + when(() => controller.selectLocation(any())).thenReturn(null); + when(() => controller.pickImage(any())).thenAnswer((_) async {}); + }); + + setUpAll(() { + registerFallbackValue(FakeBuildContext()); + }); + + testWidgets('MaintenanceForm renders all fields', (tester) async { + await tester.pumpWidget(buildForm()); + + expect(find.text('Select a Category'), findsOneWidget); + expect(find.text('Location'), findsOneWidget); + expect(find.text('Reason for Maintenance'), findsOneWidget); + expect(find.text('Attach a Photo (Optional)'), findsOneWidget); + }); + + testWidgets('Dropdown shows categories and triggers selection', (tester) async { + await tester.pumpWidget(buildForm()); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + + expect(find.text('Electrical'), findsOneWidget); + await tester.tap(find.text('Washer').last); + await tester.pumpAndSettle(); + + verify(() => controller.selectCategory('Washer')).called(1); + }); + + testWidgets('LocationSelector opens bottom sheet and shows options', (tester) async { + await tester.pumpWidget(buildForm()); + + await tester.tap(find.text('Select Location')); + await tester.pumpAndSettle(); + + expect(find.text('No Applicable Location'), findsOneWidget); + expect(find.text('123 Main St'), findsOneWidget); + verifyNever(() => controller.selectLocation(any())); }); - group('MaintenanceController Tests', () { - test('Initial state is correct', () { - expect(controller.selectedCategory, isNull); - expect(controller.selectedImage, isNull); - expect(controller.isLoading, false); - expect(controller.isFormValid, false); - }); - - test('Selecting a category updates state', () { - controller.selectCategory('App Maintenance'); - expect(controller.selectedCategory, 'App Maintenance'); - expect(controller.isFormValid, false); - }); - - test('Form becomes valid when category + description are set', () { - controller.selectCategory('Other'); - controller.descriptionController.text = 'Something is broken'; - - expect(controller.isFormValid, true); - }); - - test('submitMaintenance returns false when userId is null', () async { - when(() => auth.getCurrentUserId).thenReturn(null); - - final result = await controller.submitMaintenance(); - expect(result, false); - }); - - test('submitMaintenance calls edge function with correct payload', () async { - when(() => auth.getCurrentUserId).thenReturn('user123'); - when(() => profile.getUserNameById('user123')) - .thenAnswer((_) async => 'John Doe'); - - when(() => edge.runEdgeFunction( - name: any(named: 'name'), - body: any(named: 'body'), - )).thenAnswer((_) async => null); - - controller.selectCategory('Washer/Dryer Maintenance'); - controller.descriptionController.text = 'Machine leaking'; - - final result = await controller.submitMaintenance(); - - expect(result, true); - - verify(() => edge.runEdgeFunction( - name: 'maintenance-request', - body: { - 'username': 'John Doe', - 'user_id': 'user123', - 'category': 'Washer/Dryer Maintenance', - 'description': 'Machine leaking', - 'has_image': false, - }, - )).called(1); - }); - - test('Image selection updates selectedImage', () { - // Simulate a picked file - final fakeFile = File('test_assets/fake_image.jpg'); - controller.selectedImage = fakeFile; - - expect(controller.selectedImage!.path, contains('fake_image.jpg')); - }); + testWidgets('Selecting a location calls controller.selectLocation', (tester) async { + await tester.pumpWidget(buildForm()); + + await tester.tap(find.text('Select Location')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('123 Main St')); + await tester.pumpAndSettle(); + + verify(() => controller.selectLocation('123 Main St')).called(1); + }); + + testWidgets('Typing in description updates controller', (tester) async { + final textController = TextEditingController(); + when(() => controller.descriptionController).thenReturn(textController); + + await tester.pumpWidget(buildForm()); + await tester.enterText(find.byType(TextField), 'Test description'); + expect(textController.text, 'Test description'); + }); + + testWidgets('Shows image preview when selectedImage is present', (tester) async { + final fakeFile = File('fake_path.jpg'); + when(() => controller.selectedImage).thenReturn(fakeFile); + + await tester.pumpWidget(buildForm()); + expect(find.byType(Image), findsOneWidget); + }); + + testWidgets('Shows error message when form invalid and attemptedSubmit', (tester) async { + when(() => controller.attemptedSubmit).thenReturn(true); + when(() => controller.isFormValid).thenReturn(false); + + await tester.pumpWidget(buildForm()); + + expect(find.text('Please fill in all fields'), findsOneWidget); + }); + + testWidgets('Tapping image picker calls controller.pickImage', (tester) async { + await tester.pumpWidget(buildForm()); + await tester.tap(find.text('Tap to take or upload a photo')); + await tester.pump(); + + verify(() => controller.pickImage(any())).called(1); }); } \ No newline at end of file diff --git a/test/features/maintenance_request/maintenance_request_test.dart b/test/features/maintenance_request/maintenance_request_test.dart index f7f3cdd..d1442d2 100644 --- a/test/features/maintenance_request/maintenance_request_test.dart +++ b/test/features/maintenance_request/maintenance_request_test.dart @@ -24,7 +24,9 @@ void main() { when(() => mockController.isFormValid).thenReturn(false); when(() => mockController.selectedImage).thenReturn(null); when(() => mockController.descriptionController).thenReturn(descriptionController); - + when(() => mockController.locations).thenReturn(['Address 1', 'Address 2']); + when(() => mockController.selectedLocation).thenReturn(null); + when(() => mockController.init()).thenAnswer((_) async => {}); when(() => mockController.addListener(any())).thenReturn(null); when(() => mockController.removeListener(any())).thenReturn(null); when(() => mockController.disposeController()).thenReturn(null); @@ -58,6 +60,12 @@ void main() { expect(find.byIcon(Icons.arrow_back), findsOneWidget); }); + testWidgets('calls init on page load to fetch locations', (tester) async { + await tester.pumpWidget(createWidget()); + + verify(() => mockController.init()).called(1); + }); + testWidgets('submit button shows grey background when form is invalid', (tester) async { when(() => mockController.isFormValid).thenReturn(false); @@ -117,7 +125,7 @@ void main() { await tester.tap(submitButton); await tester.pump(); await tester.pumpAndSettle(); - + expect(find.text('Success'), findsOneWidget); expect(find.text('Your maintenance request has been submitted'), findsOneWidget); }); diff --git a/test/features/maintenance_request/widgets/location_selector_test.dart b/test/features/maintenance_request/widgets/location_selector_test.dart new file mode 100644 index 0000000..0e9cdcc --- /dev/null +++ b/test/features/maintenance_request/widgets/location_selector_test.dart @@ -0,0 +1,74 @@ +import 'package:clean_stream_laundry_app/features/maintenance_request/widgets/location_selector.dart'; +import 'package:clean_stream_laundry_app/features/maintenance_request/controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockMaintenanceController extends Mock implements MaintenanceController {} + +void main() { + late MockMaintenanceController mockController; + + setUp(() { + mockController = MockMaintenanceController(); + when(() => mockController.locations).thenReturn(['123 Wash Ave', '456 Spin Ct']); + when(() => mockController.selectedLocation).thenReturn(null); + when(() => mockController.selectLocation(any())).thenReturn(null); + }); + + Widget createWidget() { + return MaterialApp( + home: Scaffold( + body: LocationSelector(controller: mockController), + ), + ); + } + + testWidgets('displays placeholder text when no location is selected', (tester) async { + await tester.pumpWidget(createWidget()); + + expect(find.text('Select Location'), findsOneWidget); + }); + + testWidgets('displays the selected location name from the controller', (tester) async { + when(() => mockController.selectedLocation).thenReturn('123 Wash Ave'); + + await tester.pumpWidget(createWidget()); + + expect(find.text('123 Wash Ave'), findsOneWidget); + expect(find.text('Select Location'), findsNothing); + }); + + testWidgets('opens bottom sheet and shows options when tapped', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.tap(find.byType(GestureDetector)); + await tester.pumpAndSettle(); + + expect(find.text('No Applicable Location'), findsOneWidget); + expect(find.text('123 Wash Ave'), findsOneWidget); + expect(find.text('456 Spin Ct'), findsOneWidget); + }); + + testWidgets('calls selectLocation on controller and closes sheet when an item is tapped', (tester) async { + await tester.pumpWidget(createWidget()); + + await tester.tap(find.byType(GestureDetector)); + await tester.pumpAndSettle(); + await tester.tap(find.text('456 Spin Ct')); + await tester.pumpAndSettle(); + + verify(() => mockController.selectLocation('456 Spin Ct')).called(1); + expect(find.text('456 Spin Ct'), findsNothing); + }); + + testWidgets('selecting "No Applicable Location" sends correct string', (tester) async { + await tester.pumpWidget(createWidget()); + + await tester.tap(find.byType(GestureDetector)); + await tester.pumpAndSettle(); + await tester.tap(find.text('No Applicable Location')); + await tester.pumpAndSettle(); + + verify(() => mockController.selectLocation('No Applicable Location')).called(1); + }); +} \ No newline at end of file diff --git a/test/features/maintenance_request/widgets/maintenance_form_test.dart b/test/features/maintenance_request/widgets/maintenance_form_test.dart index 9229f50..bf5c199 100644 --- a/test/features/maintenance_request/widgets/maintenance_form_test.dart +++ b/test/features/maintenance_request/widgets/maintenance_form_test.dart @@ -1,5 +1,4 @@ import 'dart:io'; - import 'package:clean_stream_laundry_app/features/maintenance_request/widgets/maintenance_form.dart'; import 'package:clean_stream_laundry_app/features/maintenance_request/controller.dart'; import 'package:flutter/material.dart'; @@ -12,11 +11,12 @@ class FakeBuildContext extends Fake implements BuildContext {} void main() { late MockMaintenanceController controller; - Widget buildForm() { return MaterialApp( home: Scaffold( - body: MaintenanceForm(controller: controller), + body: SingleChildScrollView( + child: MaintenanceForm(controller: controller), + ), ), ); } @@ -26,13 +26,15 @@ void main() { when(() => controller.categories).thenReturn(['Electrical', 'Washer', 'Dryer']); when(() => controller.selectedCategory).thenReturn(null); - when(() => controller.descriptionController) - .thenReturn(TextEditingController()); + when(() => controller.descriptionController).thenReturn(TextEditingController()); when(() => controller.selectedImage).thenReturn(null); when(() => controller.attemptedSubmit).thenReturn(false); when(() => controller.isFormValid).thenReturn(true); + when(() => controller.locations).thenReturn(['123 Main St', '456 Oak Ave']); + when(() => controller.selectedLocation).thenReturn(null); when(() => controller.selectCategory(any())).thenReturn(null); + when(() => controller.selectLocation(any())).thenReturn(null); when(() => controller.pickImage(any())).thenAnswer((_) async {}); }); @@ -44,86 +46,78 @@ void main() { await tester.pumpWidget(buildForm()); expect(find.text('Select a Category'), findsOneWidget); - expect(find.byType(DropdownButtonFormField), findsOneWidget); - + expect(find.text('Location'), findsOneWidget); expect(find.text('Reason for Maintenance'), findsOneWidget); - expect(find.byType(TextField), findsOneWidget); - expect(find.text('Attach a Photo (Optional)'), findsOneWidget); - expect(find.byIcon(Icons.camera_alt), findsOneWidget); }); - testWidgets('Dropdown shows categories from controller', (tester) async { + testWidgets('Dropdown shows categories and triggers selection', (tester) async { await tester.pumpWidget(buildForm()); await tester.tap(find.byType(DropdownButtonFormField)); await tester.pumpAndSettle(); expect(find.text('Electrical'), findsOneWidget); - expect(find.text('Washer'), findsOneWidget); - expect(find.text('Dryer'), findsOneWidget); + await tester.tap(find.text('Washer').last); + await tester.pumpAndSettle(); + + verify(() => controller.selectCategory('Washer')).called(1); }); - testWidgets('Selecting a category triggers controller.selectCategory', - (tester) async { - await tester.pumpWidget(buildForm()); + testWidgets('LocationSelector opens bottom sheet and shows options', (tester) async { + await tester.pumpWidget(buildForm()); - await tester.tap(find.byType(DropdownButtonFormField)); - await tester.pumpAndSettle(); + await tester.tap(find.text('Select Location')); + await tester.pumpAndSettle(); - await tester.tap(find.text('Washer').last); - await tester.pumpAndSettle(); + expect(find.text('No Applicable Location'), findsOneWidget); + expect(find.text('123 Main St'), findsOneWidget); + verifyNever(() => controller.selectLocation(any())); + }); - verify(() => controller.selectCategory('Washer')).called(1); - }); + testWidgets('Selecting a location calls controller.selectLocation', (tester) async { + await tester.pumpWidget(buildForm()); - testWidgets('Typing in description updates controller.descriptionController', - (tester) async { - final textController = TextEditingController(); - when(() => controller.descriptionController).thenReturn(textController); + await tester.tap(find.text('Select Location')); + await tester.pumpAndSettle(); - await tester.pumpWidget(buildForm()); + await tester.tap(find.text('123 Main St')); + await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), 'Motor is making noise'); - expect(textController.text, 'Motor is making noise'); - }); + verify(() => controller.selectLocation('123 Main St')).called(1); + }); - testWidgets('Shows placeholder when no image selected', (tester) async { - when(() => controller.selectedImage).thenReturn(null); + testWidgets('Typing in description updates controller', (tester) async { + final textController = TextEditingController(); + when(() => controller.descriptionController).thenReturn(textController); await tester.pumpWidget(buildForm()); - - expect(find.byIcon(Icons.camera_alt), findsOneWidget); - expect(find.text('Tap to take or upload a photo'), findsOneWidget); + await tester.enterText(find.byType(TextField), 'Test description'); + expect(textController.text, 'Test description'); }); - testWidgets('Shows image preview when selectedImage is not null', - (tester) async { - final fakeImage = File('fake_path.jpg'); - when(() => controller.selectedImage).thenReturn(fakeImage); - - await tester.pumpWidget(buildForm()); + testWidgets('Shows image preview when selectedImage is present', (tester) async { + final fakeFile = File('fake_path.jpg'); + when(() => controller.selectedImage).thenReturn(fakeFile); - expect(find.byType(Image), findsOneWidget); - }); + await tester.pumpWidget(buildForm()); + expect(find.byType(Image), findsOneWidget); + }); - testWidgets('Shows error message when form invalid and attemptedSubmit', - (tester) async { - when(() => controller.attemptedSubmit).thenReturn(true); - when(() => controller.isFormValid).thenReturn(false); + testWidgets('Shows error message when form invalid and attemptedSubmit', (tester) async { + when(() => controller.attemptedSubmit).thenReturn(true); + when(() => controller.isFormValid).thenReturn(false); - await tester.pumpWidget(buildForm()); + await tester.pumpWidget(buildForm()); - expect(find.text('Please fill in all fields'), findsOneWidget); - }); + expect(find.text('Please fill in all fields'), findsOneWidget); + }); - testWidgets('Tapping image picker calls controller.pickImage', - (tester) async { - await tester.pumpWidget(buildForm()); - - await tester.tap(find.text('Tap to take or upload a photo')); - await tester.pump(); + testWidgets('Tapping image picker calls controller.pickImage', (tester) async { + await tester.pumpWidget(buildForm()); + await tester.tap(find.text('Tap to take or upload a photo')); + await tester.pump(); - verify(() => controller.pickImage(any())).called(1); - }); + verify(() => controller.pickImage(any())).called(1); + }); } \ No newline at end of file From 53e497830ac703dc3524afe0f1785bf3d9da0a5d Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Wed, 15 Apr 2026 01:41:54 -0400 Subject: [PATCH 71/79] Reduced space header took up too much space, shortened sized boxes in between fields and text, reduced char limit for reason for maintenance, and limited reason for maintenance size --- .../maintenance_request.dart | 2 - .../maintenance_request/widgets/header.dart | 62 ------------------- .../widgets/maintenance_form.dart | 17 ++--- .../widgets/header_test.dart | 35 ----------- 4 files changed, 9 insertions(+), 107 deletions(-) delete mode 100644 lib/features/maintenance_request/widgets/header.dart delete mode 100644 test/features/maintenance_request/widgets/header_test.dart diff --git a/lib/features/maintenance_request/maintenance_request.dart b/lib/features/maintenance_request/maintenance_request.dart index 791c8ef..7c2ba05 100644 --- a/lib/features/maintenance_request/maintenance_request.dart +++ b/lib/features/maintenance_request/maintenance_request.dart @@ -1,6 +1,5 @@ import 'controller.dart'; import 'widgets/maintenance_form.dart'; -import 'widgets/header.dart'; import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:clean_stream_laundry_app/features/widgets/status_dialog_box.dart'; import 'package:flutter/material.dart'; @@ -99,7 +98,6 @@ class MaintenancePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Header(), const SizedBox(height: 28), MaintenanceForm(controller: _controller), const SizedBox(height: 24), diff --git a/lib/features/maintenance_request/widgets/header.dart b/lib/features/maintenance_request/widgets/header.dart deleted file mode 100644 index cc4c52d..0000000 --- a/lib/features/maintenance_request/widgets/header.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:clean_stream_laundry_app/core/theme/theme.dart'; -import 'package:flutter/material.dart'; - -class Header extends StatelessWidget { - const Header({super.key}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: colorScheme.primary.withOpacity(0.08), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: colorScheme.primary.withOpacity(0.2)), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.primary.withOpacity(0.15), - borderRadius: BorderRadius.circular(14), - ), - child: Icon( - Icons.receipt_long_rounded, - color: colorScheme.primary, - size: 28, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Submit a Maintenance Request', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: colorScheme.fontInverted, - ), - ), - const SizedBox(height: 4), - Text( - 'Select a transaction and describe your issue. ' - 'Our team will review it shortly.', - style: TextStyle( - fontSize: 13, - color: colorScheme.fontSecondary, - ), - ), - ], - ), - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/lib/features/maintenance_request/widgets/maintenance_form.dart b/lib/features/maintenance_request/widgets/maintenance_form.dart index baf4fb6..5968868 100644 --- a/lib/features/maintenance_request/widgets/maintenance_form.dart +++ b/lib/features/maintenance_request/widgets/maintenance_form.dart @@ -48,7 +48,7 @@ class MaintenanceForm extends StatelessWidget { color: colorScheme.fontInverted, ), ), - const SizedBox(height: 8), + const SizedBox(height: 4), DropdownButtonFormField( initialValue: controller.selectedCategory, @@ -75,7 +75,7 @@ class MaintenanceForm extends StatelessWidget { }, ), - const SizedBox(height: 24), + const SizedBox(height: 18), Text( 'Location', @@ -85,10 +85,10 @@ class MaintenanceForm extends StatelessWidget { color: colorScheme.fontInverted, ), ), - const SizedBox(height: 8), + const SizedBox(height: 4), LocationSelector(controller: controller), - const SizedBox(height: 24), + const SizedBox(height: 18), Text( 'Reason for Maintenance', @@ -98,11 +98,12 @@ class MaintenanceForm extends StatelessWidget { color: colorScheme.fontInverted, ), ), - const SizedBox(height: 8), + const SizedBox(height: 4), TextField( controller: controller.descriptionController, - minLines: 4, - maxLines: null, + maxLength: 200, + minLines: 2, + maxLines: 4, keyboardType: TextInputType.multiline, style: TextStyle(color: colorScheme.fontInverted), decoration: _inputDecoration(context).copyWith( @@ -111,7 +112,7 @@ class MaintenanceForm extends StatelessWidget { ), ), - const SizedBox(height: 24), + const SizedBox(height: 18), Text( 'Attach a Photo (Optional)', style: TextStyle( diff --git a/test/features/maintenance_request/widgets/header_test.dart b/test/features/maintenance_request/widgets/header_test.dart deleted file mode 100644 index 0007e63..0000000 --- a/test/features/maintenance_request/widgets/header_test.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:clean_stream_laundry_app/features/maintenance_request/widgets/header.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - Widget buildWidget() { - return const MaterialApp( - home: Scaffold(body: Header()), - ); - } - - group('Header', () { - testWidgets('displays receipt_long_rounded icon', (tester) async { - await tester.pumpWidget(buildWidget()); - expect(find.byIcon(Icons.receipt_long_rounded), findsOneWidget); - }); - - testWidgets('displays Submit a Maintenance Request title', (tester) async { - await tester.pumpWidget(buildWidget()); - expect(find.text('Submit a Maintenance Request'), findsOneWidget); - }); - - - testWidgets('title has bold font weight', (tester) async { - await tester.pumpWidget(buildWidget()); - final text = tester.widget(find.text('Submit a Maintenance Request')); - expect(text.style?.fontWeight, FontWeight.bold); - }); - - testWidgets('renders inside a Row', (tester) async { - await tester.pumpWidget(buildWidget()); - expect(find.byType(Row), findsOneWidget); - }); - }); -} \ No newline at end of file From 50c53fb500fec4ef5bca19757b85e758fdd50437 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Wed, 15 Apr 2026 01:44:54 -0400 Subject: [PATCH 72/79] Reduced logo height --- lib/features/settings/settings.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/settings/settings.dart b/lib/features/settings/settings.dart index a6272f0..312ec5f 100644 --- a/lib/features/settings/settings.dart +++ b/lib/features/settings/settings.dart @@ -85,7 +85,7 @@ class _SettingsState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Image.asset('assets/Logo.png', width: 230, height: 230), + Image.asset('assets/Logo.png', width: 150, height: 150), SettingsCard( icon: Icons.lightbulb, title: Theme.of(context).colorScheme.modeChangerText, From b55d0c370f4b3e20605ff8a29d171a78c695207a Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Wed, 15 Apr 2026 15:19:00 -0400 Subject: [PATCH 73/79] Added Location Maint Category --- lib/features/maintenance_request/controller.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/features/maintenance_request/controller.dart b/lib/features/maintenance_request/controller.dart index 7b0ccc9..7b9e26b 100644 --- a/lib/features/maintenance_request/controller.dart +++ b/lib/features/maintenance_request/controller.dart @@ -29,6 +29,7 @@ class MaintenanceController extends ChangeNotifier { final List categories = const [ 'Washer/Dryer Maintenance', 'App Maintenance', + 'Location Maintenance', 'Other', ]; From 864527cbe87a961159560150909aeb82bed2071c Mon Sep 17 00:00:00 2001 From: karelinejones Date: Wed, 15 Apr 2026 16:55:49 -0400 Subject: [PATCH 74/79] changed email verification to using code instead of email link --- lib/core/router/app_router.dart | 19 +- .../email_verification/controller.dart | 99 +- .../email_verification.dart | 207 ++- lib/features/login/controller.dart | 24 +- lib/features/sign_up/sign_up.dart | 10 +- lib/logic/services/auth_service.dart | 11 +- .../supabase/supabase_auth_service.dart | 45 +- supabase/config.toml | 2 +- test/features/login/login_test.dart | 346 ++--- .../authentication/authenticator_test.dart | 1113 +++++++++-------- 10 files changed, 1114 insertions(+), 762 deletions(-) diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index d7d40fa..4bf10f8 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -90,13 +90,16 @@ class RouterService { ), GoRoute( path: '/email-verification', - pageBuilder: (context, state) => CustomTransitionPage( - key: state.pageKey, - child: EmailVerificationPage(appLinks: AppLinks()), - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, - transitionsBuilder: (_, _, _, child) => child, - ), + pageBuilder: (context, state) { + final email = state.extra is String ? state.extra as String : null; + return CustomTransitionPage( + key: state.pageKey, + child: EmailVerificationPage(email: email), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + transitionsBuilder: (_, _, _, child) => child, + ); + }, ), GoRoute( path: '/homePage', @@ -195,7 +198,7 @@ class RouterService { transitionsBuilder: (_, _, _, child) => child, ); }, - ) + ), ], errorBuilder: (context, state) { final uri = state.uri; diff --git a/lib/features/email_verification/controller.dart b/lib/features/email_verification/controller.dart index 9a62c31..b671914 100644 --- a/lib/features/email_verification/controller.dart +++ b/lib/features/email_verification/controller.dart @@ -1,65 +1,86 @@ -import 'dart:async'; -import 'package:app_links/app_links.dart'; import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; + +enum EmailVerifyResult { success, invalid, error } + +enum EmailResendResult { success, failed, error } class EmailVerificationController { final AuthService _authService = GetIt.instance(); - final AppLinks appLinks; - final BuildContext context; - StreamSubscription? _authSub; - StreamSubscription? _linkSub; + final TextEditingController codeController = TextEditingController(); bool resent = false; bool isLoading = false; AuthenticationResponses? lastResponse; + String? error; - EmailVerificationController({ - required this.appLinks, - required this.context, - }); - - void init() { - _authSub = _authService.onAuthChange.listen((isLoggedIn) { - if (isLoggedIn && _authService.isEmailVerified()) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) context.go('/homePage'); - }); - } - }); - - _linkSub = appLinks.uriLinkStream.listen(_handleUri); - } + EmailVerificationController(); void dispose() { - _authSub?.cancel(); - _linkSub?.cancel(); + codeController.dispose(); } - Future _handleUri(Uri? uri) async { - if (uri != null && - uri.scheme == 'clean-stream' && - uri.host == 'email-verification') { - await _authService.getSessionFromURI(uri); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) context.go('/homePage'); - }); - } - } - - Future resendVerification() async { + Future resendVerification({String? email}) async { if (resent) return; isLoading = true; - lastResponse = await _authService.resendVerification(); + lastResponse = await _authService.resendVerification(email: email); isLoading = false; if (lastResponse == AuthenticationResponses.success) { resent = true; } } -} \ No newline at end of file + + String? get currentEmail => _authService.getCurrentUserEmail(); + + void clearError() { + error = null; + } + + Future verifyEmailCode(String email) async { + final code = codeController.text.trim(); + + if (code.length != 6) { + error = 'Please enter the 6-digit code'; + return EmailVerifyResult.invalid; + } + + isLoading = true; + error = null; + + try { + final response = await _authService.verifyEmailCode( + email: email, + code: code, + ); + + if (response == AuthenticationResponses.success) { + return EmailVerifyResult.success; + } + + error = 'Invalid or expired code'; + return EmailVerifyResult.invalid; + } catch (_) { + error = 'Something went wrong. Try again'; + return EmailVerifyResult.error; + } finally { + isLoading = false; + } + } + + Future resendVerificationEmail(String email) async { + try { + await resendVerification(email: email); + if (lastResponse == AuthenticationResponses.success) { + return EmailResendResult.success; + } + return EmailResendResult.failed; + } catch (_) { + return EmailResendResult.error; + } + } +} diff --git a/lib/features/email_verification/email_verification.dart b/lib/features/email_verification/email_verification.dart index 1acaf1d..7b2aeda 100644 --- a/lib/features/email_verification/email_verification.dart +++ b/lib/features/email_verification/email_verification.dart @@ -1,13 +1,13 @@ -import 'package:app_links/app_links.dart'; import 'package:clean_stream_laundry_app/features/email_verification/controller.dart'; -import 'package:clean_stream_laundry_app/features/email_verification/widgets/resend_verification.dart'; +import 'package:clean_stream_laundry_app/features/verify_code/widgets/code_field.dart'; import 'package:clean_stream_laundry_app/core/theme/theme.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; class EmailVerificationPage extends StatefulWidget { - final AppLinks appLinks; + final String? email; - const EmailVerificationPage({super.key, required this.appLinks}); + const EmailVerificationPage({super.key, this.email}); @override State createState() => _EmailVerificationPageState(); @@ -19,11 +19,7 @@ class _EmailVerificationPageState extends State { @override void initState() { super.initState(); - _controller = EmailVerificationController( - appLinks: widget.appLinks, - context: context, - ); - _controller.init(); + _controller = EmailVerificationController(); } @override @@ -32,48 +28,183 @@ class _EmailVerificationPageState extends State { super.dispose(); } - void _refresh() { + void _showMessage(String msg) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + + String? _resolveEmail() { + final fromRoute = widget.email?.trim(); + if (fromRoute != null && fromRoute.isNotEmpty) { + return fromRoute; + } + + final fromSession = _controller.currentEmail?.trim(); + if (fromSession != null && fromSession.isNotEmpty) { + return fromSession; + } + + return null; + } + + Future _onVerifyPressed() async { + final email = _resolveEmail(); + if (email == null) { + _showMessage('Missing account email. Please log in and try again.'); + return; + } + + setState(() {}); + final result = await _controller.verifyEmailCode(email); + if (!mounted) return; + setState(() {}); + if (result == EmailVerifyResult.success) { + _showMessage('Email verified successfully!'); + context.go('/homePage'); + } + } + + Future _onResendPressed() async { + final email = _resolveEmail(); + if (email == null) { + _showMessage('Missing account email. Please log in and try again.'); + return; + } + + setState(() {}); + final result = await _controller.resendVerificationEmail(email); + if (!mounted) return; + + setState(() {}); + switch (result) { + case EmailResendResult.success: + _showMessage('Verification code sent! Check your email.'); + break; + case EmailResendResult.failed: + _showMessage('Failed to send verification code.'); + break; + case EmailResendResult.error: + _showMessage('Error sending verification code.'); + break; + } } @override Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final email = _resolveEmail(); + return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - body: Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.email, size: 80, color: Colors.blueAccent), - const SizedBox(height: 24), - Text( - 'Please verify your email address', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 18, - color: Theme.of(context).colorScheme.fontInverted, + backgroundColor: scheme.surface, + appBar: AppBar( + backgroundColor: scheme.surface, + foregroundColor: scheme.fontInverted, + title: const Text('Verify Email'), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => context.go('/login'), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 32), + Icon( + Icons.mark_email_read_outlined, + size: 80, + color: scheme.primary, + ), + const SizedBox(height: 32), + Text( + 'Verify your email', + style: + Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: scheme.fontInverted, + ) ?? + TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: scheme.fontInverted, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'Enter the 6-digit verification code we sent to your email address.', + style: + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scheme.fontSecondary, + ) ?? + TextStyle(color: scheme.fontSecondary), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + email ?? 'your email', + style: TextStyle( + fontWeight: FontWeight.w600, + color: scheme.primary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 40), + VerificationCodeField( + controller: _controller.codeController, + error: _controller.error, + onChanged: (_) { + _controller.clearError(); + setState(() {}); + }, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _controller.isLoading ? null : _onVerifyPressed, + style: ElevatedButton.styleFrom( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 14), ), + child: _controller.isLoading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Verify Email'), ), - const SizedBox(height: 16), + ), + const SizedBox(height: 16), + if (_controller.error != null) Text( - 'Check your inbox and click the verification link.', + _controller.error!, + style: const TextStyle(color: Colors.red), textAlign: TextAlign.center, - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.fontSecondary, - ), ), - const SizedBox(height: 24), - ResendVerificationWidget( - controller: _controller, - onStateChange: _refresh, + const SizedBox(height: 8), + TextButton( + onPressed: _controller.isLoading ? null : _onResendPressed, + child: Text( + 'Resend code', + style: TextStyle(color: scheme.primary), + ), + ), + TextButton( + onPressed: _controller.isLoading + ? null + : () => context.go('/login'), + child: Text( + 'Back to Login', + style: TextStyle(color: scheme.primary), ), - ], - ), + ), + ], ), ), ); } -} \ No newline at end of file +} diff --git a/lib/features/login/controller.dart b/lib/features/login/controller.dart index 461c0b8..b480cae 100644 --- a/lib/features/login/controller.dart +++ b/lib/features/login/controller.dart @@ -11,11 +11,9 @@ class LoginController extends ChangeNotifier { final AuthService authService; final ProfileService profileService; - LoginController({ - AuthService? authService, - ProfileService? profileService, - }) : authService = authService ?? GetIt.instance(), - profileService = profileService ?? GetIt.instance(); + LoginController({AuthService? authService, ProfileService? profileService}) + : authService = authService ?? GetIt.instance(), + profileService = profileService ?? GetIt.instance(); final TextEditingController emailController = TextEditingController(); final TextEditingController passwordController = TextEditingController(); @@ -49,11 +47,11 @@ class LoginController extends ChangeNotifier { if (!context.mounted) return; final currentUser = authService.getCurrentUser(); if (currentUser != null) { - final name = currentUser.userMetadata?['full_name'] ?? + final name = + currentUser.userMetadata?['full_name'] ?? currentUser.userMetadata?['name'] ?? currentUser.userMetadata?['given_name']; - await profileService.createAccount( - id: currentUser.id, name: name); + await profileService.createAccount(id: currentUser.id, name: name); } if (!context.mounted) return; context.go('/homePage'); @@ -72,8 +70,10 @@ class LoginController extends ChangeNotifier { scrollController.dispose(); } - Future handleLogin(BuildContext context, - void Function(String) showMessage) async { + Future handleLogin( + BuildContext context, + void Function(String) showMessage, + ) async { final email = emailController.text.trim(); final password = passwordController.text; @@ -90,7 +90,7 @@ class LoginController extends ChangeNotifier { showMessage('Logged in as $email'); context.go('/homePage'); } else if (response == AuthenticationResponses.emailNotVerified) { - context.go('/email-Verification'); + context.go('/email-verification', extra: email); } else { setErrorColors(); } @@ -110,4 +110,4 @@ class LoginController extends ChangeNotifier { obscurePassword = !obscurePassword; notifyListeners(); } -} \ No newline at end of file +} diff --git a/lib/features/sign_up/sign_up.dart b/lib/features/sign_up/sign_up.dart index 7202ba7..000a8d2 100644 --- a/lib/features/sign_up/sign_up.dart +++ b/lib/features/sign_up/sign_up.dart @@ -38,8 +38,7 @@ class SignUpPageState extends State { } void _showMessage(String text) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(text))); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); } Future _onSubmit() async { @@ -49,7 +48,10 @@ class SignUpPageState extends State { if (result.success) { _showMessage('Account created successfully.'); - context.go('/email-verification'); + context.go( + '/email-verification', + extra: _controller.emailController.text.trim(), + ); return; } @@ -146,4 +148,4 @@ class SignUpPageState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/logic/services/auth_service.dart b/lib/logic/services/auth_service.dart index 99146e3..9cdd3f4 100644 --- a/lib/logic/services/auth_service.dart +++ b/lib/logic/services/auth_service.dart @@ -12,7 +12,7 @@ abstract class AuthService { Future isLoggedIn(); String? get getCurrentUserId; String getLastSignedUpUserId(); - Future resendVerification(); + Future resendVerification({String? email}); Stream get onAuthChange; bool isEmailVerified(); Future resetPassword(String email); @@ -28,5 +28,12 @@ abstract class AuthService { }); Future exchangeCodeForSession(String code); Future updatePassword(String newPassword); - Future verifyCode({required String email, required String code}); + Future verifyCode({ + required String email, + required String code, + }); + Future verifyEmailCode({ + required String email, + required String code, + }); } diff --git a/lib/services/supabase/supabase_auth_service.dart b/lib/services/supabase/supabase_auth_service.dart index c813fa8..57a1d5a 100644 --- a/lib/services/supabase/supabase_auth_service.dart +++ b/lib/services/supabase/supabase_auth_service.dart @@ -82,12 +82,16 @@ class SupabaseAuthService implements AuthService { final AuthResponse response = await _client.auth.signUp( email: email, password: password, - emailRedirectTo: 'clean-stream://email-verification', data: {"full_name": name}, ); if (response.user != null) { lastSignedUpUserId = response.user!.id; + try { + await _client.auth.resend(type: OtpType.signup, email: email); + } catch (e) { + print('Error sending verification OTP: $e'); + } output = AuthenticationResponses.success; } } else { @@ -157,10 +161,11 @@ class SupabaseAuthService implements AuthService { return output; } - Future resendVerification() async { + @override + Future resendVerification({String? email}) async { AuthenticationResponses output = AuthenticationResponses.success; - final userEmail = _client.auth.currentUser?.email; + final userEmail = email ?? _client.auth.currentUser?.email; try { if (userEmail != null) { @@ -266,9 +271,7 @@ class SupabaseAuthService implements AuthService { AuthenticationResponses output = AuthenticationResponses.failure; try { // Send password reset email and redirect back to the app via deep link. - await _client.auth.resetPasswordForEmail( - email - ); + await _client.auth.resetPasswordForEmail(email); output = AuthenticationResponses.success; } catch (e) { print('resetPassword error: $e'); @@ -306,7 +309,10 @@ class SupabaseAuthService implements AuthService { } @override - Future verifyCode({required String email, required String code}) async { + Future verifyCode({ + required String email, + required String code, + }) async { AuthenticationResponses output = AuthenticationResponses.success; try { @@ -319,11 +325,34 @@ class SupabaseAuthService implements AuthService { if (response.session == null) { output = AuthenticationResponses.failure; } - }catch (e){ + } catch (e) { output = AuthenticationResponses.failure; } return output; } + @override + Future verifyEmailCode({ + required String email, + required String code, + }) async { + AuthenticationResponses output = AuthenticationResponses.success; + + try { + final response = await _client.auth.verifyOTP( + email: email, + token: code, + type: OtpType.signup, + ); + + if (response.session == null) { + output = AuthenticationResponses.failure; + } + } catch (e) { + output = AuthenticationResponses.failure; + } + + return output; + } } diff --git a/supabase/config.toml b/supabase/config.toml index 06284e2..79ccbf8 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -206,7 +206,7 @@ enable_signup = true # addresses. If disabled, only the new email is required to confirm. double_confirm_changes = true # If enabled, users need to confirm their email address before signing in. -enable_confirmations = false +enable_confirmations = true # If enabled, users will need to reauthenticate or have logged in recently to change their password. secure_password_change = false # Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. diff --git a/test/features/login/login_test.dart b/test/features/login/login_test.dart index e5b26dd..f6c5855 100644 --- a/test/features/login/login_test.dart +++ b/test/features/login/login_test.dart @@ -49,9 +49,8 @@ void main() { builder: (_, __) => const Scaffold(body: Text('Home Page')), ), GoRoute( - path: '/email-Verification', - builder: (_, __) => - const Scaffold(body: Text('Email Verification')), + path: '/email-verification', + builder: (_, __) => const Scaffold(body: Text('Verify your email')), ), GoRoute( path: '/signup', @@ -60,7 +59,7 @@ void main() { GoRoute( path: '/password-reset', builder: (_, __) => - const Scaffold(body: Text('Password Reset Page')), + const Scaffold(body: Text('Password Reset Page')), ), GoRoute( path: '/login', @@ -72,18 +71,21 @@ void main() { } void mockLoginResponse(AuthenticationResponses response) { - when(() => mockAuthService.login(any(), any())) - .thenAnswer((_) async => response); + when( + () => mockAuthService.login(any(), any()), + ).thenAnswer((_) async => response); } Future enterCredentials( - WidgetTester tester, { - String email = 'test@example.com', - String password = 'password123', - }) async { + WidgetTester tester, { + String email = 'test@example.com', + String password = 'password123', + }) async { await tester.enterText(find.widgetWithText(TextField, 'Email'), email); await tester.enterText( - find.widgetWithText(TextField, 'Password'), password); + find.widgetWithText(TextField, 'Password'), + password, + ); } group('Static UI', () { @@ -172,23 +174,26 @@ void main() { }); group('Login functionality', () { - testWidgets('shows error snackbar when both fields are empty', - (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); + testWidgets('shows error snackbar when both fields are empty', ( + tester, + ) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); - await tester.pump(); + await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); + await tester.pump(); - expect(find.text('Please fill in both fields.'), findsOneWidget); - }); + expect(find.text('Please fill in both fields.'), findsOneWidget); + }); testWidgets('shows error snackbar when email is empty', (tester) async { await tester.pumpWidget(createWidget()); await tester.pumpAndSettle(); await tester.enterText( - find.widgetWithText(TextField, 'Password'), 'password123'); + find.widgetWithText(TextField, 'Password'), + 'password123', + ); await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); await tester.pump(); @@ -200,26 +205,29 @@ void main() { await tester.pumpAndSettle(); await tester.enterText( - find.widgetWithText(TextField, 'Email'), 'test@example.com'); + find.widgetWithText(TextField, 'Email'), + 'test@example.com', + ); await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); await tester.pump(); expect(find.text('Please fill in both fields.'), findsOneWidget); }); - testWidgets('shows logging in snackbar when credentials entered', - (tester) async { - mockLoginResponse(AuthenticationResponses.success); + testWidgets('shows logging in snackbar when credentials entered', ( + tester, + ) async { + mockLoginResponse(AuthenticationResponses.success); - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); - await enterCredentials(tester); - await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); - await tester.pump(); + await enterCredentials(tester); + await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); + await tester.pump(); - expect(find.text('Logging in as test@example.com...'), findsOneWidget); - }); + expect(find.text('Logging in as test@example.com...'), findsOneWidget); + }); testWidgets('navigates to home page on successful login', (tester) async { mockLoginResponse(AuthenticationResponses.success); @@ -232,23 +240,25 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Home Page'), findsOneWidget); - verify(() => mockAuthService.login('test@example.com', 'password123')) - .called(1); + verify( + () => mockAuthService.login('test@example.com', 'password123'), + ).called(1); }); - testWidgets('navigates to email verification on unverified email', - (tester) async { - mockLoginResponse(AuthenticationResponses.emailNotVerified); + testWidgets('navigates to email verification on unverified email', ( + tester, + ) async { + mockLoginResponse(AuthenticationResponses.emailNotVerified); - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); - await enterCredentials(tester); - await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); - await tester.pumpAndSettle(); + await enterCredentials(tester); + await tester.tap(find.widgetWithText(ElevatedButton, 'Log In')); + await tester.pumpAndSettle(); - expect(find.text('Email Verification'), findsOneWidget); - }); + expect(find.text('Verify your email'), findsOneWidget); + }); testWidgets('shows error colors on failed login', (tester) async { mockLoginResponse(AuthenticationResponses.failure); @@ -304,30 +314,33 @@ void main() { }); group('Password visibility', () { - testWidgets('toggles password visibility when suffix icon tapped', - (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); + testWidgets('toggles password visibility when suffix icon tapped', ( + tester, + ) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); - expect( - tester - .widget(find.widgetWithText(TextField, 'Password')) - .obscureText, - isTrue, - ); + expect( + tester + .widget(find.widgetWithText(TextField, 'Password')) + .obscureText, + isTrue, + ); - await tester.tap(find.byIcon(Icons.visibility_off)); - await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility_off)); + await tester.pump(); - expect( - tester - .widget(find.widgetWithText(TextField, 'Password')) - .obscureText, - isFalse, - ); - }); + expect( + tester + .widget(find.widgetWithText(TextField, 'Password')) + .obscureText, + isFalse, + ); + }); - testWidgets('shows visibility icon when password is hidden', (tester) async { + testWidgets('shows visibility icon when password is hidden', ( + tester, + ) async { await tester.pumpWidget(createWidget()); await tester.pumpAndSettle(); @@ -335,20 +348,20 @@ void main() { expect(find.byIcon(Icons.visibility), findsNothing); }); - testWidgets('shows visibility_off icon when password is shown', - (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); + testWidgets('shows visibility_off icon when password is shown', ( + tester, + ) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.visibility_off)); - await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility_off)); + await tester.pump(); - expect(find.byIcon(Icons.visibility), findsOneWidget); - expect(find.byIcon(Icons.visibility_off), findsNothing); - }); + expect(find.byIcon(Icons.visibility), findsOneWidget); + expect(find.byIcon(Icons.visibility_off), findsNothing); + }); }); - group('Navigation', () { testWidgets('navigates to sign_up on Create Account tap', (tester) async { await tester.pumpWidget(createWidget()); @@ -361,29 +374,30 @@ void main() { expect(find.text('Sign Up Page'), findsOneWidget); }); - testWidgets('navigates to password reset on Reset Password tap', - (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); + testWidgets('navigates to password reset on Reset Password tap', ( + tester, + ) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); - await tester.ensureVisible(find.text('Reset Password')); - await tester.tap(find.text('Reset Password')); - await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Reset Password')); + await tester.tap(find.text('Reset Password')); + await tester.pumpAndSettle(); - expect(find.text('Password Reset Page'), findsOneWidget); - }); + expect(find.text('Password Reset Page'), findsOneWidget); + }); }); group('Keyboard enter', () { testWidgets('pressing Enter triggers login', (tester) async { - when(() => mockAuthService.login(any(), any())) - .thenAnswer((_) async => AuthenticationResponses.success); + when( + () => mockAuthService.login(any(), any()), + ).thenAnswer((_) async => AuthenticationResponses.success); await tester.pumpWidget(createWidget()); await tester.pumpAndSettle(); - await tester.enterText( - find.byType(TextField).first, 'test@example.com'); + await tester.enterText(find.byType(TextField).first, 'test@example.com'); await tester.enterText(find.byType(TextField).last, 'password123'); await tester.pump(); @@ -398,68 +412,76 @@ void main() { }); group('Deep links', () { - testWidgets('navigates to home on email-verification deep link', - (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - fakeAppLinks.emit(Uri.parse('clean-stream://email-verification')); - await tester.pumpAndSettle(); - - expect(find.text('Home Page'), findsOneWidget); - }); - - testWidgets('handles oauth deep link with successful session', - (tester) async { - when(() => mockAuthService.getSessionFromURI(any())) - .thenAnswer((_) async {}); - when(() => mockAuthService.isLoggedIn()) - .thenAnswer((_) async => AuthenticationResponses.success); - when(() => mockAuthService.getCurrentUser()).thenReturn( - User( - id: 'testId', - appMetadata: {}, - userMetadata: {'full_name': 'Test User'}, - aud: '', - createdAt: '', - ), - ); - when(() => mockProfileService.createAccount( - id: any(named: 'id'), - name: any(named: 'name'), - )).thenAnswer((_) async {}); - - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - fakeAppLinks.emit(Uri.parse('clean-stream://oauth')); - await tester.pumpAndSettle(); - - expect(find.text('Home Page'), findsOneWidget); - verify(() => mockAuthService.getSessionFromURI(any())).called(1); - verify(() => mockAuthService.isLoggedIn()).called(1); - verify(() => mockAuthService.getCurrentUser()).called(1); - verify(() => mockProfileService.createAccount( - id: 'testId', - name: 'Test User', - )).called(1); - }); - - testWidgets('navigates to login on oauth deep link with failed session', - (tester) async { - when(() => mockAuthService.getSessionFromURI(any())) - .thenAnswer((_) async {}); - when(() => mockAuthService.isLoggedIn()) - .thenAnswer((_) async => AuthenticationResponses.failure); - - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - fakeAppLinks.emit(Uri.parse('clean-stream://oauth')); - await tester.pumpAndSettle(); - - expect(find.text('Login Page'), findsOneWidget); - }); + testWidgets('navigates to home on email-verification deep link', ( + tester, + ) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + fakeAppLinks.emit(Uri.parse('clean-stream://email-verification')); + await tester.pumpAndSettle(); + + expect(find.text('Home Page'), findsOneWidget); + }); + + testWidgets('handles oauth deep link with successful session', ( + tester, + ) async { + when( + () => mockAuthService.getSessionFromURI(any()), + ).thenAnswer((_) async {}); + when( + () => mockAuthService.isLoggedIn(), + ).thenAnswer((_) async => AuthenticationResponses.success); + when(() => mockAuthService.getCurrentUser()).thenReturn( + User( + id: 'testId', + appMetadata: {}, + userMetadata: {'full_name': 'Test User'}, + aud: '', + createdAt: '', + ), + ); + when( + () => mockProfileService.createAccount( + id: any(named: 'id'), + name: any(named: 'name'), + ), + ).thenAnswer((_) async {}); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + fakeAppLinks.emit(Uri.parse('clean-stream://oauth')); + await tester.pumpAndSettle(); + + expect(find.text('Home Page'), findsOneWidget); + verify(() => mockAuthService.getSessionFromURI(any())).called(1); + verify(() => mockAuthService.isLoggedIn()).called(1); + verify(() => mockAuthService.getCurrentUser()).called(1); + verify( + () => mockProfileService.createAccount(id: 'testId', name: 'Test User'), + ).called(1); + }); + + testWidgets('navigates to login on oauth deep link with failed session', ( + tester, + ) async { + when( + () => mockAuthService.getSessionFromURI(any()), + ).thenAnswer((_) async {}); + when( + () => mockAuthService.isLoggedIn(), + ).thenAnswer((_) async => AuthenticationResponses.failure); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + fakeAppLinks.emit(Uri.parse('clean-stream://oauth')); + await tester.pumpAndSettle(); + + expect(find.text('Login Page'), findsOneWidget); + }); testWidgets('ignores deep link with null uri', (tester) async { await tester.pumpWidget(createWidget()); @@ -469,19 +491,19 @@ void main() { }); }); - group('Styling', () { - testWidgets('Log In button has blue background and white text', - (tester) async { - await tester.pumpWidget(createWidget()); - await tester.pumpAndSettle(); - - final button = tester.widget( - find.widgetWithText(ElevatedButton, 'Log In'), - ); - expect(button.style?.backgroundColor?.resolve({}), Colors.blue); - expect(button.style?.foregroundColor?.resolve({}), Colors.white); - }); + testWidgets('Log In button has blue background and white text', ( + tester, + ) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final button = tester.widget( + find.widgetWithText(ElevatedButton, 'Log In'), + ); + expect(button.style?.backgroundColor?.resolve({}), Colors.blue); + expect(button.style?.foregroundColor?.resolve({}), Colors.white); + }); testWidgets('Create Account text is blue and underlined', (tester) async { await tester.pumpWidget(createWidget()); @@ -500,8 +522,8 @@ void main() { find.widgetWithText(TextField, 'Email'), ); final border = - (field.decoration as InputDecoration).border as OutlineInputBorder; + (field.decoration as InputDecoration).border as OutlineInputBorder; expect(border.borderRadius, BorderRadius.circular(12)); }); }); -} \ No newline at end of file +} diff --git a/test/services/supabase/authentication/authenticator_test.dart b/test/services/supabase/authentication/authenticator_test.dart index 772642d..83125d3 100644 --- a/test/services/supabase/authentication/authenticator_test.dart +++ b/test/services/supabase/authentication/authenticator_test.dart @@ -6,7 +6,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:mocktail/mocktail.dart'; import 'mocks.dart'; -void main(){ +void main() { late SupabaseAuthService authenticator; late SupabaseMock client; late GoTrueMock supabaseAuth; @@ -19,8 +19,7 @@ void main(){ }); group("authentication Tests", () { - - setUp((){ + setUp(() { client = SupabaseMock(); supabaseAuth = GoTrueMock(); when(() => client.auth).thenReturn(supabaseAuth); @@ -43,55 +42,26 @@ void main(){ ); when(() => supabaseAuth.currentUser).thenReturn(mockUser); - when(() => supabaseAuth.refreshSession()).thenAnswer((_) async => AuthResponse()); + when( + () => supabaseAuth.refreshSession(), + ).thenAnswer((_) async => AuthResponse()); when(() => supabaseAuth.signOut()).thenAnswer((_) async {}); - + when( + () => supabaseAuth.resend( + type: OtpType.signup, + email: any(named: 'email'), + ), + ).thenAnswer((_) async => ResendResponse()); }); - test("Tests if login is successful",()async{ - - when(() => supabaseAuth.signInWithPassword( - email: any(named: 'email'), - password: any(named: 'password'), - )).thenAnswer((_) async => AuthResponse( - user: const User( - id: '11111111-1111-1111-1111-111111111111', - aud: 'authenticated', - role: 'authenticated', - email: 'example@email.com', - emailConfirmedAt: '2024-01-01T00:00:00Z', - phone: '', - lastSignInAt: '2024-01-01T00:00:00Z', - appMetadata: { - 'provider': 'email', - 'providers': ['email'] - }, - userMetadata: {}, - identities: [ - UserIdentity( - identityId: '22222222-2222-2222-2222-222222222222', - id: '11111111-1111-1111-1111-111111111111', - userId: '11111111-1111-1111-1111-111111111111', - identityData: { - 'email': 'example@email.com', - 'email_verified': false, - 'phone_verified': false, - 'sub': '11111111-1111-1111-1111-111111111111' - }, - provider: 'email', - lastSignInAt: '2024-01-01T00:00:00Z', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - ), - ], - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', + test("Tests if login is successful", () async { + when( + () => supabaseAuth.signInWithPassword( + email: any(named: 'email'), + password: any(named: 'password'), ), - session: Session( - accessToken: '', - tokenType: 'bearer', - expiresIn: 3600, - refreshToken: '', + ).thenAnswer( + (_) async => AuthResponse( user: const User( id: '11111111-1111-1111-1111-111111111111', aud: 'authenticated', @@ -102,7 +72,7 @@ void main(){ lastSignInAt: '2024-01-01T00:00:00Z', appMetadata: { 'provider': 'email', - 'providers': ['email'] + 'providers': ['email'], }, userMetadata: {}, identities: [ @@ -114,35 +84,114 @@ void main(){ 'email': 'example@email.com', 'email_verified': false, 'phone_verified': false, - 'sub': '11111111-1111-1111-1111-111111111111' + 'sub': '11111111-1111-1111-1111-111111111111', }, provider: 'email', lastSignInAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', - ) + ), ], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', ), + session: Session( + accessToken: '', + tokenType: 'bearer', + expiresIn: 3600, + refreshToken: '', + user: const User( + id: '11111111-1111-1111-1111-111111111111', + aud: 'authenticated', + role: 'authenticated', + email: 'example@email.com', + emailConfirmedAt: '2024-01-01T00:00:00Z', + phone: '', + lastSignInAt: '2024-01-01T00:00:00Z', + appMetadata: { + 'provider': 'email', + 'providers': ['email'], + }, + userMetadata: {}, + identities: [ + UserIdentity( + identityId: '22222222-2222-2222-2222-222222222222', + id: '11111111-1111-1111-1111-111111111111', + userId: '11111111-1111-1111-1111-111111111111', + identityData: { + 'email': 'example@email.com', + 'email_verified': false, + 'phone_verified': false, + 'sub': '11111111-1111-1111-1111-111111111111', + }, + provider: 'email', + lastSignInAt: '2024-01-01T00:00:00Z', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ), + ], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ), + ), ), - ), ); - final response = await authenticator.login("testemail@test.com","testpassword"); + final response = await authenticator.login( + "testemail@test.com", + "testpassword", + ); expect(response, AuthenticationResponses.success); }); - test("Sign up test",() async{ - - when(() => supabaseAuth.signUp( + test("Sign up test", () async { + when( + () => supabaseAuth.signUp( email: any(named: 'email'), password: any(named: 'password'), data: {"full_name": "testname"}, - emailRedirectTo: 'clean-stream://email-verification' - )).thenAnswer((_) async => - AuthResponse( + ), + ).thenAnswer( + (_) async => AuthResponse( + user: const User( + id: '11111111-1111-1111-1111-111111111111', + aud: 'authenticated', + role: 'authenticated', + email: 'example@email.com', + emailConfirmedAt: '2024-01-01T00:00:00Z', + phone: '', + lastSignInAt: '2024-01-01T00:00:00Z', + appMetadata: { + 'provider': 'email', + 'providers': ['email'], + }, + userMetadata: {}, + identities: [ + UserIdentity( + identityId: '22222222-2222-2222-2222-222222222222', + id: '11111111-1111-1111-1111-111111111111', + userId: '11111111-1111-1111-1111-111111111111', + identityData: { + 'email': 'example@email.com', + 'email_verified': false, + 'phone_verified': false, + 'sub': '11111111-1111-1111-1111-111111111111', + }, + provider: 'email', + lastSignInAt: '2024-01-01T00:00:00Z', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ), + ], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ), + session: Session( + accessToken: '', + tokenType: 'bearer', + expiresIn: 3600, + refreshToken: '', user: const User( id: '11111111-1111-1111-1111-111111111111', aud: 'authenticated', @@ -153,7 +202,7 @@ void main(){ lastSignInAt: '2024-01-01T00:00:00Z', appMetadata: { 'provider': 'email', - 'providers': ['email'] + 'providers': ['email'], }, userMetadata: {}, identities: [ @@ -165,7 +214,7 @@ void main(){ 'email': 'example@email.com', 'email_verified': false, 'phone_verified': false, - 'sub': '11111111-1111-1111-1111-111111111111' + 'sub': '11111111-1111-1111-1111-111111111111', }, provider: 'email', lastSignInAt: '2024-01-01T00:00:00Z', @@ -176,61 +225,65 @@ void main(){ createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', ), - session: Session( - accessToken: '', - tokenType: 'bearer', - expiresIn: 3600, - refreshToken: '', - user: const User( - id: '11111111-1111-1111-1111-111111111111', - aud: 'authenticated', - role: 'authenticated', - email: 'example@email.com', - emailConfirmedAt: '2024-01-01T00:00:00Z', - phone: '', - lastSignInAt: '2024-01-01T00:00:00Z', - appMetadata: { - 'provider': 'email', - 'providers': ['email'] - }, - userMetadata: {}, - identities: [ - UserIdentity( - identityId: '22222222-2222-2222-2222-222222222222', - id: '11111111-1111-1111-1111-111111111111', - userId: '11111111-1111-1111-1111-111111111111', - identityData: { - 'email': 'example@email.com', - 'email_verified': false, - 'phone_verified': false, - 'sub': '11111111-1111-1111-1111-111111111111' - }, - provider: 'email', - lastSignInAt: '2024-01-01T00:00:00Z', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - ) - ], - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - ), - ), ), + ), ); - final response = await authenticator.signUp("testemail", "testpassword123G@", "testname"); + final response = await authenticator.signUp( + "testemail", + "testpassword123G@", + "testname", + ); - expect(response,AuthenticationResponses.success); + expect(response, AuthenticationResponses.success); }); - test("Sign up test has no digit",() async{ - - when(() => supabaseAuth.signUp( + test("Sign up test has no digit", () async { + when( + () => supabaseAuth.signUp( email: any(named: 'email'), password: any(named: 'password'), - emailRedirectTo: 'clean-stream://email-verification' - )).thenAnswer((_) async => - AuthResponse( + ), + ).thenAnswer( + (_) async => AuthResponse( + user: const User( + id: '11111111-1111-1111-1111-111111111111', + aud: 'authenticated', + role: 'authenticated', + email: 'example@email.com', + emailConfirmedAt: '2024-01-01T00:00:00Z', + phone: '', + lastSignInAt: '2024-01-01T00:00:00Z', + appMetadata: { + 'provider': 'email', + 'providers': ['email'], + }, + userMetadata: {}, + identities: [ + UserIdentity( + identityId: '22222222-2222-2222-2222-222222222222', + id: '11111111-1111-1111-1111-111111111111', + userId: '11111111-1111-1111-1111-111111111111', + identityData: { + 'email': 'example@email.com', + 'email_verified': false, + 'phone_verified': false, + 'sub': '11111111-1111-1111-1111-111111111111', + }, + provider: 'email', + lastSignInAt: '2024-01-01T00:00:00Z', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ), + ], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ), + session: Session( + accessToken: '', + tokenType: 'bearer', + expiresIn: 3600, + refreshToken: '', user: const User( id: '11111111-1111-1111-1111-111111111111', aud: 'authenticated', @@ -241,7 +294,7 @@ void main(){ lastSignInAt: '2024-01-01T00:00:00Z', appMetadata: { 'provider': 'email', - 'providers': ['email'] + 'providers': ['email'], }, userMetadata: {}, identities: [ @@ -253,7 +306,7 @@ void main(){ 'email': 'example@email.com', 'email_verified': false, 'phone_verified': false, - 'sub': '11111111-1111-1111-1111-111111111111' + 'sub': '11111111-1111-1111-1111-111111111111', }, provider: 'email', lastSignInAt: '2024-01-01T00:00:00Z', @@ -264,61 +317,65 @@ void main(){ createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', ), - session: Session( - accessToken: '', - tokenType: 'bearer', - expiresIn: 3600, - refreshToken: '', - user: const User( - id: '11111111-1111-1111-1111-111111111111', - aud: 'authenticated', - role: 'authenticated', - email: 'example@email.com', - emailConfirmedAt: '2024-01-01T00:00:00Z', - phone: '', - lastSignInAt: '2024-01-01T00:00:00Z', - appMetadata: { - 'provider': 'email', - 'providers': ['email'] - }, - userMetadata: {}, - identities: [ - UserIdentity( - identityId: '22222222-2222-2222-2222-222222222222', - id: '11111111-1111-1111-1111-111111111111', - userId: '11111111-1111-1111-1111-111111111111', - identityData: { - 'email': 'example@email.com', - 'email_verified': false, - 'phone_verified': false, - 'sub': '11111111-1111-1111-1111-111111111111' - }, - provider: 'email', - lastSignInAt: '2024-01-01T00:00:00Z', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - ) - ], - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - ), - ), ), + ), ); - final response = await authenticator.signUp("testemail", "testpasswordG", "testname"); + final response = await authenticator.signUp( + "testemail", + "testpasswordG", + "testname", + ); - expect(response,AuthenticationResponses.noDigit); + expect(response, AuthenticationResponses.noDigit); }); - test("Sign up test no special character",() async{ - - when(() => supabaseAuth.signUp( + test("Sign up test no special character", () async { + when( + () => supabaseAuth.signUp( email: any(named: 'email'), password: any(named: 'password'), - emailRedirectTo: 'clean-stream://email-verification' - )).thenAnswer((_) async => - AuthResponse( + ), + ).thenAnswer( + (_) async => AuthResponse( + user: const User( + id: '11111111-1111-1111-1111-111111111111', + aud: 'authenticated', + role: 'authenticated', + email: 'example@email.com', + emailConfirmedAt: '2024-01-01T00:00:00Z', + phone: '', + lastSignInAt: '2024-01-01T00:00:00Z', + appMetadata: { + 'provider': 'email', + 'providers': ['email'], + }, + userMetadata: {}, + identities: [ + UserIdentity( + identityId: '22222222-2222-2222-2222-222222222222', + id: '11111111-1111-1111-1111-111111111111', + userId: '11111111-1111-1111-1111-111111111111', + identityData: { + 'email': 'example@email.com', + 'email_verified': false, + 'phone_verified': false, + 'sub': '11111111-1111-1111-1111-111111111111', + }, + provider: 'email', + lastSignInAt: '2024-01-01T00:00:00Z', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ), + ], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ), + session: Session( + accessToken: '', + tokenType: 'bearer', + expiresIn: 3600, + refreshToken: '', user: const User( id: '11111111-1111-1111-1111-111111111111', aud: 'authenticated', @@ -329,7 +386,7 @@ void main(){ lastSignInAt: '2024-01-01T00:00:00Z', appMetadata: { 'provider': 'email', - 'providers': ['email'] + 'providers': ['email'], }, userMetadata: {}, identities: [ @@ -341,7 +398,7 @@ void main(){ 'email': 'example@email.com', 'email_verified': false, 'phone_verified': false, - 'sub': '11111111-1111-1111-1111-111111111111' + 'sub': '11111111-1111-1111-1111-111111111111', }, provider: 'email', lastSignInAt: '2024-01-01T00:00:00Z', @@ -352,61 +409,65 @@ void main(){ createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', ), - session: Session( - accessToken: '', - tokenType: 'bearer', - expiresIn: 3600, - refreshToken: '', - user: const User( - id: '11111111-1111-1111-1111-111111111111', - aud: 'authenticated', - role: 'authenticated', - email: 'example@email.com', - emailConfirmedAt: '2024-01-01T00:00:00Z', - phone: '', - lastSignInAt: '2024-01-01T00:00:00Z', - appMetadata: { - 'provider': 'email', - 'providers': ['email'] - }, - userMetadata: {}, - identities: [ - UserIdentity( - identityId: '22222222-2222-2222-2222-222222222222', - id: '11111111-1111-1111-1111-111111111111', - userId: '11111111-1111-1111-1111-111111111111', - identityData: { - 'email': 'example@email.com', - 'email_verified': false, - 'phone_verified': false, - 'sub': '11111111-1111-1111-1111-111111111111' - }, - provider: 'email', - lastSignInAt: '2024-01-01T00:00:00Z', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - ) - ], - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - ), - ), ), + ), ); - final response = await authenticator.signUp("testemail", "testpassword123G", "testname"); + final response = await authenticator.signUp( + "testemail", + "testpassword123G", + "testname", + ); - expect(response,AuthenticationResponses.noSpecialCharacter); + expect(response, AuthenticationResponses.noSpecialCharacter); }); - test("Sign up test no upper case",() async{ - - when(() => supabaseAuth.signUp( + test("Sign up test no upper case", () async { + when( + () => supabaseAuth.signUp( email: any(named: 'email'), password: any(named: 'password'), - emailRedirectTo: 'clean-stream://email-verification' - )).thenAnswer((_) async => - AuthResponse( + ), + ).thenAnswer( + (_) async => AuthResponse( + user: const User( + id: '11111111-1111-1111-1111-111111111111', + aud: 'authenticated', + role: 'authenticated', + email: 'example@email.com', + emailConfirmedAt: '2024-01-01T00:00:00Z', + phone: '', + lastSignInAt: '2024-01-01T00:00:00Z', + appMetadata: { + 'provider': 'email', + 'providers': ['email'], + }, + userMetadata: {}, + identities: [ + UserIdentity( + identityId: '22222222-2222-2222-2222-222222222222', + id: '11111111-1111-1111-1111-111111111111', + userId: '11111111-1111-1111-1111-111111111111', + identityData: { + 'email': 'example@email.com', + 'email_verified': false, + 'phone_verified': false, + 'sub': '11111111-1111-1111-1111-111111111111', + }, + provider: 'email', + lastSignInAt: '2024-01-01T00:00:00Z', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ), + ], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ), + session: Session( + accessToken: '', + tokenType: 'bearer', + expiresIn: 3600, + refreshToken: '', user: const User( id: '11111111-1111-1111-1111-111111111111', aud: 'authenticated', @@ -417,7 +478,7 @@ void main(){ lastSignInAt: '2024-01-01T00:00:00Z', appMetadata: { 'provider': 'email', - 'providers': ['email'] + 'providers': ['email'], }, userMetadata: {}, identities: [ @@ -429,7 +490,7 @@ void main(){ 'email': 'example@email.com', 'email_verified': false, 'phone_verified': false, - 'sub': '11111111-1111-1111-1111-111111111111' + 'sub': '11111111-1111-1111-1111-111111111111', }, provider: 'email', lastSignInAt: '2024-01-01T00:00:00Z', @@ -440,61 +501,65 @@ void main(){ createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', ), - session: Session( - accessToken: '', - tokenType: 'bearer', - expiresIn: 3600, - refreshToken: '', - user: const User( - id: '11111111-1111-1111-1111-111111111111', - aud: 'authenticated', - role: 'authenticated', - email: 'example@email.com', - emailConfirmedAt: '2024-01-01T00:00:00Z', - phone: '', - lastSignInAt: '2024-01-01T00:00:00Z', - appMetadata: { - 'provider': 'email', - 'providers': ['email'] - }, - userMetadata: {}, - identities: [ - UserIdentity( - identityId: '22222222-2222-2222-2222-222222222222', - id: '11111111-1111-1111-1111-111111111111', - userId: '11111111-1111-1111-1111-111111111111', - identityData: { - 'email': 'example@email.com', - 'email_verified': false, - 'phone_verified': false, - 'sub': '11111111-1111-1111-1111-111111111111' - }, - provider: 'email', - lastSignInAt: '2024-01-01T00:00:00Z', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - ) - ], - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - ), - ), ), + ), ); - final response = await authenticator.signUp("testemail", "testpassword123@", "testname"); + final response = await authenticator.signUp( + "testemail", + "testpassword123@", + "testname", + ); - expect(response,AuthenticationResponses.noUppercase); + expect(response, AuthenticationResponses.noUppercase); }); - test("Sign up test no digit",() async{ - - when(() => supabaseAuth.signUp( + test("Sign up test no digit", () async { + when( + () => supabaseAuth.signUp( email: any(named: 'email'), password: any(named: 'password'), - emailRedirectTo: 'clean-stream://email-verification' - )).thenAnswer((_) async => - AuthResponse( + ), + ).thenAnswer( + (_) async => AuthResponse( + user: const User( + id: '11111111-1111-1111-1111-111111111111', + aud: 'authenticated', + role: 'authenticated', + email: 'example@email.com', + emailConfirmedAt: '2024-01-01T00:00:00Z', + phone: '', + lastSignInAt: '2024-01-01T00:00:00Z', + appMetadata: { + 'provider': 'email', + 'providers': ['email'], + }, + userMetadata: {}, + identities: [ + UserIdentity( + identityId: '22222222-2222-2222-2222-222222222222', + id: '11111111-1111-1111-1111-111111111111', + userId: '11111111-1111-1111-1111-111111111111', + identityData: { + 'email': 'example@email.com', + 'email_verified': false, + 'phone_verified': false, + 'sub': '11111111-1111-1111-1111-111111111111', + }, + provider: 'email', + lastSignInAt: '2024-01-01T00:00:00Z', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ), + ], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + ), + session: Session( + accessToken: '', + tokenType: 'bearer', + expiresIn: 3600, + refreshToken: '', user: const User( id: '11111111-1111-1111-1111-111111111111', aud: 'authenticated', @@ -505,7 +570,7 @@ void main(){ lastSignInAt: '2024-01-01T00:00:00Z', appMetadata: { 'provider': 'email', - 'providers': ['email'] + 'providers': ['email'], }, userMetadata: {}, identities: [ @@ -517,7 +582,7 @@ void main(){ 'email': 'example@email.com', 'email_verified': false, 'phone_verified': false, - 'sub': '11111111-1111-1111-1111-111111111111' + 'sub': '11111111-1111-1111-1111-111111111111', }, provider: 'email', lastSignInAt: '2024-01-01T00:00:00Z', @@ -528,172 +593,164 @@ void main(){ createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', ), - session: Session( - accessToken: '', - tokenType: 'bearer', - expiresIn: 3600, - refreshToken: '', - user: const User( - id: '11111111-1111-1111-1111-111111111111', - aud: 'authenticated', - role: 'authenticated', - email: 'example@email.com', - emailConfirmedAt: '2024-01-01T00:00:00Z', - phone: '', - lastSignInAt: '2024-01-01T00:00:00Z', - appMetadata: { - 'provider': 'email', - 'providers': ['email'] - }, - userMetadata: {}, - identities: [ - UserIdentity( - identityId: '22222222-2222-2222-2222-222222222222', - id: '11111111-1111-1111-1111-111111111111', - userId: '11111111-1111-1111-1111-111111111111', - identityData: { - 'email': 'example@email.com', - 'email_verified': false, - 'phone_verified': false, - 'sub': '11111111-1111-1111-1111-111111111111' - }, - provider: 'email', - lastSignInAt: '2024-01-01T00:00:00Z', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - ) - ], - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - ), - ), ), + ), ); - final response = await authenticator.signUp("testemail", "test", "testname"); + final response = await authenticator.signUp( + "testemail", + "test", + "testname", + ); - expect(response,AuthenticationResponses.lessThanMinLength); + expect(response, AuthenticationResponses.lessThanMinLength); }); - test("Tests if login is unsuccessful because of invalid credentials", () async { - when(() => supabaseAuth.signInWithPassword( - email: any(named: 'email'), - password: any(named: 'password'), - )).thenAnswer((_) async => AuthResponse( - user: null, - session: null, - )); - - final response = await authenticator.login("testemail", "testpassword"); - - expect(response, AuthenticationResponses.failure); - }); + test( + "Tests if login is unsuccessful because of invalid credentials", + () async { + when( + () => supabaseAuth.signInWithPassword( + email: any(named: 'email'), + password: any(named: 'password'), + ), + ).thenAnswer((_) async => AuthResponse(user: null, session: null)); + final response = await authenticator.login("testemail", "testpassword"); - test("Tests if login is unsuccessful because email is not confirmed",()async{ + expect(response, AuthenticationResponses.failure); + }, + ); - when(() => - supabaseAuth.signInWithPassword( + test( + "Tests if login is unsuccessful because email is not confirmed", + () async { + when( + () => supabaseAuth.signInWithPassword( email: any(named: 'email'), password: any(named: 'password'), - )).thenThrow(AuthApiException("Email Not Confirmed",code:'email_not_confirmed',statusCode:"400")); - - final response = await authenticator.login("testemail", "testpassword"); + ), + ).thenThrow( + AuthApiException( + "Email Not Confirmed", + code: 'email_not_confirmed', + statusCode: "400", + ), + ); - expect(response,AuthenticationResponses.emailNotVerified); - }); + final response = await authenticator.login("testemail", "testpassword"); - test("Resend verification email unsuccessfully",() async{ + expect(response, AuthenticationResponses.emailNotVerified); + }, + ); - when(() => supabaseAuth.resend( + test("Resend verification email unsuccessfully", () async { + when( + () => supabaseAuth.resend( type: OtpType.signup, - email: any(named:"email") - )).thenThrow(AuthException("Invalid email")); + email: any(named: "email"), + ), + ).thenThrow(AuthException("Invalid email")); final response = await authenticator.resendVerification(); - expect(response,AuthenticationResponses.failure); + expect(response, AuthenticationResponses.failure); }); - test("Resend verification email succesfully",() async{ - - when(() => supabaseAuth.resend( + test("Resend verification email succesfully", () async { + when( + () => supabaseAuth.resend( type: OtpType.signup, - email: any(named:"email") - )).thenAnswer((_) async => ResendResponse()); + email: any(named: "email"), + ), + ).thenAnswer((_) async => ResendResponse()); final response = await authenticator.resendVerification(); - expect(response,AuthenticationResponses.success); + expect(response, AuthenticationResponses.success); }); - test("User is logged in",() async{ - - when(() => supabaseAuth.currentSession).thenReturn(Session(accessToken: 'test', tokenType: 'test', user: User(id: '', appMetadata: {}, userMetadata: {}, aud: '', createdAt: ''))); + test("User is logged in", () async { + when(() => supabaseAuth.currentSession).thenReturn( + Session( + accessToken: 'test', + tokenType: 'test', + user: User( + id: '', + appMetadata: {}, + userMetadata: {}, + aud: '', + createdAt: '', + ), + ), + ); final response = await authenticator.isLoggedIn(); - expect(response,AuthenticationResponses.success); + expect(response, AuthenticationResponses.success); }); - test("User is not logged in",() async{ + test("User is not logged in", () async { when(() => supabaseAuth.currentUser).thenReturn(null); final response = await authenticator.isLoggedIn(); - expect(response,AuthenticationResponses.failure); + expect(response, AuthenticationResponses.failure); }); - test("Verifying that the logged out logic was called",() async { + test("Verifying that the logged out logic was called", () async { await authenticator.logout(); verify(() => client.auth.signOut()); }); - test("Should return that an email is not verified",() async{ - when(() => supabaseAuth.signInWithPassword( - email: any(named: 'email'), - password: any(named: 'password'), - )).thenThrow(AuthApiException( - 'Email not verified', - code: 'email_not_confirmed', - )); + test("Should return that an email is not verified", () async { + when( + () => supabaseAuth.signInWithPassword( + email: any(named: 'email'), + password: any(named: 'password'), + ), + ).thenThrow( + AuthApiException('Email not verified', code: 'email_not_confirmed'), + ); - final result = await authenticator.login("testEmail","testPassword"); + final result = await authenticator.login("testEmail", "testPassword"); expect(result, AuthenticationResponses.emailNotVerified); }); - test("Test if an exception is thrown with a different code",() async{ - when(() => supabaseAuth.signInWithPassword( - email: any(named: 'email'), - password: any(named: 'password'), - )).thenThrow(AuthApiException( - 'Email not verified', - code: 'random-test-code', - )); + test("Test if an exception is thrown with a different code", () async { + when( + () => supabaseAuth.signInWithPassword( + email: any(named: 'email'), + password: any(named: 'password'), + ), + ).thenThrow( + AuthApiException('Email not verified', code: 'random-test-code'), + ); - final result = await authenticator.login("testEmail","testPassword"); + final result = await authenticator.login("testEmail", "testPassword"); expect(result, AuthenticationResponses.failure); }); - test("Test if an exception is thrown with an unkown exception",() async{ - when(() => supabaseAuth.signInWithPassword( - email: any(named: 'email'), - password: any(named: 'password'), - )).thenThrow(Exception("Unknown exception")); + test("Test if an exception is thrown with an unkown exception", () async { + when( + () => supabaseAuth.signInWithPassword( + email: any(named: 'email'), + password: any(named: 'password'), + ), + ).thenThrow(Exception("Unknown exception")); - final result = await authenticator.login("testEmail","testPassword"); + final result = await authenticator.login("testEmail", "testPassword"); expect(result, AuthenticationResponses.failure); }); - test("Test that correctID is returned",(){ + test("Test that correctID is returned", () { final result = authenticator.getCurrentUserId; expect(result, "11111111-1111-1111-1111-111111111111"); }); - test("Test that null is returned for no user being able to be found",(){ + test("Test that null is returned for no user being able to be found", () { when(() => supabaseAuth.currentUser).thenReturn(null); final result = authenticator.getCurrentUserId; expect(result, null); }); - test("Tests if the email is verified",(){ - + test("Tests if the email is verified", () { final testUser = User( id: '11111111-1111-1111-1111-111111111111', aud: 'authenticated', @@ -704,7 +761,7 @@ void main(){ lastSignInAt: '2024-01-01T00:00:00Z', appMetadata: { 'provider': 'email', - 'providers': ['email'] + 'providers': ['email'], }, userMetadata: {}, identities: [ @@ -716,7 +773,7 @@ void main(){ 'email': 'example@email.com', 'email_verified': false, 'phone_verified': false, - 'sub': '11111111-1111-1111-1111-111111111111' + 'sub': '11111111-1111-1111-1111-111111111111', }, provider: 'email', lastSignInAt: '2024-01-01T00:00:00Z', @@ -728,14 +785,12 @@ void main(){ updatedAt: '2024-01-01T00:00:00Z', ); - when(() => supabaseAuth.currentUser).thenReturn(testUser); var result = authenticator.isEmailVerified(); expect(result, true); }); - test("Tests if the email is not verified",(){ - + test("Tests if the email is not verified", () { final testUser = User( id: '11111111-1111-1111-1111-111111111111', aud: 'authenticated', @@ -746,7 +801,7 @@ void main(){ lastSignInAt: '2024-01-01T00:00:00Z', appMetadata: { 'provider': 'email', - 'providers': ['email'] + 'providers': ['email'], }, userMetadata: {}, identities: [ @@ -758,7 +813,7 @@ void main(){ 'email': 'example@email.com', 'email_verified': false, 'phone_verified': false, - 'sub': '11111111-1111-1111-1111-111111111111' + 'sub': '11111111-1111-1111-1111-111111111111', }, provider: 'email', lastSignInAt: '2024-01-01T00:00:00Z', @@ -770,7 +825,6 @@ void main(){ updatedAt: '2024-01-01T00:00:00Z', ); - when(() => supabaseAuth.currentUser).thenReturn(testUser); var result = authenticator.isEmailVerified(); expect(result, false); @@ -781,17 +835,20 @@ void main(){ final controller = StreamController(); - when(() => supabaseAuth.onAuthStateChange) - .thenAnswer((_) => Stream.value(AuthState( - AuthChangeEvent.signedIn, - Session( - accessToken: '', - tokenType: 'bearer', - expiresIn: 3600, - refreshToken: '', - user: supabaseAuth.currentUser!, + when(() => supabaseAuth.onAuthStateChange).thenAnswer( + (_) => Stream.value( + AuthState( + AuthChangeEvent.signedIn, + Session( + accessToken: '', + tokenType: 'bearer', + expiresIn: 3600, + refreshToken: '', + user: supabaseAuth.currentUser!, + ), + ), ), - ))); + ); when(() => auth.onAuthStateChange).thenAnswer((_) => controller.stream); @@ -815,74 +872,91 @@ void main(){ expiresIn: 3600, ); - expectLater(authenticator.onAuthChange, emits(true)); controller.add(AuthState(AuthChangeEvent.signedIn, fakeSession)); }); test("onAuthChange emits false when a user doesn't exist", () async { - final controller = StreamController(); - when(() => supabaseAuth.onAuthStateChange).thenAnswer((_) => controller.stream); + when( + () => supabaseAuth.onAuthStateChange, + ).thenAnswer((_) => controller.stream); expectLater(authenticator.onAuthChange, emits(false)); controller.add(AuthState(AuthChangeEvent.signedOut, null)); - }); test("googleSignIn calls signInWithOAuth", () async { - when(() => client.auth.signInWithOAuth( - any(), - redirectTo: any(named: 'redirectTo'), - )).thenAnswer((_) async => true); + when( + () => client.auth.signInWithOAuth( + any(), + redirectTo: any(named: 'redirectTo'), + ), + ).thenAnswer((_) async => true); await authenticator.googleSignIn(); - verify(() => client.auth.signInWithOAuth( - any(), - redirectTo: any(named: 'redirectTo'), - )).called(1); + verify( + () => client.auth.signInWithOAuth( + any(), + redirectTo: any(named: 'redirectTo'), + ), + ).called(1); }); - test("Tests that all errors are properly handled",() async{ - when(() => client.auth.signInWithOAuth(any())).thenThrow(Exception("Test Error")); + test("Tests that all errors are properly handled", () async { + when( + () => client.auth.signInWithOAuth(any()), + ).thenThrow(Exception("Test Error")); await authenticator.googleSignIn(); //If test reaches here it passed because nothing failed }); test("appleSignIn calls signInWithOAuth", () async { - when(() => client.auth.signInWithOAuth( - any(), - redirectTo: any(named: 'redirectTo'), - )).thenAnswer((_) async => true); - - await authenticator.appleSignIn(); - - verify(() => client.auth.signInWithOAuth( - any(), - redirectTo: any(named: 'redirectTo'), - )).called(1); - }); + when( + () => client.auth.signInWithOAuth( + any(), + redirectTo: any(named: 'redirectTo'), + ), + ).thenAnswer((_) async => true); - test("Tests that all errors are properly handled with apple sign in",() async{ - when(() => client.auth.signInWithOAuth(any())).thenThrow(Exception("Test Error")); await authenticator.appleSignIn(); - //If test reaches here it passed because nothing failed - }); - test("Tests that all errors are properly handled with google sign in",() async{ - when(() => client.auth.signInWithOAuth(any())).thenThrow(Exception("Test Error")); - await authenticator.googleSignIn(); - //If test reaches here it passed because nothing failed + verify( + () => client.auth.signInWithOAuth( + any(), + redirectTo: any(named: 'redirectTo'), + ), + ).called(1); }); + test( + "Tests that all errors are properly handled with apple sign in", + () async { + when( + () => client.auth.signInWithOAuth(any()), + ).thenThrow(Exception("Test Error")); + await authenticator.appleSignIn(); + //If test reaches here it passed because nothing failed + }, + ); + + test( + "Tests that all errors are properly handled with google sign in", + () async { + when( + () => client.auth.signInWithOAuth(any()), + ).thenThrow(Exception("Test Error")); + await authenticator.googleSignIn(); + //If test reaches here it passed because nothing failed + }, + ); test("Tests to see that the redirect was called", () async { - when(() => supabaseAuth.getSessionFromUrl(any())).thenAnswer( - (_) async => AuthSessionUrlResponse( + (_) async => AuthSessionUrlResponse( session: Session( accessToken: "test_token", tokenType: "bearer", @@ -904,137 +978,200 @@ void main(){ verify(() => supabaseAuth.getSessionFromUrl(testUri)).called(1); }); - test("Tests that the user was grabbed correctly",() async{ + test("Tests that the user was grabbed correctly", () async { await authenticator.getCurrentUser(); verify(() => client.auth.currentUser); }); - test("Tests that the correct userID is gotten",() async{ + test("Tests that the correct userID is gotten", () async { String? userID = await authenticator.getCurrentUserId; expect(userID, '11111111-1111-1111-1111-111111111111'); }); - test("Tests that as session is returned",() async{ - - when(() => supabaseAuth.getSessionFromUrl(any())).thenAnswer( (_) async => AuthSessionUrlResponse(session: Session(accessToken: "test", tokenType: "test", user: User(id: "1234", appMetadata: {}, userMetadata: {}, aud: "test", createdAt: "test")), redirectType: "test")); - + test("Tests that as session is returned", () async { + when(() => supabaseAuth.getSessionFromUrl(any())).thenAnswer( + (_) async => AuthSessionUrlResponse( + session: Session( + accessToken: "test", + tokenType: "test", + user: User( + id: "1234", + appMetadata: {}, + userMetadata: {}, + aud: "test", + createdAt: "test", + ), + ), + redirectType: "test", + ), + ); + await authenticator.getSessionFromURI(Uri()); verify(() => client.auth.getSessionFromUrl(any())); - }); - test("Verifies that a session was refreshed if it's not null",() async { - - when(() => supabaseAuth.currentSession).thenReturn(Session(accessToken: "test", tokenType: "test", user: User(id: '', appMetadata: {}, userMetadata: {}, aud: '', createdAt: ''))); + test("Verifies that a session was refreshed if it's not null", () async { + when(() => supabaseAuth.currentSession).thenReturn( + Session( + accessToken: "test", + tokenType: "test", + user: User( + id: '', + appMetadata: {}, + userMetadata: {}, + aud: '', + createdAt: '', + ), + ), + ); await authenticator.refreshSession(); verify(() => supabaseAuth.refreshSession()); }); - test("Verifies that a session was not refreshed if the session is null",() async { - when(() => supabaseAuth.currentSession).thenReturn(null); + test( + "Verifies that a session was not refreshed if the session is null", + () async { + when(() => supabaseAuth.currentSession).thenReturn(null); - await authenticator.refreshSession(); + await authenticator.refreshSession(); - verifyNever(() => supabaseAuth.refreshSession()); - }); + verifyNever(() => supabaseAuth.refreshSession()); + }, + ); - test("getCurrentUserEmail returns correct email",() async { + test("getCurrentUserEmail returns correct email", () async { when(() => supabaseAuth.currentSession).thenReturn(null); String? result = await authenticator.getCurrentUserEmail(); - expect(result,'testemail@test.com'); + expect(result, 'testemail@test.com'); }); - test("Tests that code is verified correctly",() async { - when(() => supabaseAuth.verifyOTP( - email: any(named: 'email'), - token: any(named: 'token'), - type: any(named: 'type'), - )).thenAnswer((_) async => AuthResponse( - session: Session( - accessToken: "test", - tokenType: "test", - user: User( - id: '', - appMetadata: {}, - userMetadata: {}, - aud: '', - createdAt: '', + test("Tests that code is verified correctly", () async { + when( + () => supabaseAuth.verifyOTP( + email: any(named: 'email'), + token: any(named: 'token'), + type: any(named: 'type'), + ), + ).thenAnswer( + (_) async => AuthResponse( + session: Session( + accessToken: "test", + tokenType: "test", + user: User( + id: '', + appMetadata: {}, + userMetadata: {}, + aud: '', + createdAt: '', + ), ), ), - )); + ); - AuthenticationResponses testResponse = await authenticator.verifyCode(email: "test", code: "testCode"); + AuthenticationResponses testResponse = await authenticator.verifyCode( + email: "test", + code: "testCode", + ); expect(testResponse, AuthenticationResponses.success); }); - test("Tests that failure is returned when exception is thrown",() async { - when(() => supabaseAuth.verifyOTP( - email: any(named: 'email'), - token: any(named: 'token'), - type: any(named: 'type'), - )).thenThrow(Exception()); + test("Tests that failure is returned when exception is thrown", () async { + when( + () => supabaseAuth.verifyOTP( + email: any(named: 'email'), + token: any(named: 'token'), + type: any(named: 'type'), + ), + ).thenThrow(Exception()); - AuthenticationResponses testResponse = await authenticator.verifyCode(email: "test", code: "testCode"); + AuthenticationResponses testResponse = await authenticator.verifyCode( + email: "test", + code: "testCode", + ); expect(testResponse, AuthenticationResponses.failure); }); - - test("Tests that exchange code for session runs correctly",() async { - when(() => supabaseAuth.exchangeCodeForSession(any())).thenAnswer((_) async => AuthSessionUrlResponse(session: Session(accessToken: "", tokenType: "", user: User(id: "", appMetadata: {}, userMetadata: {}, aud: "", createdAt: "")), redirectType:"")); - AuthenticationResponses response = await authenticator.exchangeCodeForSession("testCode"); + + test("Tests that exchange code for session runs correctly", () async { + when(() => supabaseAuth.exchangeCodeForSession(any())).thenAnswer( + (_) async => AuthSessionUrlResponse( + session: Session( + accessToken: "", + tokenType: "", + user: User( + id: "", + appMetadata: {}, + userMetadata: {}, + aud: "", + createdAt: "", + ), + ), + redirectType: "", + ), + ); + AuthenticationResponses response = await authenticator + .exchangeCodeForSession("testCode"); expect(response, AuthenticationResponses.success); }); - test("Tests that failure is sent with an exception",() async { - when(() => supabaseAuth.exchangeCodeForSession(any())).thenThrow(Exception()); - AuthenticationResponses response = await authenticator.exchangeCodeForSession("testCode"); + test("Tests that failure is sent with an exception", () async { + when( + () => supabaseAuth.exchangeCodeForSession(any()), + ).thenThrow(Exception()); + AuthenticationResponses response = await authenticator + .exchangeCodeForSession("testCode"); expect(response, AuthenticationResponses.failure); }); - test("Tests that update password runs correctly",() async { - - when(() => supabaseAuth.updateUser(any())).thenAnswer((_) async => UserResponse.fromJson({})); + test("Tests that update password runs correctly", () async { + when( + () => supabaseAuth.updateUser(any()), + ).thenAnswer((_) async => UserResponse.fromJson({})); - AuthenticationResponses response = await authenticator.updatePassword("password"); + AuthenticationResponses response = await authenticator.updatePassword( + "password", + ); expect(response, AuthenticationResponses.success); }); - test("Tests that update password handles errors",() async { - + test("Tests that update password handles errors", () async { when(() => supabaseAuth.updateUser(any())).thenThrow(Exception()); - AuthenticationResponses response = await authenticator.updatePassword("password"); + AuthenticationResponses response = await authenticator.updatePassword( + "password", + ); expect(response, AuthenticationResponses.failure); }); - test("Reset password runs correctly",() async { - - when(() => supabaseAuth.resetPasswordForEmail(any())) - .thenAnswer((_) async {}); + test("Reset password runs correctly", () async { + when( + () => supabaseAuth.resetPasswordForEmail(any()), + ).thenAnswer((_) async {}); - - AuthenticationResponses response = await authenticator.resetPassword("testEmail"); + AuthenticationResponses response = await authenticator.resetPassword( + "testEmail", + ); expect(response, AuthenticationResponses.success); }); - test("Reset password handles errors",() async { - - when(() => supabaseAuth.resetPasswordForEmail(any())) - .thenThrow(Exception()); + test("Reset password handles errors", () async { + when( + () => supabaseAuth.resetPasswordForEmail(any()), + ).thenThrow(Exception()); - - AuthenticationResponses response = await authenticator.resetPassword("testEmail"); + AuthenticationResponses response = await authenticator.resetPassword( + "testEmail", + ); expect(response, AuthenticationResponses.failure); }); - }); -} \ No newline at end of file +} From 526e781216decf9392ac65bc9002eb7c16afb719 Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 18 Apr 2026 09:52:55 -0400 Subject: [PATCH 75/79] Fixes denyRefund tests --- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 116 ++++++++- supabase/functions/approveRefund/index.ts | 33 ++- supabase/functions/approveRefund/logic.ts | 11 +- supabase/functions/denyRefund/index.ts | 7 +- supabase/functions/denyRefund/logic.test.ts | 226 ++++++++---------- supabase/functions/denyRefund/logic.ts | 51 ++-- .../functions/maintenance-request/index.ts | 83 +++++++ .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 12 files changed, 369 insertions(+), 169 deletions(-) create mode 100644 supabase/functions/maintenance-request/index.ts diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 3792af4..e12c657 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 5d07423..4453582 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux gtk url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index e996361..90b66f5 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import app_links +import file_selector_macos import flutter_local_notifications import geolocator_apple import mobile_scanner @@ -16,6 +17,7 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) diff --git a/pubspec.lock b/pubspec.lock index bc6b3aa..b7fb3b6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -129,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.15.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -217,6 +225,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -294,6 +334,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.7" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_stripe: dependency: "direct main" description: @@ -504,6 +552,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.4" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "66810af8e99b2657ee98e5c6f02064f69bb63f7a70e343937f70946c5f8c6622" + url: "https://pub.dev" + source: hosted + version: "0.8.13+16" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: "direct main" description: @@ -1374,5 +1486,5 @@ packages: source: hosted version: "2.1.0" sdks: - dart: ">=3.9.2 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/supabase/functions/approveRefund/index.ts b/supabase/functions/approveRefund/index.ts index 77848ef..47b2653 100644 --- a/supabase/functions/approveRefund/index.ts +++ b/supabase/functions/approveRefund/index.ts @@ -5,19 +5,29 @@ import { processRefund } from "./logic.ts"; const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info", }; serve(async (req) => { if (req.method === "OPTIONS") { - return new Response(null, { status: 200, headers: corsHeaders }); + return new Response(null, { status: 204, headers: corsHeaders }); } +let userId: string, transactionId: string, amount: string, note: string; + try { - const url = new URL(req.url); - const userId = url.searchParams.get("user_id") || ""; - const transactionId = url.searchParams.get("transaction_id") || ""; - const amount = url.searchParams.get("amount") || ""; + const body = await req.json(); + userId = body.customerId || body.user_id; + transactionId = body.id || body.transactionId || body.transaction_id; + amount = body.amount; + note = body.note; + + if (!userId || !transactionId || !amount) { + return new Response( + JSON.stringify({ error: "Missing required parameters" }), + { status: 400, headers: corsHeaders } + ); + } const supabaseUrl = Deno.env.get("SUPABASE_URL"); const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); @@ -35,10 +45,10 @@ serve(async (req) => { }); const deps = { - updateRefund: async (transactionId: string) => { + updateRefund: async (transactionId: string, note: string) => { const { error } = await supabase .from("Refunds") - .update({ status: "approved" }) + .update({ status: "approved", "admin-note": note }) .eq("transaction_id", transactionId); if (error) throw new Error(error.message); @@ -70,7 +80,8 @@ serve(async (req) => { sendEmail: async ( email: string, transactionId: string, - amount: string + amount: string, + note: string ) => { const response = await fetch( "https://api.resend.com/emails", @@ -89,6 +100,7 @@ serve(async (req) => {

Your refund for transaction ${transactionId} was approved.

$${amount} has been added to your loyalty card.

+

${note ? `Note: ${note}` : ""}

`, }), } @@ -101,7 +113,7 @@ serve(async (req) => { }; const result = await processRefund( - { userId, transactionId, amount }, + { userId, transactionId, amount, note }, deps ); @@ -115,6 +127,7 @@ serve(async (req) => { } ); } catch (error) { + console.log(error); return new Response( JSON.stringify({ error: error.message }), { diff --git a/supabase/functions/approveRefund/logic.ts b/supabase/functions/approveRefund/logic.ts index 40de054..cfc6370 100644 --- a/supabase/functions/approveRefund/logic.ts +++ b/supabase/functions/approveRefund/logic.ts @@ -1,27 +1,28 @@ export interface RefundDependencies { - updateRefund: (transactionId: string) => Promise; + updateRefund: (transactionId: string, note: string) => Promise; getUserEmail: (userId: string) => Promise; incrementLoyalty: (userId: string, amount: number) => Promise; - sendEmail: (email: string, transactionId: string, amount: string) => Promise; + sendEmail: (email: string, transactionId: string, amount: string, note: string) => Promise; } export interface RefundParams { userId: string; transactionId: string; amount: string; + note: string; } export async function processRefund( params: RefundParams, deps: RefundDependencies ) { - const { userId, transactionId, amount } = params; + const { userId, transactionId, amount, note } = params; if (!userId || !transactionId || !amount) { throw new Error("Missing params"); } - await deps.updateRefund(transactionId); + await deps.updateRefund(transactionId, note); const email = await deps.getUserEmail(userId); @@ -31,7 +32,7 @@ export interface RefundDependencies { await deps.incrementLoyalty(userId, Number(amount)); - await deps.sendEmail(email, transactionId, amount); + await deps.sendEmail(email, transactionId, amount, note); return { success: true, diff --git a/supabase/functions/denyRefund/index.ts b/supabase/functions/denyRefund/index.ts index d296c0b..47d5c83 100644 --- a/supabase/functions/denyRefund/index.ts +++ b/supabase/functions/denyRefund/index.ts @@ -5,9 +5,10 @@ import { handleDenyRefund, sendDenialEmail } from "./logic.ts"; const CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info", }; + serve(async (req) => { if (req.method === "OPTIONS") { return new Response(null, { status: 200, headers: CORS_HEADERS }); @@ -31,8 +32,8 @@ serve(async (req) => { const response = await handleDenyRefund(req, { supabase, - sendEmail: (to, transactionId, amount) => - sendDenialEmail(resendKey!, to, transactionId, amount), + sendEmail: (to, transactionId, amount, note) => + sendDenialEmail(resendKey!, to, transactionId, amount, note), }); return new Response(response.body, { diff --git a/supabase/functions/denyRefund/logic.test.ts b/supabase/functions/denyRefund/logic.test.ts index 4b2e6c3..bc8f239 100644 --- a/supabase/functions/denyRefund/logic.test.ts +++ b/supabase/functions/denyRefund/logic.test.ts @@ -10,23 +10,24 @@ import { sendDenialEmail } from "./logic.ts"; - function makeUrl(params: Record) { - const url = new URL("http://localhost/deny-refund"); - for (const [k, v] of Object.entries(params)) { - url.searchParams.set(k, v); - } - return url; + function makeRequestWithBody(body: Record) { + return new Request("http://localhost/deny-refund", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); } - - function makeFullUrl(overrides: Partial> = {}) { - return makeUrl({ + + function makeFullRequest(overrides: Partial> = {}) { + return makeRequestWithBody({ user_id: "user-123", transaction_id: "txn-abc", amount: "25.00", + note: "Policy violation", ...overrides, }); } - + function makeSupabaseMock(overrides: { updateError?: { message: string } | null; user?: { id: string; email?: string } | null; @@ -50,10 +51,6 @@ import { }, } as any; } - - function makeRequest(url: URL) { - return new Request(url.toString(), { method: "GET" }); - } function mockFetch(ok: boolean, responseText = "") { globalThis.fetch = () => @@ -66,71 +63,56 @@ import { function restoreFetch() { globalThis.fetch = fetch; } - - Deno.test("extractParams — returns all three params when present", () => { - const url = makeFullUrl(); - const params = extractParams(url); - + + Deno.test("extractParams — returns all four params when present", async () => { + const req = makeFullRequest(); + const params = await extractParams(req); + assertEquals(params.userId, "user-123"); assertEquals(params.transactionId, "txn-abc"); assertEquals(params.amount, "25.00"); + assertEquals(params.note, "Policy violation"); }); - - Deno.test("extractParams — throws when user_id is missing", () => { - const url = makeUrl({ transaction_id: "txn-abc", amount: "25.00" }); - try { - extractParams(url); - } catch (e) { - assertEquals(e instanceof Error, true); - assertEquals((e as Error).message, "Missing params"); - } - }); - - Deno.test("extractParams — throws when transaction_id is missing", () => { - const url = makeUrl({ user_id: "user-123", amount: "25.00" }); - try { - extractParams(url); - } catch (e) { - assertEquals(e instanceof Error, true); - assertEquals((e as Error).message, "Missing params"); - } - }); - - Deno.test("extractParams — throws when amount is missing", () => { - const url = makeUrl({ user_id: "user-123", transaction_id: "txn-abc" }); - try { - extractParams(url); - } catch (e) { - assertEquals(e instanceof Error, true); - assertEquals((e as Error).message, "Missing params"); - } + + + Deno.test("extractParams — allows missing note parameter", async () => { + const req = makeRequestWithBody({ user_id: "user-123", transaction_id: "txn-abc", amount: "25.00" }); + const params = await extractParams(req); + + assertEquals(params.userId, "user-123"); + assertEquals(params.transactionId, "txn-abc"); + assertEquals(params.amount, "25.00"); + assertEquals(params.note, undefined); }); - - Deno.test("extractParams — throws when all params are missing", () => { - const url = new URL("http://localhost/deny-refund"); - try { - extractParams(url); - } catch (e) { - assertEquals(e instanceof Error, true); - assertEquals((e as Error).message, "Missing params"); - } + + Deno.test("extractParams — throws when body is not valid JSON", async () => { + const req = new Request("http://localhost/deny-refund", { + method: "POST", + body: "not valid json", + }); + + await assertRejects( + () => extractParams(req), + Error, + "Invalid JSON body" + ); }); - + Deno.test("denyRefundInDb — resolves without error on success", async () => { const supabase = makeSupabaseMock(); - await denyRefundInDb(supabase, "txn-abc"); + await denyRefundInDb(supabase, "txn-abc", "Policy violation"); }); - + Deno.test("denyRefundInDb — throws with prefixed message on Supabase error", async () => { const supabase = makeSupabaseMock({ updateError: { message: "row not found" } }); - + await assertRejects( - () => denyRefundInDb(supabase, "txn-abc"), + () => denyRefundInDb(supabase, "txn-abc", "Policy violation"), Error, "Refund update error: row not found" ); }); - + Deno.test("denyRefundInDb — passes correct transaction_id to Supabase", async () => { let capturedVal: string | undefined; const supabase = { @@ -143,94 +125,81 @@ import { }), }), } as any; - - await denyRefundInDb(supabase, "txn-xyz"); + + await denyRefundInDb(supabase, "txn-xyz", "Policy violation"); assertEquals(capturedVal, "txn-xyz"); }); - + Deno.test("getUserEmail — returns email on valid user", async () => { const supabase = makeSupabaseMock({ user: { id: "user-123", email: "user@example.com" } }); - + const email = await getUserEmail(supabase, "user-123"); assertEquals(email, "user@example.com"); }); - + Deno.test("getUserEmail — throws when user is null", async () => { const supabase = makeSupabaseMock({ user: null }); - + await assertRejects( () => getUserEmail(supabase, "ghost"), Error, "User not found" ); }); - + Deno.test("getUserEmail — throws when Supabase returns an error", async () => { const supabase = makeSupabaseMock({ userError: { message: "JWT invalid" }, user: null }); - + await assertRejects( () => getUserEmail(supabase, "user-123"), Error, "User not found: JWT invalid" ); }); - + Deno.test("getUserEmail — throws when user has no email", async () => { - const supabase = makeSupabaseMock({ user: { id: "user-123" } }); - + const supabase = makeSupabaseMock({ user: { id: "user-123" } }); + await assertRejects( () => getUserEmail(supabase, "user-123"), Error, "User email not found" ); }); - + Deno.test("handleDenyRefund — returns 200 with confirmation HTML on success", async () => { - const req = makeRequest(makeFullUrl()); - const sendEmail = (_to: string, _txn: string, _amt: string) => Promise.resolve(); - + const req = makeFullRequest(); + const sendEmail = (_to: string, _txn: string, _amt: string, _note: string) => Promise.resolve(); + const res = await handleDenyRefund(req, { supabase: makeSupabaseMock(), sendEmail, }); const body = await res.text(); - + assertEquals(res.status, 200); assertEquals(body.includes("txn-abc"), true); assertEquals(body.includes("25.00"), true); }); - + Deno.test("handleDenyRefund — calls sendEmail with correct args", async () => { - const req = makeRequest(makeFullUrl()); - let capturedArgs: [string, string, string] | undefined; - + const req = makeFullRequest(); + let capturedArgs: [string, string, string, string] | undefined; + await handleDenyRefund(req, { supabase: makeSupabaseMock(), - sendEmail: (to, txn, amt) => { - capturedArgs = [to, txn, amt]; + sendEmail: (to, txn, amt, note) => { + capturedArgs = [to, txn, amt, note]; return Promise.resolve(); }, }); - - assertEquals(capturedArgs, ["user@example.com", "txn-abc", "25.00"]); - }); - - Deno.test("handleDenyRefund — throws Missing params when query params absent", async () => { - const req = new Request("http://localhost/deny-refund", { method: "GET" }); - - await assertRejects( - () => handleDenyRefund(req, { - supabase: makeSupabaseMock(), - sendEmail: () => Promise.resolve(), - }), - Error, - "Missing params" - ); + + assertEquals(capturedArgs, ["user@example.com", "txn-abc", "25.00", "Policy violation"]); }); - + Deno.test("handleDenyRefund — throws when DB update fails", async () => { - const req = makeRequest(makeFullUrl()); - + const req = makeFullRequest(); + await assertRejects( () => handleDenyRefund(req, { supabase: makeSupabaseMock({ updateError: { message: "constraint violation" } }), @@ -240,10 +209,10 @@ import { "Refund update error: constraint violation" ); }); - + Deno.test("handleDenyRefund — throws when user is not found", async () => { - const req = makeRequest(makeFullUrl()); - + const req = makeFullRequest(); + await assertRejects( () => handleDenyRefund(req, { supabase: makeSupabaseMock({ user: null }), @@ -253,10 +222,10 @@ import { "User not found" ); }); - + Deno.test("handleDenyRefund — throws when sendEmail fails", async () => { - const req = makeRequest(makeFullUrl()); - + const req = makeFullRequest(); + await assertRejects( () => handleDenyRefund(req, { supabase: makeSupabaseMock(), @@ -270,17 +239,17 @@ import { Deno.test("sendDenialEmail — resolves without error on success", async () => { mockFetch(true); try { - await sendDenialEmail("test-api-key", "user@example.com", "txn-abc", "25.00"); + await sendDenialEmail("test-api-key", "user@example.com", "txn-abc", "25.00", "Policy violation"); } finally { restoreFetch(); } }); - + Deno.test("sendDenialEmail — throws with error text when response is not ok", async () => { mockFetch(false, "Invalid API key"); try { await assertRejects( - () => sendDenialEmail("bad-key", "user@example.com", "txn-abc", "25.00"), + () => sendDenialEmail("bad-key", "user@example.com", "txn-abc", "25.00", "Policy violation"), Error, "Email send failed: Invalid API key" ); @@ -288,61 +257,62 @@ import { restoreFetch(); } }); - + Deno.test("sendDenialEmail — sends POST to the correct Resend endpoint", async () => { let capturedUrl: string | undefined; let capturedInit: RequestInit | undefined; - + globalThis.fetch = (url: string | URL | Request, init?: RequestInit) => { capturedUrl = url.toString(); capturedInit = init; return Promise.resolve({ ok: true, text: () => Promise.resolve("") } as Response); }; - + try { - await sendDenialEmail("test-key", "user@example.com", "txn-abc", "25.00"); + await sendDenialEmail("test-key", "user@example.com", "txn-abc", "25.00", "Policy violation"); } finally { restoreFetch(); } - + assertEquals(capturedUrl, "https://api.resend.com/emails"); assertEquals(capturedInit?.method, "POST"); }); - + Deno.test("sendDenialEmail — sends correct Authorization header", async () => { let capturedHeaders: Record | undefined; - + globalThis.fetch = (_url: string | URL | Request, init?: RequestInit) => { capturedHeaders = init?.headers as Record; return Promise.resolve({ ok: true, text: () => Promise.resolve("") } as Response); }; - + try { - await sendDenialEmail("my-resend-key", "user@example.com", "txn-abc", "25.00"); + await sendDenialEmail("my-resend-key", "user@example.com", "txn-abc", "25.00", "Policy violation"); } finally { restoreFetch(); } - + assertEquals(capturedHeaders?.["Authorization"], "Bearer my-resend-key"); assertEquals(capturedHeaders?.["Content-Type"], "application/json"); }); - - Deno.test("sendDenialEmail — sends correct recipient, transactionId, and amount in body", async () => { + + Deno.test("sendDenialEmail — sends correct recipient, transactionId, amount, and note in body", async () => { let capturedBody: any; - + globalThis.fetch = (_url: string | URL | Request, init?: RequestInit) => { capturedBody = JSON.parse(init?.body as string); return Promise.resolve({ ok: true, text: () => Promise.resolve("") } as Response); }; - + try { - await sendDenialEmail("test-key", "customer@example.com", "txn-xyz", "49.99"); + await sendDenialEmail("test-key", "customer@example.com", "txn-xyz", "49.99", "Custom note here"); } finally { restoreFetch(); } - + assertEquals(capturedBody.to, "customer@example.com"); assertEquals(capturedBody.html.includes("txn-xyz"), true); assertEquals(capturedBody.html.includes("49.99"), true); + assertEquals(capturedBody.html.includes("Custom note here"), true); assertEquals(capturedBody.subject, "Refund Request Denied"); }); \ No newline at end of file diff --git a/supabase/functions/denyRefund/logic.ts b/supabase/functions/denyRefund/logic.ts index 5020cad..d86e3db 100644 --- a/supabase/functions/denyRefund/logic.ts +++ b/supabase/functions/denyRefund/logic.ts @@ -4,32 +4,41 @@ export interface DenyRefundParams { userId: string; transactionId: string; amount: string; + note: string; } export interface DenyRefundDeps { supabase: SupabaseClient; - sendEmail: (to: string, transactionId: string, amount: string) => Promise; + sendEmail: (to: string, transactionId: string, amount: string, note: string) => Promise; } -export function extractParams(url: URL): DenyRefundParams { - const userId = url.searchParams.get("user_id"); - const transactionId = url.searchParams.get("transaction_id"); - const amount = url.searchParams.get("amount"); +export async function extractParams(req: Request): Promise { + try { + const body = await req.json(); - if (!userId || !transactionId || !amount) { - throw new Error("Missing params"); - } + const userId = body.customerId || body.user_id; + const transactionId = body.id || body.transactionId || body.transaction_id; + const amount = body.amount; + const note = body.note; + + if (!userId || !transactionId || !amount) { + throw new Error("Missing params"); + } - return { userId, transactionId, amount }; + return { userId, transactionId, amount, note }; + } catch (err) { + throw new Error("Invalid JSON body"); + } } export async function denyRefundInDb( supabase: SupabaseClient, - transactionId: string + transactionId: string, + note: string ): Promise { const { error } = await supabase .from("Refunds") - .update({ status: "denied" }) + .update({ status: "denied", "admin-note": note }) .eq("transaction_id", transactionId); if (error) { @@ -61,7 +70,8 @@ export async function sendDenialEmail( resendApiKey: string, to: string, transactionId: string, - amount: string + amount: string, + note: string, ): Promise { const response = await fetch("https://api.resend.com/emails", { method: "POST", @@ -78,6 +88,7 @@ export async function sendDenialEmail(

Unfortunately, your refund request for transaction ${transactionId} has been denied.

Amount: $${amount}

If you have questions about this decision, please contact support.

+

${note ? `Note: ${note}` : ""}

`, }), }); @@ -92,21 +103,19 @@ export async function handleDenyRefund( req: Request, deps: DenyRefundDeps ): Promise { - const url = new URL(req.url); - const { userId, transactionId, amount } = extractParams(url); + const { userId, transactionId, amount, note } = await extractParams(req); - await denyRefundInDb(deps.supabase, transactionId); + await denyRefundInDb(deps.supabase, transactionId, note); const userEmail = await getUserEmail(deps.supabase, userId); - await deps.sendEmail(userEmail, transactionId, amount); + await deps.sendEmail(userEmail, transactionId, amount, note); const html = `Refund Denied - The refund request has been denied and - the customer has been notified via email. - Transaction: ${transactionId} - Amount: $${amount} - `; +The refund request has been denied and +the customer has been notified via email. +Transaction: ${transactionId} +Amount: $${amount}`; return new Response(html, { status: 200, diff --git a/supabase/functions/maintenance-request/index.ts b/supabase/functions/maintenance-request/index.ts new file mode 100644 index 0000000..1313ef9 --- /dev/null +++ b/supabase/functions/maintenance-request/index.ts @@ -0,0 +1,83 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +serve(async (req: Request) => { + if (req.method === "OPTIONS") { + return new Response("ok", { + status: 200, + headers: corsHeaders, + }); + } + + try { + const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? ''; + const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''; + const supabase = createClient(supabaseUrl, supabaseKey); + + const body = await req.json(); + + const { user_id, category, description, image, location } = body; + + console.log(`Processing Maintenance Request for User: ${user_id} at Location: ${location}`); + + if (!user_id || !category || !description || !location) { + console.error("Missing required fields in request body."); + return new Response( + JSON.stringify({ error: "Missing required fields: user_id, category, description, or location" }), + { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" } + } + ); + } + + const { data, error: dbError } = await supabase + .from('Maintenance') + .insert([ + { + user_id: user_id, + category: category, + description: description, + location: location, + image_data: image, + created_at: new Date().toISOString() + } + ]) + .select(); + + if (dbError) { + console.error(`Database Error: ${dbError.message}`); + return new Response( + JSON.stringify({ error: "Database save failed", details: dbError.message }), + { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" } + } + ); + } + + return new Response( + JSON.stringify({ success: true, message: "Request logged", record: data }), + { + status: 200, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + + } catch (err: any) { + console.error(`Global Handler Error: ${err.message}`); + return new Response( + JSON.stringify({ error: "Internal Server Error", message: err.message }), + { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } +}); \ No newline at end of file diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 3e035bd..41c18a7 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -14,6 +15,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); GeolocatorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("GeolocatorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index d49f920..98923c8 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + file_selector_windows geolocator_windows permission_handler_windows url_launcher_windows From 07f36d05da639e5024b4034f4634d8dc4c754fad Mon Sep 17 00:00:00 2001 From: Jason Yoder Date: Sat, 18 Apr 2026 09:57:04 -0400 Subject: [PATCH 76/79] Fixes approveRefund tests --- .../functions/approveRefund/logic.test.ts | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/supabase/functions/approveRefund/logic.test.ts b/supabase/functions/approveRefund/logic.test.ts index 7c6b677..9d9733f 100644 --- a/supabase/functions/approveRefund/logic.test.ts +++ b/supabase/functions/approveRefund/logic.test.ts @@ -7,34 +7,35 @@ import { function createMockDeps(overrides: Partial = {}) { return { - updateRefund: async (_: string) => {}, + updateRefund: async (_: string, __: string) => {}, getUserEmail: async (_: string) => "test@example.com", incrementLoyalty: async (_: string, __: number) => {}, - sendEmail: async (_: string, __: string, ___: string) => {}, + sendEmail: async (_: string, __: string, ___: string, ____: string) => {}, ...overrides, }; } - + Deno.test("processRefund succeeds with valid input", async () => { const deps = createMockDeps(); - + const result = await processRefund( { userId: "user1", transactionId: "tx1", amount: "25", + note: "Approved by admin", }, deps ); - + assertEquals(result.success, true); assertEquals(result.transactionId, "tx1"); assertEquals(result.amount, "25"); }); - + Deno.test("throws if params are missing", async () => { const deps = createMockDeps(); - + await assertRejects( () => processRefund( @@ -42,6 +43,7 @@ import { userId: "", transactionId: "tx1", amount: "25", + note: "Test note", }, deps ), @@ -49,14 +51,14 @@ import { "Missing params" ); }); - + Deno.test("propagates updateRefund error", async () => { const deps = createMockDeps({ updateRefund: async () => { throw new Error("DB failure"); }, }); - + await assertRejects( () => processRefund( @@ -64,6 +66,7 @@ import { userId: "user1", transactionId: "tx1", amount: "25", + note: "Test note", }, deps ), @@ -71,12 +74,12 @@ import { "DB failure" ); }); - + Deno.test("throws if user email not found", async () => { const deps = createMockDeps({ getUserEmail: async () => "", }); - + await assertRejects( () => processRefund( @@ -84,6 +87,7 @@ import { userId: "user1", transactionId: "tx1", amount: "25", + note: "Test note", }, deps ), @@ -91,14 +95,14 @@ import { "User email not found" ); }); - + Deno.test("propagates incrementLoyalty error", async () => { const deps = createMockDeps({ incrementLoyalty: async () => { throw new Error("RPC failed"); }, }); - + await assertRejects( () => processRefund( @@ -106,6 +110,7 @@ import { userId: "user1", transactionId: "tx1", amount: "25", + note: "Test note", }, deps ), @@ -113,14 +118,14 @@ import { "RPC failed" ); }); - + Deno.test("propagates sendEmail error", async () => { const deps = createMockDeps({ sendEmail: async () => { throw new Error("Email failed"); }, }); - + await assertRejects( () => processRefund( @@ -128,6 +133,7 @@ import { userId: "user1", transactionId: "tx1", amount: "25", + note: "Test note", }, deps ), From 48f77649a82bdd1ebfcd656011b615a68b5bfb07 Mon Sep 17 00:00:00 2001 From: karelinejones Date: Mon, 20 Apr 2026 16:54:33 -0400 Subject: [PATCH 77/79] fixed failed tests --- .../email_verification/controller_test.dart | 306 ++++++++++-------- .../email_verification_test.dart | 241 ++++++++------ .../widgets/resend_verification_test.dart | 118 +++---- 3 files changed, 377 insertions(+), 288 deletions(-) diff --git a/test/features/email_verification/controller_test.dart b/test/features/email_verification/controller_test.dart index 5550252..757d05f 100644 --- a/test/features/email_verification/controller_test.dart +++ b/test/features/email_verification/controller_test.dart @@ -1,94 +1,66 @@ -import 'dart:async'; import 'package:clean_stream_laundry_app/features/email_verification/controller.dart'; import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; -import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; import 'mocks.dart'; void main() { late MockAuthService mockAuthService; - late StreamController authChangeController; - late FakeAppLinks fakeAppLinks; setUpAll(() { - registerFallbackValue(FakeAuthService()); - registerFallbackValue(FakeUri()); + registerFallbackValue(''); }); setUp(() { mockAuthService = MockAuthService(); - authChangeController = StreamController.broadcast(); - fakeAppLinks = FakeAppLinks(); - GetIt.instance.registerSingleton(mockAuthService); - - when(() => mockAuthService.onAuthChange) - .thenAnswer((_) => authChangeController.stream); - when(() => mockAuthService.isEmailVerified()).thenReturn(false); }); tearDown(() { - authChangeController.close(); - fakeAppLinks.dispose(); GetIt.instance.reset(); }); - /// minimal GoRouter - controller needs a context - Widget buildWithRouter({ - required void Function(EmailVerificationController) onControllerReady, - }) { - return MaterialApp.router( - routerConfig: GoRouter( - initialLocation: '/email-verification', - routes: [ - GoRoute( - path: '/email-verification', - builder: (context, state) { - final controller = EmailVerificationController( - appLinks: fakeAppLinks, - context: context, - ); - onControllerReady(controller); - controller.init(); - return const SizedBox(); - }, - ), - GoRoute( - path: '/homePage', - builder: (_, __) => const Scaffold(body: Text('Home')), - ), - ], - ), - ); - } + EmailVerificationController buildController() => + EmailVerificationController(); + + // --------------------------------------------------------------------------- + // resendVerification + // --------------------------------------------------------------------------- group('resendVerification', () { - testWidgets('calls auth service resendVerification', (tester) async { - when(() => mockAuthService.resendVerification()) - .thenAnswer((_) async => AuthenticationResponses.success); + test('calls auth service resendVerification', () async { + when( + () => mockAuthService.resendVerification(), + ).thenAnswer((_) async => AuthenticationResponses.success); - late EmailVerificationController controller; - await tester.pumpWidget( - buildWithRouter(onControllerReady: (c) => controller = c), - ); + final controller = buildController(); await controller.resendVerification(); verify(() => mockAuthService.resendVerification()).called(1); }); - testWidgets('sets resent to true on success', (tester) async { - when(() => mockAuthService.resendVerification()) - .thenAnswer((_) async => AuthenticationResponses.success); + test('passes email when provided', () async { + const email = 'verify@example.com'; + when( + () => mockAuthService.resendVerification(email: email), + ).thenAnswer((_) async => AuthenticationResponses.success); - late EmailVerificationController controller; - await tester.pumpWidget( - buildWithRouter(onControllerReady: (c) => controller = c), - ); + final controller = buildController(); + + await controller.resendVerification(email: email); + + verify(() => mockAuthService.resendVerification(email: email)).called(1); + }); + + test('sets resent to true on success', () async { + when( + () => mockAuthService.resendVerification(), + ).thenAnswer((_) async => AuthenticationResponses.success); + + final controller = buildController(); await controller.resendVerification(); @@ -96,117 +68,185 @@ void main() { expect(controller.lastResponse, AuthenticationResponses.success); }); - testWidgets('sets lastResponse on failure, resent stays false', - (tester) async { - when(() => mockAuthService.resendVerification()) - .thenAnswer((_) async => AuthenticationResponses.failure); + test('sets lastResponse on failure and keeps resent false', () async { + when( + () => mockAuthService.resendVerification(), + ).thenAnswer((_) async => AuthenticationResponses.failure); - late EmailVerificationController controller; - await tester.pumpWidget( - buildWithRouter(onControllerReady: (c) => controller = c), - ); + final controller = buildController(); - await controller.resendVerification(); + await controller.resendVerification(); - expect(controller.resent, isFalse); - expect(controller.lastResponse, AuthenticationResponses.failure); - }); + expect(controller.resent, isFalse); + expect(controller.lastResponse, AuthenticationResponses.failure); + }); - testWidgets('does not call service again when already resent', - (tester) async { - when(() => mockAuthService.resendVerification()) - .thenAnswer((_) async => AuthenticationResponses.success); + test('does not call service again when already resent', () async { + when( + () => mockAuthService.resendVerification(), + ).thenAnswer((_) async => AuthenticationResponses.success); - late EmailVerificationController controller; - await tester.pumpWidget( - buildWithRouter(onControllerReady: (c) => controller = c), - ); + final controller = buildController(); - await controller.resendVerification(); - await controller.resendVerification(); + await controller.resendVerification(); + await controller.resendVerification(); - verify(() => mockAuthService.resendVerification()).called(1); - }); + verify(() => mockAuthService.resendVerification()).called(1); + }); }); - group('Auth change listener', () { - testWidgets('calls isEmailVerified when authState changes', - (tester) async { - when(() => mockAuthService.isEmailVerified()).thenReturn(true); + // --------------------------------------------------------------------------- + // verifyEmailCode + // --------------------------------------------------------------------------- + + group('verifyEmailCode', () { + test('returns invalid when code is not 6 digits', () async { + final controller = buildController(); + controller.codeController.text = '123'; + + final result = await controller.verifyEmailCode('user@example.com'); + + expect(result, EmailVerifyResult.invalid); + expect(controller.error, 'Please enter the 6-digit code'); + verifyNever( + () => mockAuthService.verifyEmailCode( + email: any(named: 'email'), + code: any(named: 'code'), + ), + ); + }); - await tester.pumpWidget(buildWithRouter(onControllerReady: (_) {})); - await tester.pumpAndSettle(); + test('returns success when auth service verifies code', () async { + when( + () => mockAuthService.verifyEmailCode( + email: 'user@example.com', + code: '123456', + ), + ).thenAnswer((_) async => AuthenticationResponses.success); - authChangeController.add(true); - await tester.pumpAndSettle(); + final controller = buildController(); + controller.codeController.text = '123456'; + final result = await controller.verifyEmailCode('user@example.com'); - verify(() => mockAuthService.isEmailVerified()).called(1); - }); + expect(result, EmailVerifyResult.success); + expect(controller.error, isNull); + expect(controller.isLoading, isFalse); + }); - testWidgets('does not navigate when logged in but email not verified', - (tester) async { - when(() => mockAuthService.isEmailVerified()).thenReturn(false); + test('returns invalid when auth service rejects code', () async { + when( + () => mockAuthService.verifyEmailCode( + email: 'user@example.com', + code: '123456', + ), + ).thenAnswer((_) async => AuthenticationResponses.failure); - await tester.pumpWidget(buildWithRouter(onControllerReady: (_) {})); - await tester.pumpAndSettle(); + final controller = buildController(); + controller.codeController.text = '123456'; - authChangeController.add(true); - await tester.pumpAndSettle(); + final result = await controller.verifyEmailCode('user@example.com'); - expect(find.text('Home'), findsNothing); - }); + expect(result, EmailVerifyResult.invalid); + expect(controller.error, 'Invalid or expired code'); + expect(controller.isLoading, isFalse); + }); - testWidgets('does not navigate when auth emits false', (tester) async { - when(() => mockAuthService.isEmailVerified()).thenReturn(true); + test('returns error when auth service throws', () async { + when( + () => mockAuthService.verifyEmailCode( + email: 'user@example.com', + code: '123456', + ), + ).thenThrow(Exception('network')); - await tester.pumpWidget(buildWithRouter(onControllerReady: (_) {})); - await tester.pumpAndSettle(); + final controller = buildController(); + controller.codeController.text = '123456'; - authChangeController.add(false); - await tester.pumpAndSettle(); + final result = await controller.verifyEmailCode('user@example.com'); - expect(find.text('Home'), findsNothing); + expect(result, EmailVerifyResult.error); + expect(controller.error, 'Something went wrong. Try again'); + expect(controller.isLoading, isFalse); }); }); - group('Deep link handling', () { - testWidgets('calls getSessionFromURI on valid deep link', (tester) async { - when(() => mockAuthService.getSessionFromURI(any())) - .thenAnswer((_) async {}); + // --------------------------------------------------------------------------- + // resendVerificationEmail + // --------------------------------------------------------------------------- + + group('resendVerificationEmail', () { + test('returns success when resend succeeds', () async { + when( + () => mockAuthService.resendVerification(email: 'user@example.com'), + ).thenAnswer((_) async => AuthenticationResponses.success); - await tester.pumpWidget(buildWithRouter(onControllerReady: (_) {})); - await tester.pumpAndSettle(); + final controller = buildController(); - fakeAppLinks.emit(Uri.parse('clean-stream://email-verification')); - await tester.pumpAndSettle(); - await tester.pump(); - await tester.pumpAndSettle(); + final result = await controller.resendVerificationEmail( + 'user@example.com', + ); - verify(() => mockAuthService.getSessionFromURI(any())).called(1); + expect(result, EmailResendResult.success); }); - testWidgets('ignores URIs with wrong host', (tester) async { - await tester.pumpWidget(buildWithRouter(onControllerReady: (_) {})); - await tester.pumpAndSettle(); + test('returns failed when resend returns non-success', () async { + when( + () => mockAuthService.resendVerification(email: 'user@example.com'), + ).thenAnswer((_) async => AuthenticationResponses.failure); + + final controller = buildController(); - fakeAppLinks.emit(Uri.parse('clean-stream://wrong-host')); - await tester.pumpAndSettle(); + final result = await controller.resendVerificationEmail( + 'user@example.com', + ); - verifyNever(() => mockAuthService.getSessionFromURI(any())); - expect(find.text('Home'), findsNothing); + expect(result, EmailResendResult.failed); }); - }); + test('returns error when resend throws', () async { + when( + () => mockAuthService.resendVerification(email: 'user@example.com'), + ).thenThrow(Exception('network')); + + final controller = buildController(); - group('Lifecycle', () { - testWidgets('dispose cancels subscriptions without error', (tester) async { - late EmailVerificationController controller; - await tester.pumpWidget( - buildWithRouter(onControllerReady: (c) => controller = c), + final result = await controller.resendVerificationEmail( + 'user@example.com', ); + expect(result, EmailResendResult.error); + }); + }); + + // --------------------------------------------------------------------------- + // helpers + // --------------------------------------------------------------------------- + + group('helpers', () { + test('currentEmail reads from auth service', () { + when( + () => mockAuthService.getCurrentUserEmail(), + ).thenReturn('user@example.com'); + + final controller = buildController(); + + expect(controller.currentEmail, 'user@example.com'); + }); + + test('clearError clears existing error', () { + final controller = buildController(); + controller.error = 'test error'; + + controller.clearError(); + + expect(controller.error, isNull); + }); + + test('dispose does not throw', () { + final controller = buildController(); + expect(() => controller.dispose(), returnsNormally); }); }); -} \ No newline at end of file +} diff --git a/test/features/email_verification/email_verification_test.dart b/test/features/email_verification/email_verification_test.dart index d8eb6e8..cd178a6 100644 --- a/test/features/email_verification/email_verification_test.dart +++ b/test/features/email_verification/email_verification_test.dart @@ -1,10 +1,6 @@ -import 'dart:async'; import 'package:clean_stream_laundry_app/features/email_verification/email_verification.dart'; +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/location_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/machine_service.dart'; -import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; -import 'package:clean_stream_laundry_app/features/home/home.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; @@ -14,61 +10,41 @@ import 'mocks.dart'; void main() { late MockAuthService mockAuthService; - late StreamController authChangeController; - late MockMachineService mockMachineService; - late MockLocationService mockLocationService; - late MockProfileService mockProfileService; - late FakeAppLinks fakeAppLinks; setUpAll(() { registerFallbackValue(FakeAuthService()); - registerFallbackValue(FakeUri()); + registerFallbackValue(''); }); setUp(() { mockAuthService = MockAuthService(); - authChangeController = StreamController.broadcast(); - mockMachineService = MockMachineService(); - mockLocationService = MockLocationService(); - mockProfileService = MockProfileService(); - fakeAppLinks = FakeAppLinks(); GetIt.instance.registerSingleton(mockAuthService); - GetIt.instance.registerSingleton(mockMachineService); - GetIt.instance.registerSingleton(mockLocationService); - GetIt.instance.registerSingleton(mockProfileService); - - when(() => mockAuthService.onAuthChange) - .thenAnswer((_) => authChangeController.stream); - when(() => mockAuthService.isEmailVerified()).thenReturn(false); - when(() => mockLocationService.getLocations()) - .thenAnswer((_) async => >[]); + when(() => mockAuthService.getCurrentUserEmail()).thenReturn(null); }); tearDown(() { - authChangeController.close(); - fakeAppLinks.dispose(); GetIt.instance.reset(); }); - Widget createTestWidget() { + Widget createTestWidget({String? email}) { return MaterialApp.router( routerConfig: GoRouter( initialLocation: '/email-verification', routes: [ GoRoute( path: '/email-verification', - builder: (context, state) => - EmailVerificationPage(appLinks: fakeAppLinks), + builder: (context, state) => EmailVerificationPage(email: email), ), GoRoute( path: '/homePage', - builder: (context, state) => HomePage(), + builder: (context, state) => + const Scaffold(body: Text('Home Page')), ), GoRoute( - path: '/scanner', + path: '/login', builder: (context, state) => - const Scaffold(body: Text('Scanner Page')), + const Scaffold(body: Text('Login Page')), ), ], ), @@ -77,125 +53,194 @@ void main() { group('Static UI', () { testWidgets('displays all required UI elements', (tester) async { - await tester.pumpWidget(createTestWidget()); + await tester.pumpWidget(createTestWidget(email: 'route@example.com')); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.email), findsOneWidget); - expect(find.text('Please verify your email address'), findsOneWidget); + expect(find.byIcon(Icons.mark_email_read_outlined), findsOneWidget); + expect(find.text('Verify your email'), findsOneWidget); expect( - find.text('Check your inbox and click the verification link.'), + find.text( + 'Enter the 6-digit verification code we sent to your email address.', + ), findsOneWidget, ); - expect(find.text('Resend Verification'), findsOneWidget); + expect(find.text('Verify Email'), findsNWidgets(2)); + expect(find.text('Resend code'), findsOneWidget); + expect(find.text('Back to Login'), findsOneWidget); }); - testWidgets('email icon has correct styling', (tester) async { - await tester.pumpWidget(createTestWidget()); + testWidgets('email icon has correct sizing', (tester) async { + await tester.pumpWidget(createTestWidget(email: 'route@example.com')); await tester.pumpAndSettle(); - final icon = tester.widget(find.byIcon(Icons.email)); + final icon = tester.widget( + find.byIcon(Icons.mark_email_read_outlined), + ); expect(icon.size, equals(80)); - expect(icon.color, equals(Colors.blueAccent)); }); - testWidgets('text uses center alignment', (tester) async { - await tester.pumpWidget(createTestWidget()); + testWidgets('shows route email when provided', (tester) async { + await tester.pumpWidget(createTestWidget(email: 'route@example.com')); await tester.pumpAndSettle(); - final titleText = tester.widget( - find.text('Please verify your email address'), - ); - final descText = tester.widget( - find.text('Check your inbox and click the verification link.'), - ); + expect(find.text('route@example.com'), findsOneWidget); + }); - expect(titleText.textAlign, equals(TextAlign.center)); - expect(descText.textAlign, equals(TextAlign.center)); + testWidgets('shows current user email when route email is missing', ( + tester, + ) async { + when( + () => mockAuthService.getCurrentUserEmail(), + ).thenReturn('session@example.com'); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + expect(find.text('session@example.com'), findsOneWidget); }); - testWidgets('uses theme surface color as scaffold background', - (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); + testWidgets('shows fallback email placeholder when none available', ( + tester, + ) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); - final scaffold = tester.widget(find.byType(Scaffold)); - expect(scaffold.backgroundColor, isNotNull); - }); + expect(find.text('your email'), findsOneWidget); + }); }); - group('Navigation', () { - testWidgets('verifies email verification check called', (tester) async { - when(() => mockAuthService.isEmailVerified()).thenReturn(true); + group('Actions', () { + testWidgets( + 'shows missing email message when verify is pressed without email', + (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Verify Email')); + await tester.pumpAndSettle(); + + expect( + find.text('Missing account email. Please log in and try again.'), + findsOneWidget, + ); + verifyNever( + () => mockAuthService.verifyEmailCode( + email: any(named: 'email'), + code: any(named: 'code'), + ), + ); + }, + ); - await tester.pumpWidget(createTestWidget()); + testWidgets('navigates to home after successful code verification', ( + tester, + ) async { + when( + () => mockAuthService.verifyEmailCode( + email: 'route@example.com', + code: '123456', + ), + ).thenAnswer((_) async => AuthenticationResponses.success); + + await tester.pumpWidget(createTestWidget(email: 'route@example.com')); await tester.pumpAndSettle(); - authChangeController.add(true); + await tester.enterText(find.byType(TextField), '123456'); + await tester.tap(find.widgetWithText(ElevatedButton, 'Verify Email')); await tester.pumpAndSettle(); - verify(() => mockAuthService.isEmailVerified()).called(1); + expect(find.text('Home Page'), findsOneWidget); }); - testWidgets('stays on page when email not verified', (tester) async { - when(() => mockAuthService.isEmailVerified()).thenReturn(false); - - await tester.pumpWidget(createTestWidget()); + testWidgets('shows invalid code error when verification fails', ( + tester, + ) async { + when( + () => mockAuthService.verifyEmailCode( + email: 'route@example.com', + code: '123456', + ), + ).thenAnswer((_) async => AuthenticationResponses.failure); + + await tester.pumpWidget(createTestWidget(email: 'route@example.com')); await tester.pumpAndSettle(); - authChangeController.add(true); + await tester.enterText(find.byType(TextField), '123456'); + await tester.tap(find.widgetWithText(ElevatedButton, 'Verify Email')); await tester.pumpAndSettle(); - expect(find.text('Please verify your email address'), findsOneWidget); + expect(find.text('Invalid or expired code'), findsWidgets); }); - testWidgets('stays on page when auth emits false (logout)', (tester) async { - when(() => mockAuthService.isEmailVerified()).thenReturn(true); + testWidgets('resends code and shows success snackbar', (tester) async { + when( + () => mockAuthService.resendVerification(email: 'route@example.com'), + ).thenAnswer((_) async => AuthenticationResponses.success); - await tester.pumpWidget(createTestWidget()); + await tester.pumpWidget(createTestWidget(email: 'route@example.com')); await tester.pumpAndSettle(); - authChangeController.add(false); + await tester.tap(find.text('Resend code')); await tester.pumpAndSettle(); - expect(find.text('Please verify your email address'), findsOneWidget); + verify( + () => mockAuthService.resendVerification(email: 'route@example.com'), + ).called(1); + expect( + find.text('Verification code sent! Check your email.'), + findsOneWidget, + ); }); - testWidgets('verifies deeplink gets session', (tester) async { - when(() => mockAuthService.getSessionFromURI(any())) - .thenAnswer((_) async {}); + testWidgets('shows resend failure message', (tester) async { + when( + () => mockAuthService.resendVerification(email: 'route@example.com'), + ).thenAnswer((_) async => AuthenticationResponses.failure); - await tester.pumpWidget(createTestWidget()); + await tester.pumpWidget(createTestWidget(email: 'route@example.com')); await tester.pumpAndSettle(); - fakeAppLinks.emit(Uri.parse('clean-stream://email-verification')); + await tester.tap(find.text('Resend code')); await tester.pumpAndSettle(); - verify(() => mockAuthService.getSessionFromURI(any())).called(1); + expect(find.text('Failed to send verification code.'), findsOneWidget); }); - testWidgets('ignores deep link with wrong host', (tester) async { - await tester.pumpWidget(createTestWidget()); + testWidgets('navigates to login when back to login is tapped', ( + tester, + ) async { + await tester.pumpWidget(createTestWidget(email: 'route@example.com')); await tester.pumpAndSettle(); - fakeAppLinks.emit(Uri.parse('clean-stream://other-host')); + await tester.dragUntilVisible( + find.text('Back to Login'), + find.byType(SingleChildScrollView), + const Offset(0, -200), + ); + await tester.tap(find.text('Back to Login')); await tester.pumpAndSettle(); - expect(find.text('Please verify your email address'), findsOneWidget); + expect(find.text('Login Page'), findsOneWidget); }); }); - group('Lifecycle', () { - testWidgets('properly disposes controller on navigation away', - (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); + group('Layout', () { + testWidgets('uses theme surface color as scaffold background', ( + tester, + ) async { + await tester.pumpWidget(createTestWidget(email: 'route@example.com')); + await tester.pumpAndSettle(); + + final scaffold = tester.widget(find.byType(Scaffold)); + expect(scaffold.backgroundColor, isNotNull); + }); - final context = - tester.element(find.byType(EmailVerificationPage)); - GoRouter.of(context).go('/scanner'); - await tester.pumpAndSettle(); + testWidgets('title text is centered', (tester) async { + await tester.pumpWidget(createTestWidget(email: 'route@example.com')); + await tester.pumpAndSettle(); - expect(find.byType(EmailVerificationPage), findsNothing); - }); + final titleText = tester.widget(find.text('Verify your email')); + expect(titleText.textAlign, TextAlign.center); + }); }); -} \ No newline at end of file +} diff --git a/test/features/email_verification/widgets/resend_verification_test.dart b/test/features/email_verification/widgets/resend_verification_test.dart index dccff93..0254f9d 100644 --- a/test/features/email_verification/widgets/resend_verification_test.dart +++ b/test/features/email_verification/widgets/resend_verification_test.dart @@ -10,40 +10,33 @@ import '../mocks.dart'; void main() { late MockAuthService mockAuthService; - late FakeAppLinks fakeAppLinks; setUpAll(() { - registerFallbackValue(FakeAuthService()); - registerFallbackValue(FakeUri()); + registerFallbackValue(''); }); setUp(() { mockAuthService = MockAuthService(); - fakeAppLinks = FakeAppLinks(); GetIt.instance.registerSingleton(mockAuthService); - - when(() => mockAuthService.onAuthChange) - .thenAnswer((_) => const Stream.empty()); }); tearDown(() { - fakeAppLinks.dispose(); GetIt.instance.reset(); }); Future buildController( - WidgetTester tester) async { + WidgetTester tester, + ) async { late EmailVerificationController controller; await tester.pumpWidget( MaterialApp( - home: Builder(builder: (context) { - controller = EmailVerificationController( - appLinks: fakeAppLinks, - context: context, - ); - return const SizedBox(); - }), + home: Builder( + builder: (context) { + controller = EmailVerificationController(); + return const SizedBox(); + }, + ), ), ); @@ -98,25 +91,28 @@ void main() { }); group('Success state', () { - testWidgets('shows check_circle icon after successful resend', - (tester) async { - when(() => mockAuthService.resendVerification()) - .thenAnswer((_) async => AuthenticationResponses.success); + testWidgets('shows check_circle icon after successful resend', ( + tester, + ) async { + when( + () => mockAuthService.resendVerification(), + ).thenAnswer((_) async => AuthenticationResponses.success); - final controller = await buildController(tester); - await tester.pumpWidget(buildWidget(controller: controller)); - await tester.pumpAndSettle(); + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); - await tester.tap(find.text('Resend Verification')); - await tester.pumpAndSettle(); + await tester.tap(find.text('Resend Verification')); + await tester.pumpAndSettle(); - expect(find.byIcon(Icons.check_circle), findsOneWidget); - expect(find.text('Resend Verification'), findsNothing); - }); + expect(find.byIcon(Icons.check_circle), findsOneWidget); + expect(find.text('Resend Verification'), findsNothing); + }); testWidgets('check_circle icon has correct styling', (tester) async { - when(() => mockAuthService.resendVerification()) - .thenAnswer((_) async => AuthenticationResponses.success); + when( + () => mockAuthService.resendVerification(), + ).thenAnswer((_) async => AuthenticationResponses.success); final controller = await buildController(tester); await tester.pumpWidget(buildWidget(controller: controller)); @@ -130,31 +126,34 @@ void main() { expect(icon.color, equals(Colors.green)); }); - testWidgets('tapping check_circle does not trigger another resend', - (tester) async { - when(() => mockAuthService.resendVerification()) - .thenAnswer((_) async => AuthenticationResponses.success); + testWidgets('tapping check_circle does not trigger another resend', ( + tester, + ) async { + when( + () => mockAuthService.resendVerification(), + ).thenAnswer((_) async => AuthenticationResponses.success); - final controller = await buildController(tester); - await tester.pumpWidget(buildWidget(controller: controller)); - await tester.pumpAndSettle(); + final controller = await buildController(tester); + await tester.pumpWidget(buildWidget(controller: controller)); + await tester.pumpAndSettle(); - await tester.tap(find.text('Resend Verification')); - await tester.pumpAndSettle(); + await tester.tap(find.text('Resend Verification')); + await tester.pumpAndSettle(); - verify(() => mockAuthService.resendVerification()).called(1); + verify(() => mockAuthService.resendVerification()).called(1); - await tester.tap(find.byIcon(Icons.check_circle)); - await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.check_circle)); + await tester.pumpAndSettle(); - verifyNever(() => mockAuthService.resendVerification()); - }); + verifyNever(() => mockAuthService.resendVerification()); + }); }); group('Failure state', () { testWidgets('shows close icon and error text on failure', (tester) async { - when(() => mockAuthService.resendVerification()) - .thenAnswer((_) async => AuthenticationResponses.failure); + when( + () => mockAuthService.resendVerification(), + ).thenAnswer((_) async => AuthenticationResponses.failure); final controller = await buildController(tester); await tester.pumpWidget(buildWidget(controller: controller)); @@ -171,8 +170,9 @@ void main() { }); testWidgets('error container has red circular decoration', (tester) async { - when(() => mockAuthService.resendVerification()) - .thenAnswer((_) async => AuthenticationResponses.failure); + when( + () => mockAuthService.resendVerification(), + ).thenAnswer((_) async => AuthenticationResponses.failure); final controller = await buildController(tester); await tester.pumpWidget(buildWidget(controller: controller)); @@ -184,9 +184,9 @@ void main() { final container = tester.widget( find .ancestor( - of: find.byIcon(Icons.close), - matching: find.byType(Container), - ) + of: find.byIcon(Icons.close), + matching: find.byType(Container), + ) .first, ); @@ -195,9 +195,12 @@ void main() { expect(decoration.shape, equals(BoxShape.circle)); }); - testWidgets('does not trigger another resend after failure', (tester) async { - when(() => mockAuthService.resendVerification()) - .thenAnswer((_) async => AuthenticationResponses.failure); + testWidgets('does not trigger another resend after failure', ( + tester, + ) async { + when( + () => mockAuthService.resendVerification(), + ).thenAnswer((_) async => AuthenticationResponses.failure); final controller = await buildController(tester); await tester.pumpWidget(buildWidget(controller: controller)); @@ -217,8 +220,9 @@ void main() { group('InkWell interaction', () { testWidgets('tapping InkWell calls resendVerification', (tester) async { - when(() => mockAuthService.resendVerification()) - .thenAnswer((_) async => AuthenticationResponses.success); + when( + () => mockAuthService.resendVerification(), + ).thenAnswer((_) async => AuthenticationResponses.success); final controller = await buildController(tester); await tester.pumpWidget(buildWidget(controller: controller)); @@ -235,4 +239,4 @@ void main() { verify(() => mockAuthService.resendVerification()).called(1); }); }); -} \ No newline at end of file +} From 70a15093c622c38202c41daa7fe5e582db99ca08 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 20 Apr 2026 20:11:42 -0400 Subject: [PATCH 78/79] Fixed credit-card spanning --- lib/features/loyalty/widgets/credit_card.dart | 48 +++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/lib/features/loyalty/widgets/credit_card.dart b/lib/features/loyalty/widgets/credit_card.dart index e2f2afa..ca73d18 100644 --- a/lib/features/loyalty/widgets/credit_card.dart +++ b/lib/features/loyalty/widgets/credit_card.dart @@ -41,23 +41,18 @@ class CreditCard extends StatelessWidget { top: 65, child: SvgPicture.asset("assets/CardChip.svg", width: 60, height: 45,key: Key("cardChip")), ), - Positioned( - left: -4, - right: 0, - top: 120, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 4), - color: Theme.of(context).colorScheme.cardSecondary, - child: Text( - "1234 5678 9012 3456", - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 26, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.fontInverted, - ), - ), - ) + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.center, + child: Text( + "1234 5678 9012 3456", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.fontInverted, + ), + ), ), Positioned( left: 15, @@ -68,14 +63,17 @@ class CreditCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: Text( - (username == null || username!.isEmpty) ? 'John Doe' : username!, - textAlign: TextAlign.left, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 26, - fontWeight: FontWeight.w500, - color: Colors.black87, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + (username == null || username!.isEmpty) ? 'John Doe' : username!, + textAlign: TextAlign.left, + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w500, + color: Colors.black87, + ), ), ), ), From 6e3a59d9f9c7f9c7517b3b0bc927dd49112e84c2 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 20 Apr 2026 20:12:43 -0400 Subject: [PATCH 79/79] Fixed home page spanning issues --- lib/features/home/widgets/header.dart | 101 ++++++++++++++------------ 1 file changed, 55 insertions(+), 46 deletions(-) diff --git a/lib/features/home/widgets/header.dart b/lib/features/home/widgets/header.dart index 63c89fb..ffd8d8d 100644 --- a/lib/features/home/widgets/header.dart +++ b/lib/features/home/widgets/header.dart @@ -17,63 +17,72 @@ class Header extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - Text( - controller.username == null - ? 'Welcome!' - : 'Welcome ${controller.username}!', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 28, - color: Theme.of(context).colorScheme.fontInverted, + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + controller.username == null + ? 'Welcome!' + : 'Welcome ${controller.username}!', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 28, + color: Theme.of(context).colorScheme.fontInverted, + ), ), ), + const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Flexible( - child: Text( - 'Current balance: \$' - '${controller.balance?["balance"] != null ? (controller.balance!["balance"] as num).toStringAsFixed(2) : 'Loading...'}', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, - color: Theme.of(context).colorScheme.fontInverted, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + 'Current balance: \$' + '${controller.balance?["balance"] != null ? (controller.balance!["balance"] as num).toStringAsFixed(2) : 'Loading...'}', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: Theme.of(context).colorScheme.fontInverted, + ), ), - overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 12), - InkWell( - onTap: onNearestLocationTap, - borderRadius: BorderRadius.circular(14), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Nearest Location', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - decoration: TextDecoration.underline, - decorationColor: - Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(width: 6), - SvgPicture.asset( - 'assets/locationPin.svg', - width: 24, - height: 24, - colorFilter: ColorFilter.mode( - Theme.of(context).colorScheme.primary, - BlendMode.srcIn, - ), + Flexible( + child: InkWell( + onTap: onNearestLocationTap, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Nearest Location', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 6), + SvgPicture.asset( + 'assets/locationPin.svg', + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary, + BlendMode.srcIn, + ), + ), + ], ), - ], + ), ), ), ),