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