Files
doormile_customer_app/lib/shared/state/app_state.dart
2026-07-23 11:11:25 +05:30

673 lines
21 KiB
Dart

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<void> _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<void> _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<String?> _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<void> 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<bool> _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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<Order> orders = [];
bool isLoadingOrders = false;
/// Fetches real bookings from the Go backend
Future<void> 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<dynamic> 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<void> 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<Order> 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<AddressEntry> 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',
),
];
}