Initial commit
This commit is contained in:
563
lib/shared/state/app_state.dart
Normal file
563
lib/shared/state/app_state.dart
Normal file
@@ -0,0 +1,563 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:firebase_auth/firebase_auth.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 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 = '';
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
void setPhone(String value) {
|
||||
phone = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> sendOtp(String phoneNumber, {String? firstName, String? email, bool resetPin = false, required Function onSuccess, required Function(String) onError}) async {
|
||||
final fullPhone = '+91${phoneNumber.replaceAll(' ', '')}';
|
||||
setPhone(phoneNumber);
|
||||
pendingFirstName = firstName;
|
||||
pendingEmail = email;
|
||||
isResettingPin = resetPin;
|
||||
|
||||
try {
|
||||
if (kIsWeb) {
|
||||
debugPrint('⚠️ RUNNING ON WEB: Bypassing Firebase OTP (Mocking success)');
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
verificationId = 'mock_verification_id_$fullPhone';
|
||||
onSuccess();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('====================================');
|
||||
debugPrint('🚀 INITIATING LIVE FIREBASE OTP FOR: $fullPhone');
|
||||
await FirebaseAuth.instance.verifyPhoneNumber(
|
||||
phoneNumber: fullPhone,
|
||||
verificationCompleted: (PhoneAuthCredential credential) async {
|
||||
debugPrint('✅ VERIFICATION COMPLETED AUTOMATICALLY');
|
||||
try {
|
||||
await FirebaseAuth.instance.signInWithCredential(credential);
|
||||
} catch (e) {
|
||||
debugPrint('❌ ERROR SIGNING IN: $e');
|
||||
onError(e.toString());
|
||||
}
|
||||
},
|
||||
verificationFailed: (FirebaseAuthException e) {
|
||||
debugPrint('❌ VERIFICATION FAILED: ${e.code} - ${e.message}');
|
||||
onError(e.message ?? 'Verification failed');
|
||||
},
|
||||
codeSent: (String vId, int? resendToken) {
|
||||
debugPrint('✅ CODE SUCCESSFULLY SENT. Verification ID: $vId');
|
||||
verificationId = vId;
|
||||
onSuccess();
|
||||
},
|
||||
codeAutoRetrievalTimeout: (String vId) {
|
||||
debugPrint('⚠️ AUTO RETRIEVAL TIMEOUT. Verification ID: $vId');
|
||||
verificationId = vId;
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('💥 FIREBASE OTP CRASHED COMPLETELY: $e');
|
||||
onError('Firebase OTP Error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> verifyOtp(String smsCode, {required Function onSuccess, required Function(String) onError}) async {
|
||||
if (verificationId == null) {
|
||||
onError('Verification ID is missing. Please resend OTP.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (kIsWeb) {
|
||||
debugPrint('⚠️ RUNNING ON WEB: Bypassing Firebase OTP verification (Mocking success)');
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (smsCode.length == 6) {
|
||||
onSuccess();
|
||||
} else {
|
||||
onError('Invalid mock OTP code (must be 6 digits).');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
PhoneAuthCredential credential = PhoneAuthProvider.credential(
|
||||
verificationId: verificationId!,
|
||||
smsCode: smsCode,
|
||||
);
|
||||
await FirebaseAuth.instance.signInWithCredential(credential);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
onError('Error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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 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 (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 verifyRes = await http.post(
|
||||
Uri.parse(ApiConstants.verifyPin),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({"phone": rawPhone, "pin": pin, "configid": 1001}),
|
||||
);
|
||||
|
||||
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;
|
||||
// Wipe hardcoded mock data so real users only see their own actual data!
|
||||
orders.clear();
|
||||
savedAddresses.clear();
|
||||
_saveSession();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
isLoggedIn = false;
|
||||
authToken = null;
|
||||
// Reset in-memory user details so the previous account does not linger.
|
||||
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();
|
||||
|
||||
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,
|
||||
"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');
|
||||
|
||||
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": "560001", // Default pincode until we parse from GPS
|
||||
"deliverypincode": draft.dropPincode,
|
||||
"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');
|
||||
|
||||
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,
|
||||
),
|
||||
AddressEntry(
|
||||
label: 'Work',
|
||||
line: 'Cyber City Building 10, DLF Phase 2',
|
||||
icon: Icons.work_rounded,
|
||||
iconBg: AppColors.tertiaryFixed,
|
||||
iconColor: AppColors.tertiary,
|
||||
),
|
||||
AddressEntry(
|
||||
label: 'Select City Walk Mall',
|
||||
line: 'Saket District Centre, New Delhi',
|
||||
icon: Icons.history_rounded,
|
||||
iconBg: AppColors.surfaceContainerHigh,
|
||||
iconColor: AppColors.onSurfaceVariant,
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user