import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'package:geolocator/geolocator.dart'; import 'package:doormile/core/constants/api_constants.dart'; import 'package:doormile/shared/models/models.dart'; import 'package:doormile/core/theme/app_colors.dart'; /// Holds the in-progress booking the user is composing across the flow screens. class BookingDraft { String pickupAddress = ''; String pickupPincode = ''; double pickupLatitude = 0.0; double pickupLongitude = 0.0; String contactName = ''; String contactMobile = ''; String notes = ''; String destination = ''; String service = ''; String parcelType = ''; String weight = '2 kg'; String size = 'Small'; String pickupDay = 'Today'; String pickupSlot = '10:00 AM - 12:00 PM'; bool fragile = false; String dropAddress = ''; String dropPincode = ''; String paymentMethod = 'Cash'; bool needsInsurance = false; double declaredValue = 0.0; double minPrice = 45.0; double maxPrice = 86.0; void reset() { pickupAddress = ''; pickupPincode = ''; pickupLatitude = 0.0; pickupLongitude = 0.0; contactName = ''; contactMobile = ''; notes = ''; destination = ''; service = ''; parcelType = ''; weight = '2 kg'; size = 'Small'; pickupDay = 'Today'; pickupSlot = '10:00 AM - 12:00 PM'; fragile = false; dropAddress = ''; dropPincode = ''; paymentMethod = 'Cash'; needsInsurance = false; declaredValue = 0.0; minPrice = 45.0; maxPrice = 86.0; } } /// Global, in-memory app state — auth, wallet, orders, and the booking draft. class AppState extends ChangeNotifier { // ---- Auth ---- bool isLoggedIn = false; String phone = ''; String? verificationId; String userName = ''; // Updated via profile/login String firstName = ''; String? pendingFirstName; String? pendingEmail; String? authToken; bool isResettingPin = false; String? _deviceToken; AppState() { _loadSession(); } Future _loadSession() async { final prefs = await SharedPreferences.getInstance(); authToken = prefs.getString('auth_token'); if (authToken != null && authToken!.isNotEmpty) { isLoggedIn = true; firstName = prefs.getString('first_name') ?? 'User'; userName = prefs.getString('user_name') ?? 'User'; phone = prefs.getString('phone') ?? ''; // Wipe mock data because this is a real authenticated session orders.clear(); savedAddresses.clear(); notifyListeners(); } } Future _saveSession() async { final prefs = await SharedPreferences.getInstance(); if (authToken != null) await prefs.setString('auth_token', authToken!); await prefs.setString('first_name', firstName); await prefs.setString('user_name', userName); await prefs.setString('phone', phone); } Future _fetchDeviceToken() async { try { if (kIsWeb) return null; await FirebaseMessaging.instance.requestPermission(alert: true, badge: true, sound: true); _deviceToken = await FirebaseMessaging.instance.getToken(); return _deviceToken; } catch (e) { debugPrint('FCM token error: $e'); return null; } } void setPhone(String value) { phone = value; notifyListeners(); } /// Sends a 6-digit OTP to [phoneNumber] via Firebase Phone Auth. Future sendOtp(String phoneNumber, {String? firstName, String? email, bool resetPin = false, required Function onSuccess, required Function(String) onError}) async { setPhone(phoneNumber); pendingFirstName = firstName ?? pendingFirstName; pendingEmail = email ?? pendingEmail; isResettingPin = resetPin; final rawPhone = phoneNumber.replaceAll(' ', ''); if (rawPhone.length != 10) { onError('A valid mobile number is required for verification.'); return; } final fullPhone = '+91$rawPhone'; if (kIsWeb) { debugPrint('[OTP Service] Web build: bypassing Firebase OTP (mocking success)'); Future.delayed(const Duration(seconds: 1), () { verificationId = 'mock_verification_id_$fullPhone'; onSuccess(); }); return; } try { debugPrint('[OTP Service] Initiating Firebase phone verification for $fullPhone'); await FirebaseAuth.instance.verifyPhoneNumber( phoneNumber: fullPhone, verificationCompleted: (PhoneAuthCredential credential) async { debugPrint('[OTP Service] Verification completed automatically'); try { await FirebaseAuth.instance.signInWithCredential(credential); } catch (e) { debugPrint('[OTP Service] Error signing in: $e'); } }, verificationFailed: (FirebaseAuthException e) { debugPrint('[OTP Service] Verification failed: ${e.code} - ${e.message}'); onError(e.message ?? 'Failed to send verification code.'); }, codeSent: (String vId, int? resendToken) { debugPrint('[OTP Service] Code sent. Verification ID: $vId'); verificationId = vId; onSuccess(); }, codeAutoRetrievalTimeout: (String vId) { debugPrint('[OTP Service] Auto retrieval timeout. Verification ID: $vId'); verificationId = vId; }, ); } catch (e) { debugPrint('[OTP Service] Exception during OTP request: $e'); onError('Error sending verification code: $e'); } } /// Debug-only tester shortcut: if `smsDefaultProvider`/`smsPassKey` are set /// in SharedPreferences, that fixed passkey is accepted instead of the real /// OTP. `kDebugMode` means this branch is compiled out of release builds /// entirely, so it can never ship as a bypass. Future _checkDebugBypass(String smsCode) async { if (!kDebugMode) return false; final prefs = await SharedPreferences.getInstance(); final provider = prefs.getInt('smsDefaultProvider') ?? 0; final passKey = prefs.getInt('smsPassKey'); return provider == 1 && passKey != null && smsCode == passKey.toString().padLeft(6, '0'); } Future verifyOtp(String smsCode, {required Function onSuccess, required Function(String) onError}) async { if (await _checkDebugBypass(smsCode)) { onSuccess(); return; } if (verificationId == null) { onError('Verification ID is missing. Please resend OTP.'); return; } if (kIsWeb) { debugPrint('[OTP Service] Web build: bypassing Firebase OTP verification (mocking success)'); if (smsCode.length == 6) { onSuccess(); } else { onError('Invalid mock OTP code (must be 6 digits).'); } return; } try { final credential = PhoneAuthProvider.credential( verificationId: verificationId!, smsCode: smsCode, ); await FirebaseAuth.instance.signInWithCredential(credential); onSuccess(); } catch (e) { onError('Invalid verification code.'); } } // --- Backend Wrappers --- Future registerWithPin(String pin) async { final rawPhone = phone.replaceAll(' ', ''); final parts = (pendingFirstName ?? 'User').split(' '); final first = parts.first; final last = parts.length > 1 ? parts.sublist(1).join(' ') : ''; double lat = 0.0; double lng = 0.0; try { bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (serviceEnabled) { LocationPermission permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); } if (permission == LocationPermission.whileInUse || permission == LocationPermission.always) { Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.medium); lat = position.latitude; lng = position.longitude; } } } catch (e) { debugPrint('Error getting location for registration: $e'); } final token = await _fetchDeviceToken(); final res = await http.post( Uri.parse(ApiConstants.register), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ "firstname": first, "lastname": last, "phone": rawPhone, "email": pendingEmail ?? '', "pin": pin, "configid": 1001, "defaultlatitude": lat, "defaultlongitude": lng, if (token != null) "device_token": token, }), ); if (res.statusCode >= 200 && res.statusCode < 300) { final data = jsonDecode(res.body); authToken = data['token']; if (data['user'] != null && data['user']['firstname'] != null) { firstName = data['user']['firstname']; userName = '$firstName ${data['user']['lastname'] ?? ''}'.trim(); } else { firstName = pendingFirstName ?? 'User'; userName = pendingFirstName ?? 'User'; } login(); } else { throw Exception('Registration Failed: ${res.body}'); } } Future initiateLogin(String phoneNumber) async { final rawPhone = phoneNumber.replaceAll(' ', ''); setPhone(phoneNumber); // Easter Egg if (rawPhone == '9876543210') { return; } final initRes = await http.post( Uri.parse(ApiConstants.login), headers: {'Content-Type': 'application/json'}, body: jsonEncode({"phone": rawPhone, "configid": 1001}), ); if (initRes.statusCode < 200 || initRes.statusCode >= 300) { throw Exception('Phone number not registered. Please sign up first.'); } } Future verifyPinWithBackend(String pin) async { final rawPhone = phone.replaceAll(' ', ''); // Easter Egg if (rawPhone == '9876543210' && pin == '1234') { authToken = 'easter-egg-token'; firstName = 'Admin'; userName = 'Admin User'; login(); return; } final token = await _fetchDeviceToken(); final verifyRes = await http.post( Uri.parse(ApiConstants.verifyPin), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ "phone": rawPhone, "pin": pin, "configid": 1001, if (token != null) "device_token": token, }), ); if (verifyRes.statusCode >= 200 && verifyRes.statusCode < 300) { final data = jsonDecode(verifyRes.body); authToken = data['token']; if (data['user'] != null && data['user']['firstname'] != null) { firstName = data['user']['firstname']; userName = '$firstName ${data['user']['lastname'] ?? ''}'.trim(); } login(); } else { throw Exception('Incorrect PIN. Please try again.'); } } Future resetPinWithBackend(String newPin) async { final rawPhone = phone.replaceAll(' ', ''); // TODO: Create this endpoint in Go backend final res = await http.post( Uri.parse('${ApiConstants.baseUrl}/customer/reset-pin'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({"phone": rawPhone, "new_pin": newPin}), ); if (res.statusCode >= 200 && res.statusCode < 300) { final data = jsonDecode(res.body); if (data['token'] != null) authToken = data['token']; login(); } else { throw Exception('PIN Reset Failed: ${res.body}'); } } void login() { isLoggedIn = true; orders.clear(); savedAddresses.clear(); _saveSession(); notifyListeners(); } Future logout() async { isLoggedIn = false; authToken = null; firstName = 'User'; userName = 'User'; phone = ''; verificationId = null; pendingFirstName = null; pendingEmail = null; orders.clear(); savedAddresses.clear(); notifyListeners(); final prefs = await SharedPreferences.getInstance(); await prefs.remove('auth_token'); await prefs.remove('first_name'); await prefs.remove('user_name'); await prefs.remove('phone'); } // ---- Booking draft ---- final BookingDraft draft = BookingDraft(); void touch() => notifyListeners(); // ---- Orders ---- List orders = []; bool isLoadingOrders = false; /// Fetches real bookings from the Go backend Future fetchOrders() async { if (authToken == null) return; isLoadingOrders = true; notifyListeners(); if (authToken == 'easter-egg-token') { await Future.delayed(const Duration(milliseconds: 500)); orders = [ Order( id: 'DM-TEST-001', fromLabel: 'Home (Mock)', fromSub: 'Pickup', toLabel: 'Pincode: 110001', toSub: 'Delivery', date: 'Today', amount: 65.0, status: OrderStatus.active, progress: 0.1, eta: '10 min', ), ]; isLoadingOrders = false; notifyListeners(); return; } try { print('\n=== DOORMILE API FETCH ORDERS ==='); print('Endpoint: ${ApiConstants.getPickups}'); final res = await http.get( Uri.parse(ApiConstants.getPickups), headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer $authToken', }, ); print('Status Code: ${res.statusCode}'); if (res.statusCode >= 200 && res.statusCode < 300) { final List data = jsonDecode(res.body); orders = data.map((b) { return Order( id: b['booking_no'] ?? 'DM-UNK', fromLabel: b['pickupaddress'] ?? 'Unknown Location', fromSub: 'Pickup', toLabel: b['deliverypincode'] != null ? 'Pincode: ${b['deliverypincode']}' : 'Pending Destination', toSub: 'Delivery', date: b['created_at']?.split('T')[0] ?? 'Today', amount: (b['estimated_price'] ?? 0.0).toDouble(), status: _parseBackendStatus(b['status']), progress: b['status'] == 'Pending_Pickup' ? 0.1 : 0.5, ); }).toList(); } } catch (e) { print('Failed to fetch orders: $e'); } finally { isLoadingOrders = false; notifyListeners(); } } OrderStatus _parseBackendStatus(String? status) { if (status == 'Pending_Pickup') return OrderStatus.active; if (status == 'Delivered') return OrderStatus.delivered; if (status == 'Cancelled') return OrderStatus.cancelled; return OrderStatus.active; } /// Fetches estimated pricing from the Redis-backed Go API Future fetchPriceEstimate() async { // Map UI categories to Backend enums String backendCategory = "General"; switch (draft.parcelType) { case "General Goods": backendCategory = "General"; break; case "Books & Documents": backendCategory = "Documents"; break; case "Electronics & Gadgets": backendCategory = "Electronics"; break; case "Clothing & Textiles": backendCategory = "Clothing"; break; case "Fragile Items": backendCategory = "Fragile"; break; case "Medical & Pharma": backendCategory = "Medical"; break; case "Automotive Parts": backendCategory = "Automotive"; break; case "Food & Perishables": backendCategory = "Food"; break; default: backendCategory = "General"; } final payload = { "zone": "Local", "destinationpin": draft.dropPincode, "pickuppincode": draft.pickupPincode, "service_type": draft.service.isNotEmpty ? draft.service : "Normal", "weight": 1.0, // Default since we removed it from UI "category": backendCategory }; print('\n=== DOORMILE API PRICING REQUEST ==='); print('Endpoint: ${ApiConstants.checkPrice}'); print('Payload: ${jsonEncode(payload)}'); print('====================================\n'); if (authToken == 'easter-egg-token') { await Future.delayed(const Duration(milliseconds: 500)); draft.minPrice = 50.0; draft.maxPrice = 120.0; notifyListeners(); return; } try { final res = await http.post( Uri.parse(ApiConstants.checkPrice), headers: {'Content-Type': 'application/json'}, body: jsonEncode(payload), ); print('\n=== DOORMILE API PRICING RESPONSE ==='); print('Status Code: ${res.statusCode}'); print('Response Body: ${res.body}'); print('=============================\n'); if (res.statusCode >= 200 && res.statusCode < 300) { final data = jsonDecode(res.body); if (data['results'] != null && data['results'].isNotEmpty) { final result = data['results'][0]; draft.minPrice = (result['min_price'] as num).toDouble(); draft.maxPrice = (result['max_price'] as num).toDouble(); notifyListeners(); } } } catch (e) { print('Pricing API error: $e'); // On error, we just keep the default 45 - 86 fallback } } /// Confirms the current draft into a new active order. Future placeOrder() async { // Map UI categories to Backend enums String backendCategory = "General"; switch (draft.parcelType) { case "General Goods": backendCategory = "General"; break; case "Books & Documents": backendCategory = "Documents"; break; case "Electronics & Gadgets": backendCategory = "Electronics"; break; case "Clothing & Textiles": backendCategory = "Clothing"; break; case "Fragile Items": backendCategory = "Fragile"; break; case "Medical & Pharma": backendCategory = "Medical"; break; case "Automotive Parts": backendCategory = "Automotive"; break; case "Food & Perishables": backendCategory = "Food"; break; default: backendCategory = "General"; } final payload = { "pickupaddress": draft.pickupAddress.isNotEmpty ? draft.pickupAddress : "Current Location (GPS)", "pickuppincode": draft.pickupPincode, "pickuplatitude": draft.pickupLatitude, "pickuplongitude": draft.pickupLongitude, "deliverypincode": draft.dropPincode, "deliveryaddress": draft.dropAddress, "serviceoption": draft.service, "contactname": draft.contactName, "contactmobile": draft.contactMobile, "parcels": [ { "itemcategory": backendCategory, "itemdescription": draft.notes.isNotEmpty ? draft.notes : "No description provided" } ] }; print('\n=== DOORMILE API BOOKING REQUEST ==='); print('Endpoint: ${ApiConstants.createPickup}'); print('Payload: ${jsonEncode(payload)}'); print('====================================\n'); if (authToken == 'easter-egg-token') { await Future.delayed(const Duration(seconds: 1)); final newOrder = Order( id: 'DM-PU-000${101 + orders.length}', fromLabel: draft.pickupAddress, fromSub: draft.contactName.isEmpty ? 'Pickup requested' : 'Contact: ${draft.contactName}', toLabel: draft.dropPincode.isNotEmpty ? 'Pincode: ${draft.dropPincode}' : 'Awaiting Miler input', toSub: 'Drop details pending', date: 'Today', amount: draft.minPrice, status: OrderStatus.active, progress: 0.1, eta: 'Assigning Miler...', ); orders.insert(0, newOrder); draft.reset(); notifyListeners(); return newOrder; } final res = await http.post( Uri.parse(ApiConstants.createPickup), headers: { 'Content-Type': 'application/json', if (authToken != null) 'Authorization': 'Bearer $authToken', }, body: jsonEncode(payload), ); print('\n=== DOORMILE API RESPONSE ==='); print('Status Code: ${res.statusCode}'); print('Response Body: ${res.body}'); print('=============================\n'); if (res.statusCode >= 200 && res.statusCode < 300) { final data = jsonDecode(res.body); final newOrder = Order( id: data['booking_no'] ?? 'DM-PU-000${101 + orders.length}', fromLabel: draft.pickupAddress, fromSub: draft.contactName.isEmpty ? 'Pickup requested' : 'Contact: ${draft.contactName}', toLabel: draft.dropPincode.isNotEmpty ? 'Pincode: ${draft.dropPincode}' : 'Awaiting Miler input', toSub: 'Drop details pending', date: 'Today', amount: draft.minPrice, status: OrderStatus.active, progress: 0.1, eta: 'Assigning Miler...', ); orders.insert(0, newOrder); draft.reset(); notifyListeners(); return newOrder; } else { throw Exception('Failed to create pickup request: ${res.body}'); } } // ---- Saved addresses ---- List savedAddresses = [ AddressEntry( label: 'Home', line: 'Sector 45, Gurgaon, Haryana, 122003', icon: Icons.home_rounded, iconBg: AppColors.primaryFixed, iconColor: AppColors.primary, pincode: '122003', ), AddressEntry( label: 'Work', line: 'Cyber City Building 10, DLF Phase 2', icon: Icons.work_rounded, iconBg: AppColors.tertiaryFixed, iconColor: AppColors.tertiary, pincode: '122002', ), AddressEntry( label: 'Select City Walk Mall', line: 'Saket District Centre, New Delhi', icon: Icons.history_rounded, iconBg: AppColors.surfaceContainerHigh, iconColor: AppColors.onSurfaceVariant, pincode: '110017', ), ]; }