From e50bc3ebba4fc44adbaca79e62ce0e231d6b6067 Mon Sep 17 00:00:00 2001 From: Joshua Miller Date: Mon, 6 Apr 2026 13:12:18 -0400 Subject: [PATCH 01/16] 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 02/16] 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 03/16] 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 04/16] 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 05/16] 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 06/16] 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 07/16] 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 08/16] 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 09/16] 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 10/16] 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 11/16] 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 12/16] 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 13/16] 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 14/16] 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 15/16] 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 16/16] 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', ];