564 lines
18 KiB
Dart
564 lines
18 KiB
Dart
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,
|
|
),
|
|
];
|
|
|
|
|
|
}
|