diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a5c10710..a5810d9b 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/core/router/app_router.dart b/lib/core/router/app_router.dart index d7d40faf..efce9b0c 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 00000000..7b9e26bc --- /dev/null +++ b/lib/features/maintenance_request/controller.dart @@ -0,0 +1,174 @@ +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'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class MaintenanceController extends ChangeNotifier { + final EdgeFunctionService edgeFunctionService; + final ProfileService profileService; + final AuthService authService; + final LocationService locationService = GetIt.instance(); + + MaintenanceController({ + EdgeFunctionService? edgeFunctionService, + ProfileService? profileService, + AuthService? authService, + }) : edgeFunctionService = + edgeFunctionService ?? GetIt.instance(), + profileService = profileService ?? GetIt.instance(), + authService = authService ?? GetIt.instance(); + + final TextEditingController descriptionController = TextEditingController(); + + final List categories = const [ + 'Washer/Dryer Maintenance', + 'App Maintenance', + 'Location Maintenance', + 'Other', + ]; + + 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(); + return camera.isGranted && photos.isGranted; + } + + Future pickImage(BuildContext context) async { + if (!await _ensurePermissions()) return; + + 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 picker = ImagePicker(); + final picked = await picker.pickImage(source: source); + + if (picked != null) { + selectedImage = File(picked.path); + notifyListeners(); + } + } + + void selectCategory(String category) { + selectedCategory = category; + notifyListeners(); + } + + bool get isFormValid => + selectedCategory != null && + selectedLocation != null && + descriptionController.text.trim().isNotEmpty; + + void selectLocation(String location) { + selectedLocation = location; + notifyListeners(); + } + + void markAttemptedSubmit() { + attemptedSubmit = true; + notifyListeners(); + } + + Future submitMaintenance() async { + final userId = authService.getCurrentUserId; + if (userId == null) return false; + + isLoading = true; + notifyListeners(); + + try { + String? imageUrl; + + if (selectedImage != null) { + final fileName = '${DateTime.now().millisecondsSinceEpoch}_${userId.hashCode}.jpg'; + final path = 'requests/$fileName'; + await Supabase.instance.client.storage + .from('maintenance-images') + .upload(path, selectedImage!); + + imageUrl = Supabase.instance.client.storage + .from('maintenance-images') + .getPublicUrl(path); + } + + await edgeFunctionService.runEdgeFunction( + name: 'maintenance-request', + body: { + 'user_id': userId, + 'category': selectedCategory, + 'location': selectedLocation, + 'description': descriptionController.text, + 'image': imageUrl, + }, + ); + return true; + } catch (e) { + return false; + } finally { + isLoading = false; + 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 new file mode 100644 index 00000000..7c2ba057 --- /dev/null +++ b/lib/features/maintenance_request/maintenance_request.dart @@ -0,0 +1,144 @@ +import 'controller.dart'; +import 'widgets/maintenance_form.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'; + +class MaintenancePage extends StatefulWidget { + final MaintenanceController? controller; + const MaintenancePage({super.key, this.controller}); + + @override + State createState() => MaintenancePageState(); +} + +class MaintenancePageState extends State { + late final MaintenanceController _controller; + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _controller = widget.controller ?? MaintenanceController(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _controller.init(); + }); + _controller.addListener(_onControllerChanged); + } + + void _onControllerChanged() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _controller.removeListener(_onControllerChanged); + _controller.disposeController(); + if (widget.controller == null) { + _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: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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 SizedBox(height: 4), + ], + ), + ), + ); + } +} \ No newline at end of file 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 00000000..4de58a69 --- /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 new file mode 100644 index 00000000..59688681 --- /dev/null +++ b/lib/features/maintenance_request/widgets/maintenance_form.dart @@ -0,0 +1,182 @@ +import '../widgets/location_selector.dart'; +import '../controller.dart'; +import 'package:clean_stream_laundry_app/core/theme/theme.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 Category', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: colorScheme.fontInverted, + ), + ), + const SizedBox(height: 4), + + DropdownButtonFormField( + initialValue: 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: 18), + + Text( + 'Location', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: colorScheme.fontInverted, + ), + ), + const SizedBox(height: 4), + LocationSelector(controller: controller), + + const SizedBox(height: 18), + + Text( + 'Reason for Maintenance', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: colorScheme.fontInverted, + ), + ), + const SizedBox(height: 4), + TextField( + controller: controller.descriptionController, + maxLength: 200, + minLines: 2, + maxLines: 4, + keyboardType: TextInputType.multiline, + style: TextStyle(color: colorScheme.fontInverted), + decoration: _inputDecoration(context).copyWith( + hintText: 'Describe the issue...', + hintStyle: TextStyle(color: colorScheme.fontSecondary), + ), + ), + + const SizedBox(height: 18), + 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), + 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/settings/settings.dart b/lib/features/settings/settings.dart index 4e623924..312ec5f1 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, @@ -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', diff --git a/pubspec.yaml b/pubspec.yaml index 3f60d83e..e19df990 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: diff --git a/test/features/maintenance_request/controller_test.dart b/test/features/maintenance_request/controller_test.dart new file mode 100644 index 00000000..bf5c1995 --- /dev/null +++ b/test/features/maintenance_request/controller_test.dart @@ -0,0 +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:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockMaintenanceController extends Mock implements MaintenanceController {} +class FakeBuildContext extends Fake implements BuildContext {} + +void main() { + late MockMaintenanceController controller; + + Widget buildForm() { + return MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: 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.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())); + }); + + 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 new file mode 100644 index 00000000..d1442d28 --- /dev/null +++ b/test/features/maintenance_request/maintenance_request_test.dart @@ -0,0 +1,133 @@ +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 {} + +void main() { + late MockMaintenanceController mockController; + late TextEditingController descriptionController; + + setUp(() { + 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.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); + when(() => mockController.dispose()).thenReturn(null); + }); + + Widget createWidget() { + return MaterialApp.router( + routerConfig: GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => MaintenancePage(controller: mockController), + ), + GoRoute( + path: '/settings', + builder: (_, _) => const Scaffold(body: Text('Settings Page')), + ), + ], + ), + ); + } + + 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.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); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final button = tester.widget(find.byType(ElevatedButton)); + final color = button.style?.backgroundColor?.resolve({}); + expect(color, Colors.grey); + }); + + testWidgets('calls markAttemptedSubmit on button press with auto-scroll', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final submitButton = find.text('Submit Maintenance Request'); + + await tester.dragUntilVisible( + submitButton, + find.byType(SingleChildScrollView), + const Offset(0, -200), + ); + await tester.pumpAndSettle(); + + await tester.tap(submitButton); + await tester.pump(); + + verify(() => mockController.markAttemptedSubmit()).called(1); + }); + + testWidgets('shows loading indicator when controller is loading', (tester) async { + when(() => mockController.isLoading).thenReturn(true); + + await tester.pumpWidget(createWidget()); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + 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); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final submitButton = find.text('Submit Maintenance Request'); + + await tester.dragUntilVisible( + submitButton, + find.byType(SingleChildScrollView), + const Offset(0, -200), + ); + await tester.pumpAndSettle(); + + 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); + }); + }); +} \ 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 00000000..35bc2eba --- /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/location_selector_test.dart b/test/features/maintenance_request/widgets/location_selector_test.dart new file mode 100644 index 00000000..0e9cdccc --- /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 new file mode 100644 index 00000000..bf5c1995 --- /dev/null +++ b/test/features/maintenance_request/widgets/maintenance_form_test.dart @@ -0,0 +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:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockMaintenanceController extends Mock implements MaintenanceController {} +class FakeBuildContext extends Fake implements BuildContext {} + +void main() { + late MockMaintenanceController controller; + + Widget buildForm() { + return MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: 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.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())); + }); + + 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/settings/settings_test.dart b/test/features/settings/settings_test.dart index 34aae786..4cf2d0e5 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