Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
Expand Down
11 changes: 11 additions & 0 deletions lib/core/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
174 changes: 174 additions & 0 deletions lib/features/maintenance_request/controller.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import 'package:image_picker/image_picker.dart';
import 'dart:io';
import 'package:clean_stream_laundry_app/logic/services/auth_service.dart';
import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart';
import 'package:clean_stream_laundry_app/logic/services/location_service.dart';
import 'package:clean_stream_laundry_app/logic/services/profile_service.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

class MaintenanceController extends ChangeNotifier {
final EdgeFunctionService edgeFunctionService;
final ProfileService profileService;
final AuthService authService;
final LocationService locationService = GetIt.instance<LocationService>();

MaintenanceController({
EdgeFunctionService? edgeFunctionService,
ProfileService? profileService,
AuthService? authService,
}) : edgeFunctionService =
edgeFunctionService ?? GetIt.instance<EdgeFunctionService>(),
profileService = profileService ?? GetIt.instance<ProfileService>(),
authService = authService ?? GetIt.instance<AuthService>();

final TextEditingController descriptionController = TextEditingController();

final List<String> categories = const [
'Washer/Dryer Maintenance',
'App Maintenance',
'Location Maintenance',
'Other',
];

String? selectedCategory;
List<dynamic> locations = [];
String? selectedLocation;
File? selectedImage;

bool attemptedSubmit = false;
bool isLoading = false;

Future<void> init() async {
isLoading = true;
await _loadLocations();
isLoading = false;
notifyListeners();
}

Future<void> _loadLocations() async {
try {
final List<dynamic> fetchedLocations = await locationService.getLocations();

locations = fetchedLocations
.map((loc) => loc['Address']?.toString() ?? '')
.where((address) => address.isNotEmpty)
.toList();
} catch (e) {
locations = [];
}
}

Future<bool> _ensurePermissions() async {
final camera = await Permission.camera.request();
final photos = await Permission.photos.request();
return camera.isGranted && photos.isGranted;
}

Future<void> pickImage(BuildContext context) async {
if (!await _ensurePermissions()) return;

final source = await showModalBottomSheet<ImageSource>(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('Take Photo'),
onTap: () => Navigator.pop(ctx, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('Choose from Library'),
onTap: () => Navigator.pop(ctx, ImageSource.gallery),
),
],
),
);
},
);

if (source == null) return;

final picker = ImagePicker();
final picked = await picker.pickImage(source: source);

if (picked != null) {
selectedImage = File(picked.path);
notifyListeners();
}
}

void selectCategory(String category) {
selectedCategory = category;
notifyListeners();
}

bool get isFormValid =>
selectedCategory != null &&
selectedLocation != null &&
descriptionController.text.trim().isNotEmpty;

void selectLocation(String location) {
selectedLocation = location;
notifyListeners();
}

void markAttemptedSubmit() {
attemptedSubmit = true;
notifyListeners();
}

Future<bool> submitMaintenance() async {
final userId = authService.getCurrentUserId;
if (userId == null) return false;

isLoading = true;
notifyListeners();

try {
String? imageUrl;

if (selectedImage != null) {
final fileName = '${DateTime.now().millisecondsSinceEpoch}_${userId.hashCode}.jpg';
final path = 'requests/$fileName';
await Supabase.instance.client.storage
.from('maintenance-images')
.upload(path, selectedImage!);

imageUrl = Supabase.instance.client.storage
.from('maintenance-images')
.getPublicUrl(path);
}

await edgeFunctionService.runEdgeFunction(
name: 'maintenance-request',
body: {
'user_id': userId,
'category': selectedCategory,
'location': selectedLocation,
'description': descriptionController.text,
'image': imageUrl,
},
);
return true;
} catch (e) {
return false;
} finally {
isLoading = false;
notifyListeners();
}
}

void disposeController() {
descriptionController.dispose();
}
}
144 changes: 144 additions & 0 deletions lib/features/maintenance_request/maintenance_request.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import 'controller.dart';
import 'widgets/maintenance_form.dart';
import 'package:clean_stream_laundry_app/core/theme/theme.dart';
import 'package:clean_stream_laundry_app/features/widgets/status_dialog_box.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class MaintenancePage extends StatefulWidget {
final MaintenanceController? controller;
const MaintenancePage({super.key, this.controller});

@override
State<MaintenancePage> createState() => MaintenancePageState();
}

class MaintenancePageState extends State<MaintenancePage> {
late final MaintenanceController _controller;
final FocusNode _focusNode = FocusNode();

@override
void initState() {
super.initState();
_controller = widget.controller ?? MaintenanceController();

WidgetsBinding.instance.addPostFrameCallback((_) {
_controller.init();
});
_controller.addListener(_onControllerChanged);
}

void _onControllerChanged() {
if (mounted) setState(() {});
}

@override
void dispose() {
_controller.removeListener(_onControllerChanged);
_controller.disposeController();
if (widget.controller == null) {
_controller.dispose();
}
_focusNode.dispose();
super.dispose();
}

Future<void> _onSubmitPressed() async {
_controller.markAttemptedSubmit();
if (!_controller.isFormValid) return;
await _handleMaintenance();
}

Future<void> _handleMaintenance() async {
try {
final success = await _controller.submitMaintenance();
if (!mounted) return;

if (!success) return;

_showMaintenanceDialog();
} catch (e) {
if (!mounted) return;
}
}

void _showMaintenanceDialog() {
statusDialog(
context,
title: 'Success',
message: 'Your maintenance request has been submitted',
isSuccess: true,
).then((_) {
if (mounted) context.go('/settings');
});
}

@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;

return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back, color: Colors.white),
),
backgroundColor: Colors.transparent,
title: const Text('Request Maintenance',
style: TextStyle(color: Colors.white)),
centerTitle: true,
flexibleSpace: Container(
decoration: BoxDecoration(
gradient: colorScheme.primaryGradient,
),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 28),
MaintenanceForm(controller: _controller),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _controller.isLoading ? null : _onSubmitPressed,
style: ElevatedButton.styleFrom(
backgroundColor: _controller.isFormValid
? colorScheme.primary
: Colors.grey,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: _controller.isFormValid ? 2 : 0,
),
child: _controller.isLoading
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
)
: const Text(
'Submit Maintenance Request',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 12),
const SizedBox(height: 4),
],
),
),
);
}
}
Loading
Loading