initial commit: push everything

This commit is contained in:
2026-07-06 20:27:53 +05:30
commit df4e044d74
329 changed files with 36620 additions and 0 deletions

View File

@@ -0,0 +1,995 @@
part of '../deliveries/deliveries.dart';
// -------------------------------------------------------------------------
// CART PAGE (Active Deliveries)
// -------------------------------------------------------------------------
class Cartpage extends StatefulWidget {
const Cartpage({super.key});
@override
State<Cartpage> createState() => _CartpageState();
}
class _CartpageState extends State<Cartpage> with AutomaticKeepAliveClientMixin {
final DeliveryProvider _provider = DeliveryProvider();
final CreateDeliveryLogProvider _deliveryLogProvider = CreateDeliveryLogProvider();
List<Map<String, dynamic>> _activeDeliveries = <Map<String, dynamic>>[];
StreamSubscription<void>? _pollerSubscription;
bool _fetching = false;
final Map<String, Timer> _deliveryTimers = <String, Timer>{};
final Map<String, Map<String, dynamic>> _deliveryBasePayload = <String, Map<String, dynamic>>{};
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_fetchActive();
_startPolling();
}
@override
void dispose() {
_pollerSubscription?.cancel();
_stopAllTimers();
super.dispose();
}
void _startPolling() {
_pollerSubscription?.cancel();
_pollerSubscription = Stream.periodic(
const Duration(seconds: 10),
(_) {},
).asyncMap((_) async {
if (!_fetching && mounted) {
await _fetchActive();
}
}).listen(
(_) {},
onError: (error) {
debugPrint('[CART][STREAM ERROR] $error');
},
cancelOnError: false,
);
}
Future<void> _fetchActive() async {
if (_fetching) return;
_fetching = true;
try {
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0;
if (userId == 0) {
debugPrint('[CART] No user ID found');
_fetching = false;
return;
}
// Get current date in YYYY-MM-DD format
final now = DateTime.now();
final today = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
// Hardcoded API endpoint for cart page only: v2/deliveries/getdeliveries
final bool isLive = ApiConstants.mainRoute == 'live';
final baseUrl = isLive
? 'https://jupiter.nearle.app/live/api/v2/deliveries/getdeliveries'
: 'https://jupiter.nearle.app/dev/api/v2/deliveries/getdeliveries';
final uri = Uri.parse(baseUrl).replace(queryParameters: {
'userid': userId.toString(),
'fromdate': today,
'todate': today,
't': DateTime.now().millisecondsSinceEpoch.toString(),
});
debugPrint('[CART] Fetching from: $uri');
// Fetch deliveries from API directly using http
// Hardcoded endpoint for cart page: v2/deliveries/getdeliveries
final httpClient = http.Client();
List<dynamic> items = [];
try {
final response = await httpClient.get(uri);
if (response.statusCode >= 200 && response.statusCode < 300) {
final decoded = json.decode(response.body);
final data = decoded is Map<String, dynamic>
? (decoded['details'] ?? decoded['data'] ?? decoded)
: decoded;
items = data is List
? data
: (data is Map && data['items'] is List ? data['items'] as List : []);
} else {
debugPrint('[CART] API error: ${response.statusCode}');
}
} catch (e) {
debugPrint('[CART] Error fetching from API: $e');
} finally {
httpClient.close();
}
debugPrint('[CART] Raw API items count: ${items.length}');
// Debug: Print all order statuses to see what we're getting
for (final item in items) {
if (item is Map<String, dynamic>) {
final orderId = (item['orderid'] ?? '').toString();
final status = (item['orderstatus'] ?? '').toString();
debugPrint('[CART] Order $orderId has status: "$status" (raw: ${item['orderstatus']})');
}
}
// Filter for ACTIVE status only
final activeOrders = items
.whereType<Map<String, dynamic>>()
.where((order) {
final status = (order['orderstatus']?.toString().toLowerCase() ?? '').trim();
final isActive = status == 'active';
if (isActive) {
debugPrint('[CART] ✅ Found active order: ${order['orderid']}');
} else {
debugPrint('[CART] ❌ Order ${order['orderid']} has status: "$status" (not active)');
}
return isActive;
})
.toList();
debugPrint('[CART] Found ${activeOrders.length} active deliveries out of ${items.length} total');
// Sort by order ID or step number if available
activeOrders.sort((a, b) {
final stepA = (a['step'] ?? a['Step'] ?? 0).toString();
final stepB = (b['step'] ?? b['Step'] ?? 0).toString();
final stepAInt = int.tryParse(stepA) ?? 0;
final stepBInt = int.tryParse(stepB) ?? 0;
if (stepAInt != stepBInt) return stepAInt.compareTo(stepBInt);
final orderIdA = (a['orderid'] ?? '').toString();
final orderIdB = (b['orderid'] ?? '').toString();
return orderIdA.compareTo(orderIdB);
});
if (mounted) {
setState(() {
_activeDeliveries = activeOrders;
});
}
// Get all active order IDs
final activeOrderIds = activeOrders
.map((o) => (o['orderid'] ?? '').toString())
.where((id) => id.isNotEmpty)
.toSet();
// ✅ CRITICAL: Start/restart timers for ALL active deliveries
// This ensures every active delivery posts logs every 30 seconds
for (final order in activeOrders) {
final orderId = (order['orderid'] ?? '').toString();
if (orderId.isEmpty) {
debugPrint('[CART] ⚠️ Skipping order with empty orderId');
continue;
}
// Always restart timer to ensure it's running (handles edge cases)
if (_deliveryTimers.containsKey(orderId)) {
debugPrint('[CART] 🔄 Restarting timer for active delivery: $orderId');
_deliveryTimers[orderId]?.cancel();
_deliveryTimers.remove(orderId);
// Also clear base payload to force reload
_deliveryBasePayload.remove(orderId);
}
debugPrint('[CART] ▶️ Starting timer for active delivery: $orderId');
await _startDeliveryPosting(order);
debugPrint('[CART] ✅ Timer started successfully for: $orderId');
}
// ✅ Stop timers for deliveries that are no longer active
final timersToStop = _deliveryTimers.keys
.where((id) => !activeOrderIds.contains(id))
.toList();
for (final id in timersToStop) {
debugPrint('[CART] Stopping timer for orderId: $id (no longer active)');
_stopDeliveryPosting(id);
}
debugPrint('[CART] Active timers: ${_deliveryTimers.keys.toList()}');
} catch (e) {
debugPrint('[CART] Error fetching active deliveries: $e');
} finally {
_fetching = false;
}
}
Future<void> _startDeliveryPosting(Map<String, dynamic> order) async {
final orderId = (order['orderid'] ?? '').toString();
if (orderId.isEmpty) {
debugPrint('[CART][DELIVERYLOG] ⚠️ Cannot start timer: empty orderId');
return;
}
// Safety check: If timer already exists, cancel it first (shouldn't happen after cleanup above)
if (_deliveryTimers.containsKey(orderId)) {
debugPrint('[CART][DELIVERYLOG] ⚠️ Timer already exists for $orderId, canceling old one');
_deliveryTimers[orderId]?.cancel();
_deliveryTimers.remove(orderId);
}
debugPrint('[CART][DELIVERYLOG] 🚀 Starting 30-second timer for orderId: $orderId');
// Get starttime from SharedPreferences (saved when order became active via updateActiveStatus)
// If not found, use activetime from order data, or current time as fallback
String startTime = '';
try {
final prefs = await SharedPreferences.getInstance();
final deliveryId = (order['deliveryid'] ?? 0).toString();
// Method 1: Get from SharedPreferences (saved when order became active)
startTime = prefs.getString('delivery_starttime_$deliveryId') ?? '';
if (startTime.isNotEmpty) {
debugPrint('[CART][DELIVERYLOG] ✅ Loaded starttime from SharedPreferences: $startTime');
}
// Method 2: Fallback - try to get from order data (starttime field)
if (startTime.isEmpty) {
startTime = (order['starttime'] ?? order['startTime'] ?? '').toString();
if (startTime.isNotEmpty) {
debugPrint('[CART][DELIVERYLOG] ✅ Loaded starttime from order data: $startTime');
}
}
// Method 3: Fallback - try activetime from order data
if (startTime.isEmpty) {
final activetime = (order['activetime'] ?? order['activTime'] ?? '').toString();
if (activetime.isNotEmpty) {
startTime = activetime;
debugPrint('[CART][DELIVERYLOG] ✅ Loaded starttime from activetime: $startTime');
}
}
// Method 4: Last fallback - current time (shouldn't happen if updateActiveStatus was called)
if (startTime.isEmpty) {
final now = DateTime.now();
startTime = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
debugPrint('[CART][DELIVERYLOG] ⚠️ Using current time as starttime fallback: $startTime');
}
} catch (e) {
debugPrint('[CART][DELIVERYLOG] ❌ Error getting starttime: $e');
// Set a fallback starttime even on error
final now = DateTime.now();
startTime = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
}
// CRITICAL: Ensure starttime is never empty
if (startTime.isEmpty) {
final now = DateTime.now();
startTime = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
debugPrint('[CART][DELIVERYLOG] ⚠️ Final fallback: starttime was empty, using: $startTime');
}
debugPrint('[CART][DELIVERYLOG] 📝 Final starttime for orderId $orderId: $startTime');
// Create base payload with starttime
final base = <String, dynamic>{
'logid': 0,
'tenantid': order['tenantid'] ?? 0,
'partnerid': order['partnerid'] ?? 0,
'locationid': order['locationid'] ?? 0,
'orderheaderid': order['orderheaderid'] ?? 0,
'deliveryid': order['deliveryid'] ?? 0,
'userid': order['userid'] ?? 0,
'orderid': orderId,
'orderstatus': 'active',
'starttime': startTime, // Include starttime in base payload
};
_deliveryBasePayload[orderId] = base;
// Save to SharedPreferences for persistence
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('deliverylog_${orderId}_tenantid', (base['tenantid'] ?? 0).toString());
await prefs.setString('deliverylog_${orderId}_partnerid', (base['partnerid'] ?? 0).toString());
await prefs.setString('deliverylog_${orderId}_locationid', (base['locationid'] ?? 0).toString());
await prefs.setString('deliverylog_${orderId}_orderheaderid', (base['orderheaderid'] ?? 0).toString());
await prefs.setString('deliverylog_${orderId}_deliveryid', (base['deliveryid'] ?? 0).toString());
await prefs.setString('deliverylog_${orderId}_userid', (base['userid'] ?? 0).toString());
await prefs.setString('deliverylog_${orderId}_orderid', orderId);
await prefs.setString('deliverylog_${orderId}_orderstatus', 'active');
await prefs.setString('deliverylog_${orderId}_starttime', startTime); // Save starttime
} catch (e) {
debugPrint('[CART][DELIVERYLOG] Error saving payload: $e');
}
// Post once immediately (don't await - let it run in background)
_postDeliveryLog(orderId, order);
debugPrint('[CART][DELIVERYLOG] 📤 Posted initial log for orderId: $orderId');
// Then every 30 seconds - CRITICAL: This ensures logs are posted every 30 seconds
final timer = Timer.periodic(const Duration(seconds: 30), (t) {
debugPrint('[CART][DELIVERYLOG] ⏰ Timer tick for orderId: $orderId (30 seconds elapsed)');
_postDeliveryLog(orderId, order);
});
_deliveryTimers[orderId] = timer;
debugPrint('[CART][DELIVERYLOG] ✅ Timer registered for orderId: $orderId (will post every 30 seconds)');
}
void _postDeliveryLog(String orderId, Map<String, dynamic> order) {
if (!mounted) {
debugPrint('[CART][DELIVERYLOG][POST] Widget disposed, skipping');
return;
}
debugPrint('[CART][DELIVERYLOG][POST] ⏰ Posting log for orderId: $orderId at ${DateTime.now()}');
// Use Future.microtask to ensure the async operation runs independently
Future.microtask(() => _performPost(orderId));
}
Future<void> _performPost(String orderId) async {
try {
debugPrint('[CART][DELIVERYLOG][POST] 🔄 Starting _performPost for orderId: $orderId');
Map<String, dynamic>? base = _deliveryBasePayload[orderId];
if (base == null) {
debugPrint('[CART][DELIVERYLOG][POST] Base is null, loading from SharedPreferences');
try {
final prefs = await SharedPreferences.getInstance();
if (!mounted) {
debugPrint('[CART][DELIVERYLOG][POST] Widget unmounted after prefs load');
return;
}
base = {
'logid': 0,
'tenantid': int.tryParse(prefs.getString('deliverylog_${orderId}_tenantid') ?? '0') ?? 0,
'partnerid': int.tryParse(prefs.getString('deliverylog_${orderId}_partnerid') ?? '0') ?? 0,
'locationid': int.tryParse(prefs.getString('deliverylog_${orderId}_locationid') ?? '0') ?? 0,
'orderheaderid': int.tryParse(prefs.getString('deliverylog_${orderId}_orderheaderid') ?? '0') ?? 0,
'deliveryid': int.tryParse(prefs.getString('deliverylog_${orderId}_deliveryid') ?? '0') ?? 0,
'userid': int.tryParse(prefs.getString('deliverylog_${orderId}_userid') ?? '0') ?? 0,
'orderid': prefs.getString('deliverylog_${orderId}_orderid') ?? orderId,
'orderstatus': prefs.getString('deliverylog_${orderId}_orderstatus') ?? 'active',
'starttime': prefs.getString('deliverylog_${orderId}_starttime') ?? '', // Load starttime
};
debugPrint('[CART][DELIVERYLOG][POST] Base loaded from prefs: $base');
} catch (e) {
debugPrint('[CART][DELIVERYLOG][POST] Error loading base: $e');
return;
}
}
// At this point, base is guaranteed to be non-null (either from cache or created above)
final basePayload = base; // Flow analysis ensures base is non-null here
debugPrint('[CART][DELIVERYLOG][POST] Getting coordinates...');
// CRITICAL: Get coordinates with retry logic - NEVER post with null or '0' coordinates
final coords = await _getValidCoordinates().timeout(
const Duration(seconds: 10), // Increased timeout to allow retries
onTimeout: () {
debugPrint('[CART][DELIVERYLOG][POST] ❌ Coordinate timeout after retries');
return null;
},
);
if (!mounted) {
debugPrint('[CART][DELIVERYLOG][POST] Widget unmounted after coords');
return;
}
// CRITICAL: Validate coordinates - NEVER post with null, '0', or invalid coordinates
if (coords == null || coords.$1.isEmpty || coords.$2.isEmpty ||
coords.$1 == '0' || coords.$2 == '0') {
debugPrint('[CART][DELIVERYLOG][POST] ❌ SKIPPING POST: Invalid coordinates (lat=${coords?.$1 ?? 'null'}, lng=${coords?.$2 ?? 'null'})');
debugPrint('[CART][DELIVERYLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)');
return; // Skip this post - don't send invalid coordinates
}
// Validate coordinate ranges
final latDouble = double.tryParse(coords.$1) ?? 0.0;
final lngDouble = double.tryParse(coords.$2) ?? 0.0;
if (latDouble == 0 || lngDouble == 0 ||
latDouble.abs() > 90 || lngDouble.abs() > 180) {
debugPrint('[CART][DELIVERYLOG][POST] ❌ SKIPPING POST: Invalid coordinate ranges (lat=$latDouble, lng=$lngDouble)');
debugPrint('[CART][DELIVERYLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)');
return; // Skip this post - don't send invalid coordinates
}
debugPrint('[CART][DELIVERYLOG][POST] ✅ Valid coordinates: lat=${coords.$1}, lng=${coords.$2}');
// Cumulative KM is tracked exclusively by LiveTrackingService (high-frequency, every 3s).
// Do not accumulate here to avoid race conditions with concurrent writers.
final now = DateTime.now();
final logdate = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
// CRITICAL: Ensure starttime is always included in payload
final starttimeValue = basePayload['starttime']?.toString() ?? '';
if (starttimeValue.isEmpty) {
debugPrint('[CART][DELIVERYLOG][POST] ⚠️ WARNING: starttime is empty in basePayload, using fallback');
}
// CRITICAL: Use validated coordinates - guaranteed to be non-null and valid at this point
final payload = {
...basePayload,
'logdate': logdate,
'latitude': coords.$1, // Guaranteed non-null and valid
'longitude': coords.$2, // Guaranteed non-null and valid
'starttime': starttimeValue.isNotEmpty ? starttimeValue : '', // CRITICAL: Always include starttime
};
// Validate payload has all required fields
final requiredFields = ['tenantid', 'partnerid', 'locationid', 'orderheaderid', 'deliveryid', 'userid', 'orderid', 'orderstatus', 'starttime'];
final missingFields = requiredFields.where((field) => payload[field] == null || payload[field] == '').toList();
if (missingFields.isNotEmpty) {
debugPrint('[CART][DELIVERYLOG][POST] ⚠️ WARNING: Missing fields in payload: $missingFields');
}
final url = ApiConstants.mainRoute == 'live'
? ApiConstants.createDeliveryLogLive
: ApiConstants.createDeliveryLogDev;
debugPrint('[CART][DELIVERYLOG][POST] 📤 Sending to API: $url');
debugPrint('[CART][DELIVERYLOG][POST] 📦 Payload: $payload');
debugPrint('[CART][DELIVERYLOG][POST] ✅ starttime in payload: "${payload['starttime']}"');
await _deliveryLogProvider
.createDeliveryLog(url, payload)
.timeout(
const Duration(seconds: 8),
onTimeout: () {
debugPrint('[CART][DELIVERYLOG][POST] ⚠️ API timeout for orderId: $orderId');
throw TimeoutException('API timeout', const Duration(seconds: 8));
},
);
debugPrint('[CART][DELIVERYLOG][POST] ✅ SUCCESS for orderId: $orderId at ${DateTime.now()}');
} catch (e, stackTrace) {
debugPrint('[CART][DELIVERYLOG][POST] ❌ ERROR for orderId: $orderId - $e');
debugPrint('[CART][DELIVERYLOG][POST] Stack trace: $stackTrace');
}
}
Future<(String lat, String lng)?> _getValidCoordinates({int retryCount = 0}) async {
const maxRetries = 3;
try {
final bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
debugPrint('[CART][COORDS] Location service disabled, trying last known position');
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && lastPos.latitude != 0 && lastPos.longitude != 0) {
final lat = lastPos.latitude.toString();
final lng = lastPos.longitude.toString();
debugPrint('[CART][COORDS] ✅ Using last known position: $lat, $lng');
return (lat, lng);
}
// Retry if we haven't exceeded max retries
if (retryCount < maxRetries) {
await Future.delayed(const Duration(milliseconds: 500));
return _getValidCoordinates(retryCount: retryCount + 1);
}
return null;
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.deniedForever ||
permission == LocationPermission.denied) {
debugPrint('[CART][COORDS] Permission denied, trying last known position');
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && lastPos.latitude != 0 && lastPos.longitude != 0) {
final lat = lastPos.latitude.toString();
final lng = lastPos.longitude.toString();
debugPrint('[CART][COORDS] ✅ Using last known position: $lat, $lng');
return (lat, lng);
}
// Retry if we haven't exceeded max retries
if (retryCount < maxRetries) {
await Future.delayed(const Duration(milliseconds: 500));
return _getValidCoordinates(retryCount: retryCount + 1);
}
return null;
}
Position? position;
try {
// Try to get current position with higher accuracy
position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high, // Changed to high for better accuracy
timeLimit: Duration(seconds: 8), // Increased timeout
),
).timeout(const Duration(seconds: 8));
} catch (e) {
debugPrint('[CART][COORDS] Timeout getting current position: $e, trying last known');
position = await Geolocator.getLastKnownPosition();
}
if (position != null && position.latitude != 0 && position.longitude != 0) {
final lat = position.latitude.toString();
final lng = position.longitude.toString();
// Validate coordinates are within valid GPS ranges
final latDouble = double.tryParse(lat) ?? 0.0;
final lngDouble = double.tryParse(lng) ?? 0.0;
if (latDouble.abs() <= 90 && lngDouble.abs() <= 180) {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('last_lat', lat);
await prefs.setString('last_lng', lng);
} catch (_) {}
debugPrint('[CART][COORDS] ✅ Got valid coordinates: $lat, $lng');
return (lat, lng);
} else {
debugPrint('[CART][COORDS] ⚠️ Invalid coordinate ranges: $lat, $lng');
}
}
// Fallback to SharedPreferences cached coordinates
try {
final prefs = await SharedPreferences.getInstance();
final lat = (prefs.getString('last_lat') ?? '').trim();
final lng = (prefs.getString('last_lng') ?? '').trim();
if (lat.isNotEmpty && lng.isNotEmpty && lat != '0' && lng != '0') {
final latDouble = double.tryParse(lat) ?? 0.0;
final lngDouble = double.tryParse(lng) ?? 0.0;
if (latDouble != 0 && lngDouble != 0 && latDouble.abs() <= 90 && lngDouble.abs() <= 180) {
debugPrint('[CART][COORDS] ✅ Using cached coordinates: $lat, $lng');
return (lat, lng);
}
}
} catch (_) {}
// Retry if we haven't exceeded max retries
if (retryCount < maxRetries) {
debugPrint('[CART][COORDS] ⚠️ Retry ${retryCount + 1}/$maxRetries to get coordinates');
await Future.delayed(const Duration(milliseconds: 500));
return _getValidCoordinates(retryCount: retryCount + 1);
}
debugPrint('[CART][COORDS] ❌ Failed to get valid coordinates after $maxRetries retries');
return null;
} catch (e) {
debugPrint('[CART][COORDS] ❌ Error getting coordinates: $e');
// Retry if we haven't exceeded max retries
if (retryCount < maxRetries) {
await Future.delayed(const Duration(milliseconds: 500));
return _getValidCoordinates(retryCount: retryCount + 1);
}
return null;
}
}
void _stopDeliveryPosting(String orderId) {
_deliveryTimers[orderId]?.cancel();
_deliveryTimers.remove(orderId);
_deliveryBasePayload.remove(orderId);
debugPrint('[CART][DELIVERYLOG] Stopped timer for orderId: $orderId');
}
void _stopAllTimers() {
for (final timer in _deliveryTimers.values) {
timer.cancel();
}
_deliveryTimers.clear();
_deliveryBasePayload.clear();
debugPrint('[CART][DELIVERYLOG] Stopped all timers');
}
// Method to stop delivery posting for a specific order (called when order is completed)
void stopDeliveryPostingForOrder(String orderId) {
_stopDeliveryPosting(orderId);
// Refresh the list to remove completed orders
if (mounted) {
_fetchActive();
}
}
double _parseD(dynamic v) {
if (v == null) return 0.0;
if (v is num) return v.toDouble();
return double.tryParse(v.toString()) ?? 0.0;
}
double _haversineKm(double lat1, double lon1, double lat2, double lon2) {
const double R = 6371.0;
final double dLat = _toRadians(lat2 - lat1);
final double dLon = _toRadians(lon2 - lon1);
final double a = math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(_toRadians(lat1)) *
math.cos(_toRadians(lat2)) *
math.sin(dLon / 2) *
math.sin(dLon / 2);
final double c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
return R * c;
}
double _toRadians(double degrees) {
return degrees * math.pi / 180.0;
}
String _distanceKmDisplay(Map<String, dynamic> m) {
// Try API distance first
final String apiKmsStr = (m['actualkms'] ?? m['distance'] ?? '0').toString();
final double apiKms = double.tryParse(apiKmsStr) ?? 0.0;
if (apiKms > 0) {
return apiKms < 10 ? apiKmsStr : apiKms.toStringAsFixed(0);
}
// Try rider location to delivery location
final double rLat = _parseD(m['riderslat']);
final double rLon = _parseD(m['riderslon']);
final double dLat = _parseD(m['droplat'] ?? m['deliverylat']);
final double dLon = _parseD(m['droplon'] ?? m['deliverylong']);
if (rLat != 0 && rLon != 0 && dLat != 0 && dLon != 0) {
final double km = _haversineKm(rLat, rLon, dLat, dLon);
return km.toStringAsFixed(km < 10 ? 1 : 0);
}
// Fallback: pickup to delivery
final double pLat = _parseD(m['pickuplat']);
final double pLon = _parseD(m['pickuplon']);
if (pLat != 0 && pLon != 0 && dLat != 0 && dLon != 0) {
final double km = _haversineKm(pLat, pLon, dLat, dLon);
return km.toStringAsFixed(km < 10 ? 1 : 0);
}
return '0';
}
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
backgroundColor: Colors.grey.shade200,
appBar: AppBar(
backgroundColor: Colors.grey.shade200,
elevation: 0,
centerTitle: false,
toolbarHeight: 70,
title: Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
"Active Deliveries",
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: Colors.black,
),
),
),
bottom: const PreferredSize(
preferredSize: Size.fromHeight(1),
child: Divider(height: 1, color: Colors.grey),
),
),
body: SafeArea(
child: _activeDeliveries.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 80),
Transform.translate(
offset: const Offset(0, -11),
child: Image.asset(
"assets/images/Nearle Bike.png",
errorBuilder: (c, e, s) => const Icon(
Icons.delivery_dining,
size: 120,
color: Colors.grey,
),
),
),
const SizedBox(height: 16),
Transform.translate(
offset: const Offset(0, -11),
child: Text(
"No Active Deliveries",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24,
fontFamily: FontConstants.fontFamily,
color: Colors.grey,
),
),
),
],
),
)
: RefreshIndicator(
onRefresh: _fetchActive,
child: ListView.builder(
padding: const EdgeInsets.only(bottom: 20),
itemCount: _activeDeliveries.length,
itemBuilder: (context, index) {
final item = _activeDeliveries[index];
final customerName = (item['deliverycustomer'] ?? item['customer'] ?? 'Customer').toString();
final address = (item['deliveryaddress'] ?? item['address'] ?? 'Address not available').toString();
final storeName = (item['pickupcustomer'] ?? item['store'] ?? 'Store').toString();
final orderId = (item['orderid'] ?? '').toString();
final distance = _distanceKmDisplay(item);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// -------------------------------------------
// TOP CUSTOMER DETAILS
// -------------------------------------------
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Container(
width: 12,
height: 12,
decoration: const BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
),
),
Container(
width: 2,
height: 30,
color: Colors.grey.shade300,
),
Container(
width: 12,
height: 12,
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
),
],
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Transform.translate(
offset: const Offset(0, -3),
child: Text(
customerName,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
color: Colors.black,
fontFamily: FontConstants.fontFamily,
),
),
),
const SizedBox(height: 8),
Transform.translate(
offset: const Offset(0, 8),
child: Text(
address,
style: TextStyle(
fontSize: 18,
color: Colors.black87,
fontFamily: FontConstants.fontFamily,
),
),
),
const SizedBox(height: 8),
Text(
'Distance: $distance km',
style: TextStyle(
fontSize: 16,
color: Colors.blueGrey,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
InkWell(
onTap: () async {
// Just launch dialer; PiP is handled only from navigation screen
final phone =
(item['deliverycontactno'] ?? '').toString();
final bool success = await launchPhoneDialer(
phone.isNotEmpty ? phone : '9876543210',
);
if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Could not launch dialer'),
),
);
}
},
child: Image.asset(
'assets/images/phone-call .png',
height: 27,
width: 27,
errorBuilder: (c, e, s) =>
const Icon(Icons.phone, size: 27, color: Colors.green),
),
),
],
),
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
// -------------------------------------------
// STORE DETAILS
// -------------------------------------------
Row(
children: [
Image.asset(
'assets/images/shoppingbag.png',
height: 32,
width: 32,
errorBuilder: (c, e, s) => const Icon(
Icons.shopping_bag,
size: 32,
color: Colors.orange,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
storeName,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
color: Colors.black,
fontFamily: FontConstants.fontFamily,
),
),
InkWell(
onTap: () async {
_showMyOptionsSheet(
context,
item,
null, // No parent state for cart page
);
// Refresh cart page after skip (order will no longer be active)
if (mounted) {
await Future.delayed(const Duration(seconds: 1));
_fetchActive();
}
},
child: Transform.translate(
offset: const Offset(0, -5),
child: Text(
'Skip>>',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.primaryColor,
),
),
),
),
],
),
Text(
"Order ID: #$orderId",
style: TextStyle(
fontSize: 18,
color: Colors.black54,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
],
),
const SizedBox(height: 15),
// -------------------------------------------
// SLIDER BUTTON
// -------------------------------------------
SliderButton(
properties: SliderButtonProperties(
height: 50,
buttonSize: 45,
width: MediaQuery.of(context).size.width - 56,
backgroundColor: ColorConstants.primaryColor,
dismissThresholds: 0.90,
action: () async {
await Future.delayed(const Duration(milliseconds: 400));
if (!context.mounted) return false;
// Navigate to delivery map screen (same as deliveries page)
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => _DeliveryMapScreen(
delivery: item,
parentState: null, // No parent state for cart page
),
),
);
// Refresh cart page when returning from map screen
// (in case delivery was completed/cancelled)
if (mounted) {
await Future.delayed(const Duration(milliseconds: 500));
_fetchActive();
}
return false;
},
label: const Text(
'Slide to start Delivery',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
icon: ClipOval(
child: Material(
color: Colors.white,
child: SizedBox(
width: 45,
height: 45,
child: Center(
child: Text(
'${index + 1}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.black,
),
),
),
),
),
),
),
),
],
),
),
);
},
),
),
),
);
}
}

View File

@@ -0,0 +1,329 @@
part of 'deliveries.dart';
// -------------------------------------------------------------------------
// DELIVERY CARD
// -------------------------------------------------------------------------
class DeliveryCard extends StatelessWidget {
final Map<String, dynamic> item;
final int displayStep;
final String distanceStr;
final bool enabled;
final bool isSkipped;
const DeliveryCard({
super.key,
required this.item,
required this.displayStep,
required this.distanceStr,
this.enabled = true,
this.isSkipped = false,
});
@override
Widget build(BuildContext context) {
final String customerName = (item['deliverycustomer'] ?? 'Customer')
.toString();
final String address = (item['deliveryaddress'] ?? 'Address not available')
.toString();
final String tenantName = (item['tenantname'] ?? 'Store').toString();
final String orderId = (item['orderid'] ?? '').toString();
return Container(
margin: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: isSkipped ? Border.all(color: Colors.orange, width: 2) : null,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isSkipped)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'SKIPPED',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.orange.shade900,
fontFamily: FontConstants.fontFamily,
),
),
),
if (isSkipped) const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Container(
width: 12,
height: 12,
decoration: const BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
),
),
Container(
width: 2,
height: 30,
color: Colors.grey.shade300,
),
Container(
width: 12,
height: 12,
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
),
],
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Transform.translate(
offset: const Offset(0, -3),
child: Text(
customerName,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 17.5,
color: Colors.black,
fontFamily: FontConstants.fontFamily,
),
),
),
const SizedBox(height: 8),
Transform.translate(
offset: const Offset(0, 8),
child: Text(
address,
style: TextStyle(
fontSize: 18,
color: Colors.black87,
fontFamily: FontConstants.fontFamily,
),
),
),
const SizedBox(height: 8),
Text(
'Distance: $distanceStr km',
style: TextStyle(
fontSize: 17,
color: Colors.blueGrey,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
InkWell(
onTap: () async {
// Just launch dialer; PiP is handled only from navigation screen
final phone = (item['deliverycontactno'] ?? '').toString();
final bool success = await launchPhoneDialer(
phone.isNotEmpty ? phone : '9876543210',
);
if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Could not launch dialer'),
),
);
}
},
child: Image.asset(
'assets/images/phone-call .png',
height: 27,
width: 27,
errorBuilder: (c, e, s) =>
const Icon(Icons.phone, size: 27, color: Colors.green),
),
),
],
),
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
Row(
children: [
Image.asset(
'assets/images/shoppingbag.png',
height: 32,
width: 32,
errorBuilder: (c, e, s) => const Icon(
Icons.shopping_bag,
size: 32,
color: Colors.orange,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
tenantName,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
color: Colors.black,
fontFamily: FontConstants.fontFamily,
),
),
if (!isSkipped)
InkWell(
onTap: () {
final parentState = context
.findAncestorStateOfType<
_MyDeliveriesState
>();
if (parentState != null) {
_showMyOptionsSheet(
context,
item,
parentState,
);
}
},
child: Transform.translate(
offset: const Offset(0, -5),
child: Text(
'Skip>>',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.primaryColor,
),
),
),
),
],
),
Text(
'Order ID: #$orderId',
style: TextStyle(
fontSize: 18,
color: Colors.black54,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
],
),
const SizedBox(height: 15),
SliderButton(
properties: SliderButtonProperties(
height: 50,
buttonSize: 45,
width: MediaQuery.of(context).size.width - 56,
backgroundColor: enabled
? ColorConstants.primaryColor
: Colors.grey.shade400,
dismissThresholds: 0.90,
action: enabled
? () async {
// Reduce delay to make it feel snappier
await Future.delayed(const Duration(milliseconds: 50));
if (!context.mounted) return false;
final parentState = context
.findAncestorStateOfType<_MyDeliveriesState>();
// ✅ BLOCK: Check if there's already an active delivery (and this isn't it)
if (parentState != null) {
final currentOrderId = (item['orderid'] ?? '')
.toString();
final activeOrderIds = parentState._activeDeliveries
.map((d) => (d['orderid'] ?? '').toString())
.where((id) => id.isNotEmpty)
.toSet();
// Block if there's a different active delivery: just ignore the swipe
if (parentState._activeDeliveries.isNotEmpty &&
!activeOrderIds.contains(currentOrderId)) {
return false;
}
}
// Navigate to delivery map screen
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => _DeliveryMapScreen(
delivery: item,
parentState: parentState,
),
),
);
}
return false;
}
: () async => null,
label: Transform.translate(
offset: const Offset(-0.5, 0),
child: Text(
isSkipped
? 'Slide to resume Delivery'
: (enabled
? 'Slide to start Delivery'
: 'Complete previous delivery'),
style: const TextStyle(
fontSize: 18.5,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
),
icon: ClipOval(
child: Material(
color: Colors.white,
child: SizedBox(
width: 45,
height: 45,
child: Center(
child: Text(
'$displayStep',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: enabled
? const ui.Color.fromARGB(255, 0, 0, 0)
: Colors.grey,
),
),
),
),
),
),
),
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,278 @@
part of 'deliveries.dart';
// -------------------------------------------------------------------------
// DELIVERIES DONE SCREEN
// -------------------------------------------------------------------------
class DeliveriesDone extends StatefulWidget {
final bool isCancelled;
final int bonusPoints;
const DeliveriesDone({
super.key,
this.isCancelled = false,
this.bonusPoints = 0,
});
@override
State<DeliveriesDone> createState() => _DeliveriesDoneState();
}
class _DeliveriesDoneState extends State<DeliveriesDone> {
late ConfettiController _confettiController;
final GlobalKey<ScratcherState> _scratcherKey = GlobalKey<ScratcherState>();
double _opacity = 0.0;
bool _isScratched = false; // Track scratch state
@override
void initState() {
super.initState();
_confettiController = ConfettiController(
duration: const Duration(seconds: 3),
);
}
@override
void dispose() {
_confettiController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Check if we should show the scratch card
final bool showScratchCard = !widget.isCancelled && widget.bonusPoints > 0;
return Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
// Main Content
SafeArea(
child: showScratchCard
? _buildScratchCardContent()
: _buildStandardContent(),
),
// Confetti Layer (on top)
Align(
alignment: Alignment.topCenter,
child: ConfettiWidget(
confettiController: _confettiController,
blastDirectionality: BlastDirectionality.explosive,
shouldLoop: false,
colors: const [
Colors.green,
Colors.blue,
Colors.pink,
Colors.orange,
Colors.purple,
],
createParticlePath: drawStar,
),
),
],
),
);
}
Widget _buildStandardContent() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Spacer(),
Lottie.asset(
widget.isCancelled
? 'assets/lotties/Error Occurred!.json'
: 'assets/lotties/result page succes.json',
height: 220,
repeat: false,
errorBuilder:
(c, e, s) => Icon(
widget.isCancelled ? Icons.cancel : Icons.check_circle,
size: 120,
color: widget.isCancelled ? Colors.red : Colors.green,
),
),
const SizedBox(height: 20),
Text(
widget.isCancelled ? 'Delivery Cancelled' : 'Delivery Completed!',
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
Text(
widget.isCancelled
? 'This order was cancelled.'
: 'Great job! Your delivery was successful.',
style: const TextStyle(fontSize: 16, color: Colors.grey),
textAlign: TextAlign.center,
),
const Spacer(),
_buildDoneButton(isEnabled: true),
],
);
}
Widget _buildScratchCardContent() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Spacer(),
Text(
'You won a Scratch Card!',
style: TextStyle(
fontSize: 28, // Increased
fontWeight: FontWeight.bold,
color: Colors.black,
fontFamily: FontConstants.fontFamily,
),
),
const SizedBox(height: 12),
Text(
'Scratch to reveal your bonus points',
style: TextStyle(
fontSize: 18, // Increased
color: Colors.grey,
fontFamily: FontConstants.fontFamily,
),
),
const SizedBox(height: 30),
Center(
child: Container(
width: 250,
height: 250,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.3),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Scratcher(
key: _scratcherKey,
brushSize: 50,
threshold: 50,
color: ColorConstants.primaryColor,
onChange: (value) {
// Optional: haptic feedback or sound while scratching
},
onThreshold: () {
_confettiController.play();
setState(() {
_opacity = 1.0;
_isScratched = true; // Enable button
});
},
// Custom cover content instead of just solid color
image: Image.asset(
'assets/images/nearlelauncher.png',
fit: BoxFit.scaleDown,
width: 100, // Constrain width so it fits nicely
height: 100,
),
child: Container(
width: 250,
height: 250,
color: Colors.white,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.monetization_on,
size: 80,
color: Colors.amber,
),
const SizedBox(height: 16),
Text(
'${widget.bonusPoints}',
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
color: Colors.black,
fontFamily: FontConstants.fontFamily,
),
),
Text(
'Points',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Colors.grey,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
),
),
),
const Spacer(),
_buildDoneButton(isEnabled: _isScratched),
],
);
}
Widget _buildDoneButton({required bool isEnabled}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
child: SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: isEnabled ? ColorConstants.primaryColor : Colors.grey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: isEnabled ? () {
Get.offAll(() => const BottomPage(initialIndex: 1));
} : null,
child: const Text(
'Done',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
Path drawStar(Size size) {
// Method to draw star shape for confetti
double degToRad(double deg) => deg * (math.pi / 180.0);
const numberOfPoints = 5;
final halfWidth = size.width / 2;
final externalRadius = halfWidth;
final internalRadius = halfWidth / 2.5;
final degreesPerStep = degToRad(360 / numberOfPoints);
final halfDegreesPerStep = degreesPerStep / 2;
final path = Path();
final fullAngle = degToRad(360);
path.moveTo(size.width, halfWidth);
for (double step = 0; step < fullAngle; step += degreesPerStep) {
path.lineTo(
halfWidth + externalRadius * math.cos(step),
halfWidth + externalRadius * math.sin(step),
);
path.lineTo(
halfWidth + internalRadius * math.cos(step + halfDegreesPerStep),
halfWidth + internalRadius * math.sin(step + halfDegreesPerStep),
);
}
path.close();
return path;
}
}

View File

@@ -0,0 +1,831 @@
part of 'deliveries.dart';
// -------------------------------------------------------------------------
// SCREEN 1: DELIVERY MAP PREVIEW (Shows route, has "Start" button)
// -------------------------------------------------------------------------
class _DeliveryMapScreen extends StatefulWidget {
final Map<String, dynamic> delivery;
final _MyDeliveriesState? parentState;
const _DeliveryMapScreen({
required this.delivery,
this.parentState,
});
@override
State<_DeliveryMapScreen> createState() => _DeliveryMapScreenState();
}
class _DeliveryMapScreenState extends State<_DeliveryMapScreen> {
GoogleMapController? _mapController;
late final LatLng _pickupLocation;
late final LatLng _dropLocation;
final Set<Marker> _markers = {};
final Set<Polyline> _polylines = {};
late final PolylinePoints _polylinePoints;
bool _isLoadingRoute = true;
bool _isNavigating = false; // Prevent multiple clicks
static const String _googleApiKey = 'AIzaSyBhkGfnq27sN0wV5y_S-M2KojpFTk_by-Q';
@override
void initState() {
super.initState();
_polylinePoints = PolylinePoints(apiKey: _googleApiKey);
_resolveLocationsFromDelivery();
_setMarkers();
_isLoadingRoute = false;
_createRealRoute();
}
double _parseD(dynamic v) {
if (v == null) return 0.0;
if (v is num) return v.toDouble();
return double.tryParse(v.toString()) ?? 0.0;
}
void _resolveLocationsFromDelivery() {
final d = widget.delivery;
final double pickLat = _parseD(d['pickuplat'] ?? d['PickupLat']);
final double pickLon = _parseD(d['pickuplon'] ?? d['PickupLon']);
final double dropLat = _parseD(
d['droplat'] ?? d['DropLat'] ?? d['deliverylat'],
);
final double dropLon = _parseD(
d['droplon'] ?? d['DropLon'] ?? d['deliverylong'],
);
final double riderLat = _parseD(d['riderslat']);
final double riderLon = _parseD(d['riderslon']);
final bool hasPickup = pickLat != 0 && pickLon != 0;
final bool hasDrop = dropLat != 0 && dropLon != 0;
final LatLng pickup = hasPickup
? LatLng(pickLat, pickLon)
: (riderLat != 0 && riderLon != 0
? LatLng(riderLat, riderLon)
: const LatLng(10.998356, 76.977596));
final LatLng drop = hasDrop
? LatLng(dropLat, dropLon)
: const LatLng(11.004556, 76.967696);
_pickupLocation = pickup;
_dropLocation = drop;
}
void _setMarkers() {
_markers.addAll([
Marker(
markerId: const MarkerId('pickup'),
position: _pickupLocation,
infoWindow: const InfoWindow(title: 'Pickup Location'),
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed),
),
Marker(
markerId: const MarkerId('drop'),
position: _dropLocation,
infoWindow: const InfoWindow(title: 'Drop Location'),
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen),
),
]);
}
Future<void> _createRealRoute() async {
try {
final request = PolylineRequest(
origin: PointLatLng(
_pickupLocation.latitude,
_pickupLocation.longitude,
),
destination: PointLatLng(
_dropLocation.latitude,
_dropLocation.longitude,
),
mode: TravelMode.driving,
);
final result = await _polylinePoints.getRouteBetweenCoordinates(
request: request,
);
if (result.points.isNotEmpty) {
final routePoints = result.points
.map((e) => LatLng(e.latitude, e.longitude))
.toList();
if (mounted) {
setState(() {
_polylines.add(
Polyline(
polylineId: const PolylineId('real_route'),
color: ColorConstants.primaryColor,
width: 6,
points: routePoints,
),
);
_isLoadingRoute = false;
});
_fitMapToRoute();
}
} else {
debugPrint('[MAP_PREVIEW] No route found');
if (mounted) {
setState(() => _isLoadingRoute = false);
}
}
} catch (e) {
debugPrint('[MAP_PREVIEW] Error creating route: $e');
if (mounted) {
setState(() => _isLoadingRoute = false);
}
}
}
Future<void> _fitMapToRoute() async {
if (!mounted) return;
if (_mapController == null) return;
// Check if controller is still alive (important!)
try {
await _mapController!.getVisibleRegion();
} catch (e) {
debugPrint("❌ Map controller is dead. Skip animateCamera.");
return;
}
final bounds = LatLngBounds(
southwest: LatLng(
math.min(_pickupLocation.latitude, _dropLocation.latitude),
math.min(_pickupLocation.longitude, _dropLocation.longitude),
),
northeast: LatLng(
math.max(_pickupLocation.latitude, _dropLocation.latitude),
math.max(_pickupLocation.longitude, _dropLocation.longitude),
),
);
// Try animate safely
for (int i = 0; i < 10; i++) {
if (!mounted) return;
try {
await _mapController!.animateCamera(
CameraUpdate.newLatLngBounds(bounds, 80),
);
return;
} catch (e) {
await Future.delayed(const Duration(milliseconds: 150));
}
}
debugPrint("❌ animateCamera failed after retries (map probably disposed)");
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
SizedBox.expand(
child: GoogleMap(
initialCameraPosition: CameraPosition(
target: _pickupLocation,
zoom: 14.5,
),
onMapCreated: (controller) {
_mapController = controller;
Future.delayed(const Duration(milliseconds: 500), () {
if (!_isLoadingRoute && _polylines.isNotEmpty) {
_fitMapToRoute();
}
});
},
markers: _markers,
polylines: _polylines,
zoomControlsEnabled: false,
myLocationButtonEnabled: false,
),
),
Positioned(
top: 50,
left: 16,
child: CircleAvatar(
backgroundColor: Colors.white,
child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black),
onPressed: () => Navigator.pop(context),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
boxShadow: [
BoxShadow(
color: Colors.black26,
blurRadius: 8,
offset: Offset(0, -2),
),
],
),
child: SafeArea(
top: false,
child: LayoutBuilder(
builder: (ctx, constraints) {
final double maxSheetHeight =
MediaQuery.of(ctx).size.height * 0.35;
// ignore: unused_local_variable
final double allowedHeight = math.min(
constraints.maxHeight,
maxSheetHeight,
);
return ListView(
padding: EdgeInsets.zero,
shrinkWrap: true,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Customer Details',
style: TextStyle(
fontSize: FontConstants.xxLarge(context),
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
InkWell(
onTap: () async {
// Just launch dialer; PiP is handled only from navigation screen
final phone =
(widget.delivery['deliverycontactno'] ?? '')
.toString();
final bool success = await launchPhoneDialer(
phone.isNotEmpty ? phone : '9876543210',
);
if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Could not launch dialer'),
),
);
}
},
child: Image.asset(
'assets/images/phone-call .png',
height: 27,
width: 27,
errorBuilder: (c, e, s) => const Icon(
Icons.phone,
size: 27,
color: Colors.green,
),
),
),
],
),
const SizedBox(height: 8),
const Divider(thickness: 1),
const SizedBox(height: 8),
_buildCustomerInfo(),
const SizedBox(height: 20),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: ColorConstants.primaryColor,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: _isNavigating ? null : () async {
// Prevent multiple clicks
if (_isNavigating || !mounted || !context.mounted) return;
setState(() {
_isNavigating = true;
});
try {
// Capture screen size before navigation
final screenSize = MediaQuery.of(context).size *
MediaQuery.of(context).devicePixelRatio;
final dc = Get.put(
DeliveriesController(),
permanent: true,
);
final d = widget.delivery;
final int deliveryId =
int.tryParse(
'${d['deliveryid'] ?? d['DeliveryId'] ?? 0}',
) ??
0;
final String orderId = (d['orderid'] ??
d['OrderId'] ??
'')
.toString();
final int orderHeaderId =
int.tryParse(
'${d['orderheaderid'] ?? d['OrderHeaderId'] ?? 0}',
) ??
0;
// Save ridertime start at the moment navigation is started
try {
if (deliveryId > 0) {
final prefs =
await SharedPreferences.getInstance();
// 1) Save rider time start (existing behaviour)
await prefs.setString(
'ridertime_start_$deliveryId',
DateTime.now().toIso8601String(),
);
// 2) Save ETA end time for this order so PiP timer can resume correctly
final rawEta = d['eta'];
int etaMinutes = 0;
if (rawEta != null) {
etaMinutes =
int.tryParse(rawEta.toString()) ?? 0;
}
if (etaMinutes > 0) {
final now = DateTime.now();
final endTime = now
.add(Duration(minutes: etaMinutes))
.millisecondsSinceEpoch ~/
1000; // store seconds
await prefs.setInt(
'eta_endtime_$orderId',
endTime,
);
debugPrint(
'[ACTIVE][ETA] Saved eta_endtime_$orderId -> $endTime (eta=$etaMinutes min)',
);
}
debugPrint(
'[ACTIVE] Saved ridertime_start for deliveryId=$deliveryId',
);
}
} catch (e) {
debugPrint(
'[ACTIVE] Error saving ridertime_start: $e',
);
}
final parentState =
widget.parentState ??
context
.findAncestorStateOfType<
_MyDeliveriesState
>();
// ✅ CRITICAL: Check active delivery BEFORE navigation
if (parentState != null) {
final currentOrderId = (d['orderid'] ??
d['OrderId'] ??
'')
.toString();
final activeOrderIds = parentState
._activeDeliveries
.map(
(del) => (del['orderid'] ??
del['OrderId'] ??
'')
.toString(),
)
.where((id) => id.isNotEmpty)
.toSet();
// Block if there's a different active delivery
if (parentState._activeDeliveries.isNotEmpty &&
!activeOrderIds.contains(currentOrderId)) {
if (mounted) {
setState(() {
_isNavigating = false;
});
showDialog(
context: context,
barrierDismissible: true,
builder: (BuildContext dialogContext) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.orange.shade400,
Colors.red.shade500,
],
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Icon
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
Icons.warning_rounded,
color: Colors.white,
size: 48,
),
),
const SizedBox(height: 20),
// Title
Text(
'Active Delivery in Progress',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: FontConstants.xxLarge(context),
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
const SizedBox(height: 12),
// Message
Text(
'Please complete your active delivery first before starting another delivery.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withOpacity(0.95),
fontSize: FontConstants.medium(context),
height: 1.4,
fontFamily: FontConstants.fontFamily,
),
),
const SizedBox(height: 24),
// Action Button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.of(dialogContext).pop();
// Navigate to active delivery
if (parentState._activeDeliveries.isNotEmpty) {
parentState.startDelivery(parentState._activeDeliveries.first);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.red.shade600,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.two_wheeler,
size: 22,
),
const SizedBox(width: 8),
Text(
'View Active Delivery',
style: TextStyle(
fontSize: FontConstants.regular(context),
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
const SizedBox(height: 12),
// Close Button
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop();
},
child: Text(
'Close',
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 15,
fontWeight: FontWeight.w500,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
);
},
);
}
return; // Return early to prevent starting another delivery
}
}
if (deliveryId > 0 && orderId.isNotEmpty) {
String riderLatStr = '0';
String riderLngStr = '0';
try {
final position =
await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.medium,
timeLimit: Duration(seconds: 3),
),
).timeout(const Duration(seconds: 3));
riderLatStr = position.latitude
.toStringAsFixed(6);
riderLngStr = position.longitude
.toStringAsFixed(6);
} catch (e) {
debugPrint(
'[ACTIVE] Error getting rider location: $e',
);
try {
final lastPos =
await Geolocator.getLastKnownPosition();
if (lastPos != null) {
riderLatStr = lastPos.latitude
.toStringAsFixed(6);
riderLngStr = lastPos.longitude
.toStringAsFixed(6);
}
} catch (_) {}
}
// ✅ CRITICAL: Navigate FIRST, then handle status updates
if (!mounted || !context.mounted) {
setState(() {
_isNavigating = false;
});
return;
}
// Navigate immediately - this must happen
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => _RiderNavigationScreen(
delivery: widget.delivery,
parentState: widget.parentState,
),
),
);
// Reset navigation state after navigation completes
setState(() {
_isNavigating = false;
});
// Continue with status updates and PiP in background
debugPrint(
'[ACTIVE] Updating status for deliveryId=$deliveryId orderId=$orderId lat=$riderLatStr lng=$riderLngStr',
);
final ok = await dc.updateActiveStatus(
deliveryId: deliveryId,
orderHeaderId: orderHeaderId,
ridersLat: riderLatStr,
ridersLng: riderLngStr,
orderId: orderId,
);
debugPrint(
'[ACTIVE] Status update result: $ok',
);
// ✅ CRITICAL: ENFORCE PiP when delivery becomes active (compulsory)
if (ok && !dc.isPipEnabled.value) {
try {
debugPrint('[ACTIVE] Delivery is now active - Enforcing PiP mode');
final floating = Floating();
const rational = Rational.landscape();
final height = (screenSize.height * 0.5).toInt();
final width = (screenSize.width * 0.9).toInt();
final arguments = ImmediatePiP(
aspectRatio: rational,
sourceRectHint: math.Rectangle<int>(
((screenSize.width - width) ~/ 2).toInt(),
((screenSize.height - height) ~/ 2).toInt(),
width,
height,
),
);
await floating.enable(arguments);
dc.isPipEnabled.value = true;
debugPrint('[ACTIVE] PiP enabled successfully');
// Also try method channel as backup
try {
const channel = MethodChannel('nearle/pip');
await channel.invokeMethod<bool>('enterPip');
} catch (_) {}
} catch (e) {
debugPrint('[ACTIVE] Error enabling PiP: $e');
}
}
if (parentState != null) {
final previousActive =
parentState._activeDeliveryOrderId;
if (previousActive != null &&
previousActive != orderId) {
parentState._stopDeliveryPosting(
previousActive,
);
}
parentState._activeDeliveryOrderId = orderId;
d['orderstatus'] = 'active';
final orderKey = parentState._getOrderKey(d);
if (parentState._skippedOrdersCache
.containsKey(orderKey)) {
parentState._skippedOrdersCache.remove(
orderKey,
);
parentState._skippedOrderTimestamps.remove(
orderKey,
);
await parentState._saveSkippedOrdersCache();
debugPrint(
'[ACTIVE] Removed from skipped cache (resumed): $orderKey',
);
}
await parentState._startDeliveryPosting(d);
try {
final prefs =
await SharedPreferences.getInstance();
await prefs.setString(
'active_delivery_order_id',
orderId,
);
} catch (e) {
debugPrint(
'[ACTIVE] Error saving active delivery ID: $e',
);
}
// ignore: invalid_use_of_protected_member
parentState.setState(() {});
}
} else {
debugPrint(
'[ACTIVE] Invalid deliveryId: $deliveryId or orderId: $orderId',
);
setState(() {
_isNavigating = false;
});
}
} catch (e) {
debugPrint(
'[ACTIVE] Error in navigation flow: $e',
);
if (mounted) {
setState(() {
_isNavigating = false;
});
}
}
},
child: _isNavigating
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
),
const SizedBox(width: 12),
Text(
'Starting...',
style: TextStyle(
fontSize: FontConstants.xLarge(context),
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: Colors.white,
),
),
],
)
: Text(
'Start Navigation',
style: TextStyle(
fontSize: FontConstants.xLarge(context),
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: Colors.white,
),
),
),
],
);
},
),
),
),
),
],
),
);
}
Widget _buildCustomerInfo() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(
'Name:',
(widget.delivery['deliverycustomer'] ?? 'Customer').toString(),
),
const SizedBox(height: 10),
_buildInfoRow(
'Address:',
(widget.delivery['deliveryaddress'] ?? 'Address not available')
.toString(),
isExpanded: true,
),
const SizedBox(height: 10),
_buildInfoRow(
'Order ID:',
'#${(widget.delivery['orderid'] ?? '').toString()}',
),
const SizedBox(height: 10),
_buildInfoRow(
'Distance:',
'${(widget.delivery['kms'] ?? '0').toString()} km',
valueColor: Colors.red,
),
],
);
}
Widget _buildInfoRow(
String label,
String value, {
bool isExpanded = false,
Color? valueColor,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: FontConstants.xLarge(context),
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
),
),
const SizedBox(width: 15),
isExpanded
? Expanded(
child: Text(
value,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: FontConstants.large(context),
fontFamily: FontConstants.fontFamily,
color: valueColor ?? Colors.black87,
),
),
)
: Text(
value,
style: TextStyle(
fontSize: FontConstants.large(context),
fontFamily: FontConstants.fontFamily,
color: valueColor ?? Colors.black87,
),
),
],
);
}
}

View File

@@ -0,0 +1,41 @@
part of 'deliveries.dart';
// -------------------------------------------------------------------------
// MAP VIEW BUTTON (Customer locations)
// -------------------------------------------------------------------------
class MapViewRow extends StatelessWidget {
final List<Map<String, dynamic>> deliveries;
final Map<String, int>? preservedStepNumbers;
const MapViewRow({
super.key,
required this.deliveries,
this.preservedStepNumbers,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MultiCustomerMapScreen(
deliveries: deliveries,
preservedStepNumbers: preservedStepNumbers ?? {},
),
),
);
},
child: Image.asset(
'assets/images/customermap.png',
color: ColorConstants.primaryColor,
height: 32,
width: 32,
errorBuilder: (c, e, s) =>
const Icon(Icons.map, size: 32, color: Colors.blue),
),
);
}
}

View File

@@ -0,0 +1,894 @@
part of 'deliveries.dart';
class _StepPoint {
final int step;
final LatLng position;
_StepPoint(this.step, this.position);
}
// -------------------------------------------------------------------------
// MULTI CUSTOMER MAP (Shows all deliveries with real step numbers)
// -------------------------------------------------------------------------
class MultiCustomerMapScreen extends StatefulWidget {
final List<Map<String, dynamic>> deliveries;
final Map<String, int> preservedStepNumbers;
const MultiCustomerMapScreen({
super.key,
required this.deliveries,
this.preservedStepNumbers = const {},
});
@override
State<MultiCustomerMapScreen> createState() => _MultiCustomerMapScreenState();
}
class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen> {
GoogleMapController? mapController;
Set<Marker> markers = {};
Set<Polyline> polylines = {};
LatLng? currentLocation;
bool _isLoading = true;
bool _mapReady = false;
final PolylinePoints _polylinePoints = PolylinePoints(
apiKey: 'AIzaSyBhkGfnq27sN0wV5y_S-M2KojpFTk_by-Q',
);
final Map<String, Map<String, dynamic>> _deliveryMap = {};
@override
void initState() {
super.initState();
if (widget.deliveries.isNotEmpty) {
final firstDelivery = widget.deliveries.first;
final dropLat = _parseD(
firstDelivery['droplat'] ?? firstDelivery['deliverylat'] ?? 0,
);
final dropLon = _parseD(
firstDelivery['droplon'] ?? firstDelivery['deliverylong'] ?? 0,
);
if (dropLat != 0 && dropLon != 0) {
currentLocation = LatLng(dropLat, dropLon);
} else {
currentLocation = const LatLng(11.018356, 77.012596);
}
} else {
currentLocation = const LatLng(11.018356, 77.012596);
}
_loadMapData();
}
double _parseD(dynamic v) {
if (v == null) return 0.0;
if (v is num) return v.toDouble();
return double.tryParse(v.toString()) ?? 0.0;
}
int _getStepNumber(Map<String, dynamic> order) {
final dynamic raw = order['step'] ?? order['Step'];
final int step = raw == null
? 0
: (raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0);
return step;
}
String _getOrderKey(Map<String, dynamic> order) {
final deliveryId = (order['deliveryid'] ?? '').toString();
final orderId = (order['orderid'] ?? '').toString();
return deliveryId.isNotEmpty ? 'delivery_$deliveryId' : 'order_$orderId';
}
int _getPreservedOrCurrentStep(Map<String, dynamic> order) {
final orderKey = _getOrderKey(order);
if (widget.preservedStepNumbers.containsKey(orderKey)) {
return widget.preservedStepNumbers[orderKey]!;
}
final currentStep = _getStepNumber(order);
return currentStep;
}
Future<void> _loadMapData() async {
try {
if (mounted) {
setState(() {
_isLoading = false;
});
}
_getCurrentLocation().then((_) {
if (mounted) {
_createDeliveryMarkers();
}
});
} catch (e) {
debugPrint('[CUSTOMER_MAP] Error loading map data: $e');
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _getCurrentLocation() async {
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && mounted) {
setState(() {
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
});
}
return;
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && mounted) {
setState(() {
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
});
}
return;
}
}
Position? position;
try {
position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.medium,
distanceFilter: 0,
timeLimit: Duration(seconds: 5),
),
);
} catch (e) {
debugPrint(
'[CUSTOMER_MAP] Timeout getting location, using last known: $e',
);
position = await Geolocator.getLastKnownPosition();
}
final safePosition = position;
if (safePosition != null && mounted) {
setState(() {
currentLocation = LatLng(
safePosition.latitude,
safePosition.longitude,
);
});
}
} catch (e) {
debugPrint('[CUSTOMER_MAP] Error getting location: $e');
try {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && mounted) {
setState(() {
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
});
}
} catch (_) {}
}
}
int? _getNextDeliveryStep() {
// Find the first non-skipped delivery (next delivery from user location)
for (int i = 0; i < widget.deliveries.length; i++) {
final delivery = widget.deliveries[i];
final status = (delivery['orderstatus']?.toString().toLowerCase() ?? '')
.trim();
if (status != 'skipped') {
final stepNumber = _getPreservedOrCurrentStep(delivery);
if (stepNumber > 0) {
return stepNumber;
} else {
// Calculate display step for orders without step
final ordersWithStepBefore = widget.deliveries
.sublist(0, i)
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
final totalOrdersWithStep = widget.deliveries
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
return totalOrdersWithStep + (i - ordersWithStepBefore) + 1;
}
}
}
return null;
}
Future<void> _createDeliveryMarkers() async {
Set<Marker> tempMarkers = {};
List<_StepPoint> stepPoints = [];
_deliveryMap.clear();
final nextStep = _getNextDeliveryStep();
for (int i = 0; i < widget.deliveries.length; i++) {
final delivery = widget.deliveries[i];
final stepNumber = _getPreservedOrCurrentStep(delivery);
int displayStep;
if (stepNumber > 0) {
displayStep = stepNumber;
} else {
final ordersWithStepBefore = widget.deliveries
.sublist(0, i)
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
final totalOrdersWithStep = widget.deliveries
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
displayStep = totalOrdersWithStep + (i - ordersWithStepBefore) + 1;
}
final double lat = _parseD(
delivery['droplat'] ?? delivery['deliverylat'],
);
final double lon = _parseD(
delivery['droplon'] ?? delivery['deliverylong'],
);
if (lat == 0 || lon == 0) continue;
final customerName = (delivery['deliverycustomer'] ?? 'Customer ${i + 1}')
.toString();
final orderId = (delivery['orderid'] ?? '').toString();
final status = (delivery['orderstatus']?.toString().toLowerCase() ?? '')
.trim();
final isSkipped = status == 'skipped';
final isNext = displayStep == nextStep;
// Store delivery data for dialog
_deliveryMap['delivery_$orderId'] = delivery;
final icon = await _createCircularMarkerBitmap(
displayStep,
isSkipped: isSkipped,
isNext: isNext,
);
tempMarkers.add(
Marker(
markerId: MarkerId('delivery_$orderId'),
position: LatLng(lat, lon),
icon: icon,
infoWindow: InfoWindow(
title: isSkipped
? 'Step $displayStep: $customerName (SKIPPED)'
: 'Step $displayStep: $customerName',
snippet: 'Order #$orderId',
),
onTap: () {
_showCustomerDetailsSheet(delivery, displayStep);
},
),
);
stepPoints.add(_StepPoint(displayStep, LatLng(lat, lon)));
}
if (currentLocation != null) {
tempMarkers.add(
Marker(
markerId: const MarkerId('current_location'),
position: currentLocation!,
icon: BitmapDescriptor.defaultMarkerWithHue(
BitmapDescriptor.hueAzure,
),
infoWindow: const InfoWindow(title: 'You are here'),
),
);
stepPoints.insert(0, _StepPoint(0, currentLocation!));
}
stepPoints.sort((a, b) => a.step.compareTo(b.step));
Set<Polyline> newPolylines = {};
int segmentIndex = 0;
for (int i = 0; i < stepPoints.length - 1; i++) {
final start = stepPoints[i].position;
final end = stepPoints[i + 1].position;
try {
// ignore: deprecated_member_use
final request = PolylineRequest(
origin: PointLatLng(start.latitude, start.longitude),
destination: PointLatLng(end.latitude, end.longitude),
mode: TravelMode.driving,
);
final result = await _polylinePoints.getRouteBetweenCoordinates(
request: request,
);
if (result.points.isNotEmpty) {
final routePoints = result.points
.map((p) => LatLng(p.latitude, p.longitude))
.toList();
newPolylines.add(
Polyline(
polylineId: PolylineId('segment_$segmentIndex'),
points: routePoints,
width: 6,
color: Colors.blue,
startCap: Cap.roundCap,
endCap: Cap.roundCap,
jointType: JointType.round,
geodesic: true,
),
);
segmentIndex++;
} else {
newPolylines.add(
Polyline(
polylineId: PolylineId('segment_fallback_$segmentIndex'),
points: [start, end],
width: 4,
color: Colors.blue.shade200,
),
);
segmentIndex++;
}
} catch (e) {
debugPrint('[CUSTOMER_MAP] Directions error for segment $i: $e');
newPolylines.add(
Polyline(
polylineId: PolylineId('segment_error_$segmentIndex'),
points: [start, end],
width: 4,
color: Colors.blue.shade200,
),
);
segmentIndex++;
}
}
if (mounted) {
setState(() {
markers = tempMarkers;
polylines = newPolylines;
});
}
await Future.delayed(const Duration(milliseconds: 200));
_fitBoundsToMarkersAndPolylines();
}
Future<void> _fitBoundsToMarkersAndPolylines() async {
if (mapController == null) return;
double minLat = double.infinity;
double maxLat = -double.infinity;
double minLng = double.infinity;
double maxLng = -double.infinity;
bool hasPoint = false;
for (final m in markers) {
final pos = m.position;
minLat = math.min(minLat, pos.latitude);
maxLat = math.max(maxLat, pos.latitude);
minLng = math.min(minLng, pos.longitude);
maxLng = math.max(maxLng, pos.longitude);
hasPoint = true;
}
for (final poly in polylines) {
for (final pos in poly.points) {
minLat = math.min(minLat, pos.latitude);
maxLat = math.max(maxLat, pos.latitude);
minLng = math.min(minLng, pos.longitude);
maxLng = math.max(maxLng, pos.longitude);
hasPoint = true;
}
}
if (!hasPoint) return;
final bounds = LatLngBounds(
southwest: LatLng(minLat, minLng),
northeast: LatLng(maxLat, maxLng),
);
WidgetsBinding.instance.addPostFrameCallback((_) async {
try {
await mapController!.animateCamera(
CameraUpdate.newLatLngBounds(bounds, 80),
);
} catch (e) {
Future.delayed(const Duration(milliseconds: 300), () async {
try {
await mapController!.animateCamera(
CameraUpdate.newLatLngBounds(bounds, 80),
);
} catch (e) {
debugPrint('[CUSTOMER_MAP] Retry failed: $e');
}
});
}
});
}
Future<BitmapDescriptor> _createCircularMarkerBitmap(
int number, {
bool isSkipped = false,
bool isNext = false,
}) async {
const double size = 70;
final pictureRecorder = ui.PictureRecorder();
final canvas = Canvas(pictureRecorder);
final center = Offset(size / 2, size / 2);
final paint = Paint()
..color = isSkipped ? Colors.orange : ColorConstants.primaryColor;
canvas.drawCircle(center, 15, paint);
final border = Paint()
..color = isSkipped ? Colors.orange.shade900 : Colors.white
..style = PaintingStyle.stroke
..strokeWidth = isSkipped ? 4 : 3;
canvas.drawCircle(center, 15, border);
final textPainter = TextPainter(
text: TextSpan(
text: number.toString(),
style: const TextStyle(
fontSize: 19,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(
center.dx - textPainter.width / 2,
center.dy - textPainter.height / 2,
),
);
// Draw "NEXT" indicator (road sign with down arrow) on top
if (isNext) {
// Draw road sign background (rectangle)
final signPaint = Paint()
..color = Colors.green
..style = PaintingStyle.fill;
final signRect = RRect.fromRectAndRadius(
Rect.fromCenter(center: Offset(size / 2, 8), width: 45, height: 30),
const Radius.circular(4),
);
canvas.drawRRect(signRect, signPaint);
// Draw border
final signBorder = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
canvas.drawRRect(signRect, signBorder);
// Draw "NEXT" text
final nextTextPainter = TextPainter(
text: const TextSpan(
text: 'NEXT',
style: TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
nextTextPainter.layout();
nextTextPainter.paint(
canvas,
Offset(size / 2 - nextTextPainter.width / 2, 4),
);
// Draw down arrow
final arrowPath = Path();
arrowPath.moveTo(size / 2, 18);
arrowPath.lineTo(size / 2 - 4, 24);
arrowPath.lineTo(size / 2 + 4, 24);
arrowPath.close();
final arrowPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
canvas.drawPath(arrowPath, arrowPaint);
}
final img = await pictureRecorder.endRecording().toImage(
size.toInt(),
size.toInt(),
);
final data = await img.toByteData(format: ui.ImageByteFormat.png);
return BitmapDescriptor.bytes(data!.buffer.asUint8List());
}
void _showCustomerDetailsSheet(
Map<String, dynamic> delivery,
int stepNumber,
) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
enableDrag: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (BuildContext context) {
final customerName = (delivery['deliverycustomer'] ?? 'Customer')
.toString();
final address = (delivery['deliveryaddress'] ?? 'Address not available')
.toString();
final phone = (delivery['deliverycontactno'] ?? '').toString();
final orderId = (delivery['orderid'] ?? '').toString();
final status = (delivery['orderstatus']?.toString().toLowerCase() ?? '')
.trim();
final isSkipped = status == 'skipped';
return SafeArea(
top: false,
left: false,
right: false,
bottom: true,
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
padding: const EdgeInsets.all(24),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with title and close icon
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Customer Details',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.primaryColor,
),
),
),
InkWell(
onTap: () => Navigator.of(context).pop(),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.grey.shade200,
shape: BoxShape.circle,
),
child: const Icon(
Icons.cancel,
size: 34,
color: Colors.red,
),
),
),
],
),
const Divider(thickness: 1.5),
// Step number and status
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: isSkipped
? Colors.orange
: ColorConstants.primaryColor,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Step $stepNumber',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
if (isSkipped) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'SKIPPED',
style: TextStyle(
color: Colors.orange,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
],
],
),
const SizedBox(height: 16),
// Customer name
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.person, size: 20, color: Colors.grey),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Customer Name',
style: TextStyle(
fontSize: 16,
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
customerName,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Delivery address
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.location_on,
size: 20,
color: Colors.grey,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Delivery Address',
style: TextStyle(
fontSize: 16,
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
address,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Phone number
if (phone.isNotEmpty)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.phone, size: 20, color: Colors.grey),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Contact Number',
style: TextStyle(
fontSize: 16,
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
phone,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey,
),
),
],
),
),
],
),
const SizedBox(height: 20),
// Order ID
Text(
'Order ID: $orderId',
style: const TextStyle(
fontSize: 16,
color: Colors.black,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 20),
// Call button
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
onPressed: () async {
// Close bottom sheet first
Navigator.of(context).pop();
// Small delay to ensure bottom sheet is closed
await Future.delayed(
const Duration(milliseconds: 300),
);
// Then make the call
final bool success = await launchPhoneDialer(
phone.isNotEmpty ? phone : '9876543210',
);
if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Could not launch dialer'),
),
);
}
},
icon: const Icon(
Icons.phone,
color: Colors.white,
size: 20,
),
label: const Text(
'Call',
style: TextStyle(
color: Colors.white,
fontSize: 21,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 8),
],
),
),
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
LatLng initialTarget;
double initialZoom = 13;
if (currentLocation != null) {
initialTarget = currentLocation!;
} else if (widget.deliveries.isNotEmpty) {
final firstDelivery = widget.deliveries.first;
final lat = _parseD(
firstDelivery['droplat'] ?? firstDelivery['deliverylat'],
);
final lon = _parseD(
firstDelivery['droplon'] ?? firstDelivery['deliverylong'],
);
initialTarget = (lat != 0 && lon != 0)
? LatLng(lat, lon)
: const LatLng(11.0168, 76.9558);
} else {
initialTarget = const LatLng(11.0168, 76.9558);
}
final initialCameraPosition = CameraPosition(
target: initialTarget,
zoom: initialZoom,
);
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(70),
child: SafeArea(
bottom: false,
child: AppBar(
automaticallyImplyLeading: false,
backgroundColor: ColorConstants.primaryColor,
elevation: 0,
toolbarHeight: 80,
leadingWidth: double.infinity,
leading: Row(
children: [
IconButton(
icon: const Icon(
Icons.arrow_back_ios,
color: Colors.white,
size: 26,
),
onPressed: () => Navigator.pop(context),
),
const Text(
'Delivery Route',
style: TextStyle(
fontSize: 26,
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
],
),
centerTitle: false,
),
),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: GoogleMap(
initialCameraPosition: initialCameraPosition,
markers: markers,
polylines: polylines,
myLocationEnabled: true,
myLocationButtonEnabled: true,
onMapCreated: (controller) {
mapController = controller;
_mapReady = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 150), () {
if (_mapReady) _fitBoundsToMarkersAndPolylines();
});
});
},
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,172 @@
part of 'deliveries.dart';
// -------------------------------------------------------------------------
// PIP INFO CARD (Shown when PiP mode is enabled)
// -------------------------------------------------------------------------
class PipInfoCard extends StatefulWidget {
final String orderId;
final int etaMinutes; // ETA in minutes from API (always treat as minutes)
const PipInfoCard({
super.key,
required this.orderId,
required this.etaMinutes,
});
@override
State<PipInfoCard> createState() => _PipInfoCardState();
}
class _PipInfoCardState extends State<PipInfoCard> {
late final CountDownController _controller;
int _durationSeconds = 0;
@override
void initState() {
super.initState();
_controller = CountDownController();
// Load remaining ETA from SharedPreferences so timer doesn't reset
_initDuration();
}
Future<void> _initDuration() async {
try {
final prefs = await SharedPreferences.getInstance();
final endKey = 'eta_endtime_${widget.orderId}';
final endSeconds = prefs.getInt(endKey);
final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
int remaining = 0;
if (endSeconds != null && endSeconds > nowSeconds) {
remaining = endSeconds - nowSeconds;
} else {
// Fallback: use full ETA from API
final int safeEtaMinutes =
widget.etaMinutes < 0 ? 0 : widget.etaMinutes;
remaining = safeEtaMinutes * 60;
}
if (!mounted) return;
setState(() {
_durationSeconds = remaining.clamp(0, 24 * 60 * 60);
});
} catch (_) {
// In case of error, just fall back to raw ETA minutes
final int safeEtaMinutes = widget.etaMinutes < 0 ? 0 : widget.etaMinutes;
if (!mounted) return;
setState(() {
_durationSeconds = safeEtaMinutes * 60;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
// Expanded PiP: timer + key delivery details, but still relatively compact
body: Center(
child: widget.orderId.isEmpty || widget.orderId == 'N/A'
? Text(
'No Active Order',
style: TextStyle(
fontSize: 12,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.primaryColor,
),
)
: Card(
color: Colors.white,
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Container(
width: 220,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Timer
SizedBox(
width: 72,
height: 72,
child: _durationSeconds <= 0
? Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: ColorConstants.primaryColor,
width: 3,
),
color: Colors.white,
),
alignment: Alignment.center,
child: Text(
'Out',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: ColorConstants.primaryColor,
fontFamily: FontConstants.fontFamily,
),
),
)
: CircularCountDownTimer(
duration: _durationSeconds,
initialDuration: 0,
controller: _controller,
width: 72,
height: 72,
ringColor: ColorConstants.primaryColor
.withValues(alpha: 0.15),
fillColor: ColorConstants.primaryColor,
backgroundColor: Colors.white,
strokeWidth: 5,
strokeCap: StrokeCap.round,
textStyle: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: ColorConstants.primaryColor,
fontFamily: FontConstants.fontFamily,
),
isReverse: true,
isReverseAnimation: true,
isTimerTextShown: true,
autoStart: true,
timeFormatterFunction:
(defaultFormatter, duration) {
return duration.inSeconds <= 0
? 'Out'
: defaultFormatter(duration);
},
),
),
const SizedBox(height: 4),
// Active order id
Text(
'Active Order ID: ${widget.orderId}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black87,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,411 @@
part of 'deliveries.dart';
// -------------------------------------------------------------------------
// SKIP REASONS SHEET
// -------------------------------------------------------------------------
Future<void> _showMyOptionsSheet(
BuildContext context,
Map<String, dynamic> delivery,
_MyDeliveriesState? parentState,
) async {
int selected = -1;
bool isLoading = false;
final List<String> skipReasons = [
'Customer unreachable',
'Customer not at the location',
];
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) {
return SafeArea(
top: false,
left: false,
right: false,
bottom: true,
child: StatefulBuilder(
builder: (context, setState) {
Widget optionBox(String title, int index) {
final bool isSelected = selected == index;
return GestureDetector(
onTap: () {
if (!isLoading) {
setState(() => selected = index);
}
},
child: Container(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: isSelected
? ColorConstants.primaryColor.withOpacity(0.1)
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? ColorConstants.primaryColor
: Colors.grey.shade300,
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Icon(
isSelected ? Icons.check_circle : Icons.circle_outlined,
color: isSelected
? ColorConstants.primaryColor
: Colors.grey,
size: 28,
),
const SizedBox(width: 12),
Text(
title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
);
}
Future<void> handleConfirm() async {
if (selected == -1 || isLoading) return;
// Check skip limits before proceeding
final dc = Get.put(DeliveriesController(), permanent: true);
final skipStatus = await dc.checkSkipStatus();
final int skipCount = skipStatus['count'] ?? 0;
bool applyPenalty = false;
if (skipCount >= 2) {
// Show Styled Alert Dialog
final bool? confirm = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
elevation: 5,
backgroundColor: Colors.white,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.shade50,
shape: BoxShape.circle,
),
child: Icon(
Icons.warning_amber_rounded,
color: Colors.red.shade600,
size: 40,
),
),
const SizedBox(height: 20),
Text(
'Skip Limit Reached',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: Colors.black87,
),
),
const SizedBox(height: 12),
Text(
'You have exceeded the limit of 2 skips within 3 hours.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
fontFamily: FontConstants.fontFamily,
color: Colors.black54,
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Colors.orange.shade200,
),
),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.orange.shade800, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
"Proceeding will forfeit your bonus points for this session.",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.orange.shade900,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context, false),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
side: BorderSide(color: Colors.grey.shade300),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
"Cancel",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black54,
fontFamily: FontConstants.fontFamily,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade600,
padding: const EdgeInsets.symmetric(vertical: 14),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
"Confirm Skip",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
),
],
),
],
),
),
);
},
);
if (confirm != true) return; // User cancelled or dismissed
applyPenalty = true;
}
setState(() => isLoading = true);
try {
final dc = Get.put(DeliveriesController(), permanent: true);
final d = delivery;
final int deliveryId =
int.tryParse('${d['deliveryid'] ?? 0}') ?? 0;
final int orderHeaderId =
int.tryParse('${d['orderheaderid'] ?? 0}') ?? 0;
final String reason = selected >= 0 && selected < skipReasons.length
? skipReasons[selected]
: '';
if (deliveryId > 0 && orderHeaderId > 0) {
String riderLatStr = '0';
String riderLngStr = '0';
try {
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
timeLimit: const Duration(seconds: 5),
);
riderLatStr = position.latitude.toStringAsFixed(6);
riderLngStr = position.longitude.toStringAsFixed(6);
} catch (e) {
debugPrint('[SKIP] Error getting rider location: $e');
try {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null) {
riderLatStr = lastPos.latitude.toStringAsFixed(6);
riderLngStr = lastPos.longitude.toStringAsFixed(6);
}
} catch (_) {}
}
debugPrint(
'[SKIP] Updating status for deliveryId=$deliveryId orderHeaderId=$orderHeaderId reason="$reason"',
);
final ok = await dc.updateSkippedStatus(
deliveryId: deliveryId,
orderHeaderId: orderHeaderId,
ridersLat: riderLatStr,
ridersLng: riderLngStr,
notes: reason,
);
debugPrint('[SKIP] Status update result: $ok');
if (ok && context.mounted) {
// Register skip locally only on success
await dc.registerSkip(applyPenalty: applyPenalty);
if (context.mounted) {
Navigator.pop(context); // Close the sheet
if (parentState != null) {
// Only update list if called from the list view
parentState.markOrderAsSkipped(d, reason);
} else {
// If called from Navigation/Map (where parentState is null),
// we just close the sheet and let the caller handle the UI update
// typically by popping the route or showing a snackbar.
// We DO NOT force navigation to "MyDeliveries".
debugPrint(
'[SKIP] Skipped from Nav/Map screen, sheet closed.',
);
}
}
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Failed to skip delivery. Please try again.',
),
backgroundColor: Colors.red,
),
);
}
} else {
debugPrint(
'[SKIP] Invalid deliveryId: $deliveryId or orderHeaderId: $orderHeaderId',
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid delivery information.'),
backgroundColor: Colors.red,
),
);
}
}
} catch (e) {
debugPrint('[SKIP] Error updating skip status: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('An error occurred. Please try again.'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (context.mounted) {
setState(() => isLoading = false);
}
}
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 30),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Select Reason',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
IconButton(
icon: const Icon(Icons.cancel,
color: Colors.red, size: 32),
onPressed: isLoading ? null : () => Navigator.pop(context),
),
],
),
const SizedBox(height: 20),
optionBox('Customer unreachable', 0),
optionBox('Customer not at the location', 1),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: (selected == -1 || isLoading)
? Colors.grey.shade300
: ColorConstants.primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed:
(selected == -1 || isLoading) ? null : handleConfirm,
child: isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Text(
'Confirm',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: (selected == -1 || isLoading)
? Colors.black45
: Colors.white,
),
),
),
),
],
),
);
},
),
);
},
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,145 @@
// Active Delivery Banner Widget for Home Page
// ignore_for_file: unused_import
import 'package:flutter/material.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
class ActiveDeliveryBanner extends StatelessWidget {
final List<Map<String, dynamic>> activeDeliveries;
final Function(Map<String, dynamic>) onTap;
const ActiveDeliveryBanner({
super.key,
required this.activeDeliveries,
required this.onTap,
});
String _getDeliveryAddress(Map<String, dynamic> delivery) {
final address =
delivery['deliveryaddress'] ??
delivery['DeliveryAddress'] ??
delivery['address'] ??
'';
if (address.toString().length > 40) {
return '${address.toString().substring(0, 40)}...';
}
return address.toString();
}
@override
Widget build(BuildContext context) {
if (activeDeliveries.isEmpty) {
return const SizedBox.shrink();
}
// Show first active delivery (or show count if multiple)
final delivery = activeDeliveries.first;
final count = activeDeliveries.length;
return Container(
margin: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => onTap(delivery),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
// Active indicator icon
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.two_wheeler,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 12),
// Delivery info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(
count > 1
? '$count Active Deliveries'
: 'Active Delivery',
style: TextStyle(
color: Colors.white,
fontSize: 19,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
if (count > 1) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$count',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
const SizedBox(height: 4),
Text(
_getDeliveryAddress(delivery),
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 17,
fontFamily: FontConstants.fontFamily,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Arrow icon
const Icon(
Icons.arrow_forward_ios,
color: Colors.white,
size: 20,
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,611 @@
import 'package:flutter/material.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'dart:async';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
import 'package:slide_to_submit_button/slide_to_submit_button.dart';
import 'package:image_picker/image_picker.dart';
/// ------------------------------
/// MAIN WIDGET WITH TWO BUTTONS
/// ------------------------------
class OrderStatusRow extends StatefulWidget {
final String currentStatus;
final Future<bool> Function(String newStatus, {String? notes, String? proofImagePath}) onStatusChange;
final bool enabled;
const OrderStatusRow({
super.key,
required this.currentStatus,
required this.onStatusChange,
this.enabled = true,
});
@override
State<OrderStatusRow> createState() => _OrderStatusRowState();
}
class _OrderStatusRowState extends State<OrderStatusRow> {
bool _isProcessing = false;
String? _overrideStatus;
@override
void didUpdateWidget(covariant OrderStatusRow oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentStatus != widget.currentStatus &&
mounted &&
_overrideStatus != null &&
widget.currentStatus == _overrideStatus) {
setState(() {
_overrideStatus = null;
});
} else if (oldWidget.currentStatus != widget.currentStatus &&
_overrideStatus != null) {
_overrideStatus = null;
}
}
String get _effectiveStatus => _overrideStatus ?? widget.currentStatus;
Future<bool> _onStatusChanged(String newStatus, {String? notes, String? proofImagePath}) async {
debugPrint('[OSR] _onStatusChanged: $newStatus, proofImagePath: $proofImagePath');
if (_isProcessing) return false;
// Optimistic UI: flip status immediately to avoid visible lag.
setState(() {
_isProcessing = true;
_overrideStatus = newStatus;
});
bool success = false;
bool timedOut = false;
try {
// Hard cap wait time to keep UI from spinning indefinitely.
success = await widget
.onStatusChange(newStatus, notes: notes, proofImagePath: proofImagePath)
.timeout(
const Duration(seconds: 3),
onTimeout: () {
timedOut = true;
// Assume success on timeout to avoid UI rollback; data will
// refresh from server on next fetch.
return true;
},
);
} catch (_) {
success = false;
} finally {
if (!mounted) return success;
setState(() {
_isProcessing = false;
// If API failed, revert the optimistic status.
// If timed out, keep the optimistic status (server likely completed).
_overrideStatus = (success || timedOut) ? newStatus : null;
});
}
return success;
}
// ------------------------------
// REJECT SHEET
// ------------------------------
void _showRejectSheet(BuildContext context) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
String selectedReason = "";
bool isLoading = false;
final List<String> reasons = [
"Customer not reachable",
"Wrong address",
"Out of delivery area",
"Other reason",
];
return SafeArea(
top: false,
left: false,
right: false,
bottom: true,
child: StatefulBuilder(
builder: (context, setModalState) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Reject Order",
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.red),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 10),
for (String reason in reasons)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: GestureDetector(
onTap: () => setModalState(() {
selectedReason = reason;
}),
child: Container(
height: 55,
width: double.infinity,
decoration: BoxDecoration(
color: selectedReason == reason
? Colors.red
: Colors.grey[300],
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.center,
child: Text(
reason,
style: TextStyle(
color: selectedReason == reason
? Colors.white
: Colors.black,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
fontSize: 16,
),
),
),
),
),
const SizedBox(height: 20),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
minimumSize: const Size(double.infinity, 50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: selectedReason.isEmpty || isLoading
? null
: () async {
setModalState(() => isLoading = true);
final success = await _onStatusChanged(
"REJECTED",
notes: selectedReason,
);
if (context.mounted) {
Navigator.pop(context, success);
}
},
child: isLoading
? const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
)
: const Text(
"Reject Order",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
],
),
);
},
),
);
},
);
}
// ------------------------------
// CANCEL SHEET
// ------------------------------
void _showCancelSheet(BuildContext context) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
bool confirmCancel = false;
bool isLoading = false;
return SafeArea(
top: false,
left: false,
right: false,
bottom: true,
child: StatefulBuilder(
builder: (context, setModalState) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"Cancel this order?",
style:
TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
const Text(
"Once cancelled, this order will return to pending state.",
textAlign: TextAlign.center,
),
const SizedBox(height: 25),
CheckboxListTile(
value: confirmCancel,
onChanged: (value) => setModalState(
() => confirmCancel = value ?? false,
),
title: const Text("I confirm to cancel this order"),
controlAffinity: ListTileControlAffinity.leading,
),
const SizedBox(height: 20),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
minimumSize: const Size(double.infinity, 50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: !confirmCancel
? null
: () async {
setModalState(() => isLoading = true);
await Future.delayed(const Duration(seconds: 1));
if (context.mounted) Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Order Cancelled"),
),
);
},
child: isLoading
? const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
)
: const Text(
"Confirm Cancel",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
],
),
);
},
),
);
},
);
}
@override
Widget build(BuildContext context) {
final String currentStatus = _effectiveStatus;
final bool showCancel =
currentStatus == "ARRIVED" || currentStatus == "PICKED";
final String leftText = showCancel ? "CANCEL" : "REJECT";
return Stack(
children: [
Row(
children: [
// LEFT BUTTON (Reject / Cancel)
Expanded(
child: InkWell(
onTap: () {
if (showCancel) {
_showCancelSheet(context);
} else {
_showRejectSheet(context);
}
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: const BoxDecoration(
color: Colors.red, //
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(12),
),
),
alignment: Alignment.center,
child: Text(
leftText,
style: TextStyle(
fontSize: 19,
color: Colors.white,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
),
),
),
// RIGHT BUTTON (ACCEPT / ARRIVED / PICKED)
OrderStatusButton(
key: ValueKey(currentStatus),
currentStatus: currentStatus,
onStatusChange: (status, {proofImagePath}) =>
_onStatusChanged(status, proofImagePath: proofImagePath),
enabled: widget.enabled && !_isProcessing,
),
],
),
if (_isProcessing)
Positioned.fill(
child: IgnorePointer(
child: Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.12),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
),
),
child: const Center(
child: SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
),
),
),
),
),
],
);
}
}
/// ------------------------------
/// STATUS BUTTON LOGIC (NO DELIVERY)
/// ------------------------------
class OrderStatusButton extends StatefulWidget {
final String currentStatus;
final Future<bool> Function(String newStatus, {String? proofImagePath}) onStatusChange;
final bool enabled;
const OrderStatusButton({
super.key,
required this.currentStatus,
required this.onStatusChange,
this.enabled = true,
});
@override
State<OrderStatusButton> createState() => _OrderStatusButtonState();
}
class _OrderStatusButtonState extends State<OrderStatusButton> {
String get _buttonText => widget.currentStatus;
Color _getButtonColor() {
switch (_buttonText) {
case "ARRIVED":
return Colors.orange;
case "PICKED":
return ColorConstants.primaryColor;
default:
return Colors.green;
}
}
void _showStatusSheet() {
if (_buttonText == "PICKED") return; // final stage now
bool isConfirmLoading = false;
// Determine next status (single step)
String? nextStatus;
if (_buttonText == "ACCEPT") {
nextStatus = "ACCEPTED";
} else if (_buttonText == "ACCEPTED") {
nextStatus = "ARRIVED";
} else if (_buttonText == "ARRIVED") {
nextStatus = "PICKED";
}
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
// If for some reason we don't have a valid next status, show nothing
if (nextStatus == null) {
return const SizedBox.shrink();
}
return SafeArea(
top: false,
left: false,
right: false,
bottom: true,
child: StatefulBuilder(
builder: (context, setModalState) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Move Your Order to",
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
Transform.translate(
offset: const Offset(0, -5),
child: IconButton(
icon: const Icon(
Icons.cancel,
color: Colors.red,
size: 36,
),
onPressed: () => Navigator.pop(context),
),
),
],
),
const SizedBox(height: 20),
// Slider to confirm moving to next status wrapped with SafeArea
SafeArea(
top: false,
left: false,
right: false,
bottom: true,
child: SizedBox(
width: double.infinity,
child: SlideToSubmit.custom(
height: 55,
sliderWidth: 40,
padding: const EdgeInsets.all(8),
backgroundDecoration: BoxDecoration(
color: _getButtonColor().withOpacity(0.5),
borderRadius: BorderRadius.circular(40),
),
foregroundDecoration: BoxDecoration(
color: _getButtonColor(),
borderRadius: BorderRadius.circular(999),
),
slider: Center(
child: ClipOval(
child: Container(
height: 40,
width: 40,
color: Colors.white,
padding: const EdgeInsets.all(8),
child: const Icon(
Icons.arrow_forward_ios,
size: 24,
color: Colors.black,
),
),
),
),
hint: Align(
alignment: Alignment.center,
child: Text(
'Slide to mark $nextStatus',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: const Color.fromARGB(255, 15, 14, 14),
),
),
),
onSubmit: (controller) async {
if (isConfirmLoading) return;
setModalState(() => isConfirmLoading = true);
// Close sheet after slide completes
if (context.mounted) {
Navigator.pop(context);
}
// Trigger status change callback
WidgetsBinding.instance.addPostFrameCallback((
_,
) async {
if (nextStatus == "PICKED") {
debugPrint('[OSB] Status is PICKED, launching camera...');
// Trigger Camera
final ImagePicker picker = ImagePicker();
final XFile? photo = await picker.pickImage(
source: ImageSource.camera,
imageQuality: 50, // Optimize size
);
if (photo != null) {
debugPrint('[OSB] Photo taken: ${photo.path}');
// Process with image
await widget.onStatusChange(
nextStatus!,
proofImagePath: photo.path,
);
} else {
debugPrint('[OSB] Camera cancelled or photo null');
}
} else {
debugPrint('[OSB] Status NOT PICKED (is $nextStatus), normal flow');
// Normal flow
await widget.onStatusChange(nextStatus!);
}
try {
controller.reset();
} catch (_) {}
});
},
),
),
),
],
),
);
},
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Expanded(
child: InkWell(
onTap: widget.enabled ? _showStatusSheet : null,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: widget.enabled ? _getButtonColor() : Colors.grey,
borderRadius: const BorderRadius.only(
bottomRight: Radius.circular(12),
),
),
alignment: Alignment.center,
child: Text(
_buttonText,
style: TextStyle(
fontSize: 19,
color: Colors.white,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,464 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; // ⭐ REQUIRED
import 'package:nearle/views/Dashboard/profile/informations/help_center.dart';
import 'package:nearle/views/Dashboard/profile/informations/profile.dart';
import 'package:nearle/views/Dashboard/profile/informations/saved_address.dart';
import 'package:nearle/views/Dashboard/profile/informations/faq.dart';
import 'package:nearle/views/Dashboard/profile/informations/notifications_page.dart';
import 'package:nearle/views/Dashboard/profile/informations/support_ticket.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
import 'package:nearle/views/onboardscreens/Sign_in.dart';
import 'package:nearle/controllers/profile_controller.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:nearle/views/Dashboard/profile/informations/order_alert_sound.dart';
import 'package:nearle/controllers/rewards_controller.dart';
import 'package:nearle/views/Dashboard/profile/rewards_card.dart';
import 'package:nearle/views/Dashboard/profile/informations/rider_rewards_page.dart';
import 'package:nearle/utils/mqtt_service.dart';
class ProfilePage extends StatefulWidget {
const ProfilePage({super.key});
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
late final ProfileController _profileController =
Get.isRegistered<ProfileController>()
? Get.find<ProfileController>()
: Get.put(ProfileController(), permanent: true);
final RewardsController _rewardsController = Get.put(RewardsController());
String _name = '';
String _email = '';
String _contact = '';
@override
void initState() {
super.initState();
_loadProfilePrefs();
ever(_profileController.userName, (_) => _assignFromController());
ever(_profileController.userEmail, (_) => _assignFromController());
ever(_profileController.userContact, (_) => _assignFromController());
ever(_profileController.userAddress, (_) => _assignFromController());
_profileController.loadFromPrefs();
}
Future<void> _loadProfilePrefs() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_name = prefs.getString('user_name') ?? '';
_email = prefs.getString('user_email') ?? '';
_contact = prefs.getString('contactno') ?? '';
_contact = prefs.getString('contactno') ?? '';
});
final userId = prefs.getInt('userid') ?? 0;
if (userId > 0) {
_rewardsController.fetchBonusSummary(userId);
}
}
void _assignFromController() {
setState(() {
if (_profileController.userName.value.trim().isNotEmpty) {
_name = _profileController.userName.value.trim();
}
if (_profileController.userEmail.value.trim().isNotEmpty) {
_email = _profileController.userEmail.value.trim();
}
if (_profileController.userContact.value.trim().isNotEmpty) {
_contact = _profileController.userContact.value.trim();
}
});
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
backgroundColor: Colors.grey.shade200,
appBar: AppBar(
backgroundColor: Colors.grey.shade200,
elevation: 0,
toolbarHeight: 70.h, // ⭐ responsive
title: Padding(
padding: EdgeInsets.only(top: 12.h),
child: Text(
"PROFILE",
style: TextStyle(
fontSize: FontConstants.xxxLarge(context).sp,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: Colors.black,
),
),
),
bottom: PreferredSize(
preferredSize: Size.fromHeight(1.h),
child: Divider(height: 1.h, color: Colors.grey),
),
),
body: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Padding(
padding: EdgeInsets.only(bottom: 40.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 20.h),
/// ⭐ PROFILE CARD RESPONSIVE
Container(
padding: EdgeInsets.all(16.r),
decoration: BoxDecoration(
color: ColorConstants.primaryColor,
borderRadius: BorderRadius.circular(20.r),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.person,
color: Colors.white,
size: 22.sp,
),
SizedBox(width: 6.w),
Flexible(
child: Text(
_name.isNotEmpty ? _name : "",
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
SizedBox(height: 12.h),
Row(
children: [
Icon(
Icons.phone,
color: Colors.white,
size: 20.sp,
),
SizedBox(width: 6.w),
Flexible(
child: Text(
_contact.isNotEmpty ? _contact : "",
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
),
),
),
],
),
SizedBox(height: 12.h),
Row(
children: [
Icon(
Icons.email,
color: Colors.white,
size: 20.sp,
),
SizedBox(width: 6.w),
Flexible(
child: Text(
_email.isNotEmpty ? _email : "",
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
/// ⭐ Circle Avatar Responsive
Obx(() {
final path = _profileController.imagePath.value;
final hasImage =
path.isNotEmpty && File(path).existsSync();
return CircleAvatar(
radius: 40.r,
backgroundColor: Colors.grey.shade300,
backgroundImage: hasImage
? FileImage(File(path))
: null,
child: !hasImage
? Icon(
Icons.person,
color: Colors.white,
size: 40.sp,
)
: null,
);
}),
],
),
),
SizedBox(height: 20.h),
// ⭐ REWARDS SECTION
RewardsCard(controller: _rewardsController),
SizedBox(height: 20.h),
_buildHeader("Your Information"),
_buildBox([
_buildInfoTile(Icons.person, "Profile"),
Divider(),
_buildInfoTile(Icons.location_on, "Saved Address"),
Divider(),
_buildInfoTile(Icons.card_giftcard, "Rewards"),
Divider(),
_buildInfoTile(Icons.notifications, "Notification"),
]),
SizedBox(height: 20.h),
_buildHeader("Support"),
_buildBox([
_buildInfoTile(Icons.support_agent, "Help Centre"),
Divider(),
_buildInfoTile(Icons.local_activity, "Support tickets"),
]),
SizedBox(height: 20.h),
_buildHeader("Other Information"),
_buildBox([
_buildInfoTile(Icons.translate, "Faq"),
Divider(),
// _buildInfoTile(Icons.sticky_note_2, "Terms & Conditions"),
// Divider(),
_buildInfoTile(
Icons.notifications_active,
"Order alert sound",
),
]),
SizedBox(height: 55.h),
/// ⭐ Logout Button Responsive
SizedBox(
height: 55.h,
width: double.infinity,
child: OutlinedButton(
onPressed: () => _showLogoutDialog(context),
style: OutlinedButton.styleFrom(
backgroundColor: ColorConstants.secondaryColor,
side: BorderSide(color: Colors.black, width: 0.2.w),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.r),
),
),
child: Text(
"Logout",
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.bold,
color: Colors.black,
fontFamily: FontConstants.fontFamily,
),
),
),
),
SizedBox(height: 15.h),
Center(
child: Text(
"App Version 1.2.19",
style: TextStyle(
fontSize: 16.sp,
color: Colors.grey.shade600,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
),
),
),
);
}
Widget _buildHeader(String title) {
return Padding(
padding: EdgeInsets.all(8.r),
child: Text(
title,
style: TextStyle(
fontSize: 22.sp,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: Colors.black,
),
),
);
}
Widget _buildBox(List<Widget> children) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
),
child: Column(children: children),
);
}
Widget _buildInfoTile(IconData icon, String title) {
return ListTile(
leading: Icon(icon, size: 26.sp),
title: Text(
title,
style: TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.w500,
fontFamily: FontConstants.fontFamily,
),
),
trailing: Icon(Icons.arrow_forward_ios, size: 22.sp),
onTap: () => _handleNavigation(title),
);
}
void _handleNavigation(String title) {
switch (title) {
case "Profile":
Get.to(() => Profile());
break;
case "Saved Address":
Get.to(() => const SavedAddressPage());
break;
case "Notification":
Get.to(() => const NotificationsPage());
break;
case "Help Centre":
Get.to(() => HelpCenter());
break;
case "Support tickets":
Get.to(() => SupportTicket());
break;
case "Rewards":
Get.to(() => const RiderRewardsPage());
break;
case "Faq":
Get.to(() => const FaqPage());
break;
// case "Terms & Conditions":
// Get.to(() => const TermsCondition());
// break;
case "Order alert sound":
Get.to(() => const OrderAlertSoundPage());
break;
default:
debugPrint("Tapped on $title — no page linked yet.");
}
}
}
void _showLogoutDialog(BuildContext context) {
showDialog(
context: context,
barrierDismissible: true,
builder: (BuildContext context) {
return Center(
child: FittedBox(
child: AlertDialog(
backgroundColor: ColorConstants.secondaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20.r),
),
title: Text(
"Logout",
style: TextStyle(
fontSize: 22.sp,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
content: Text(
"Are you sure you want to logout?",
style: TextStyle(
fontSize: 19.sp,
fontFamily: FontConstants.fontFamily,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
"No",
style: TextStyle(
fontSize: 19.sp,
color: Colors.grey,
fontFamily: FontConstants.fontFamily,
),
),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color.fromARGB(255, 153, 121, 167),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.r),
),
),
onPressed: () async {
final prefs = await SharedPreferences.getInstance();
final userId =
prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0;
if (userId != 0) {
await prefs.remove('skipped_orders_cache_$userId');
}
NearleMqttService().disconnect();
prefs.setBool('logged_out', true);
Get.offAll(() => const SignIn());
},
child: Text(
"Yes",
style: TextStyle(
fontSize: 19.sp,
color: Colors.black,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
);
},
);
}

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:get/get.dart';
import 'package:webview_flutter/webview_flutter.dart';
class FaqController extends GetxController {
WebViewController? webViewController;
var isLoading = true.obs;
@override
void onInit() {
super.onInit();
initializeWebView();
}
void initializeWebView() {
webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(const Color(0x00000000))
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (url) {
isLoading.value = true;
print('Started loading: $url');
},
onPageFinished: (url) {
isLoading.value = false;
print('Finished loading: $url');
},
onWebResourceError: (error) {
isLoading.value = false;
print('WebView error: ${error.description}');
},
),
);
loadFaqUrl();
}
Future<void> loadFaqUrl() async {
if (webViewController != null) {
try {
await webViewController!.loadRequest(
Uri.parse('https://nearle.in/faq'),
);
} catch (e) {
print('Error loading URL: $e');
}
}
}
}
class FaqPage extends StatelessWidget {
const FaqPage({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.put(FaqController());
return Scaffold(
appBar: AppBar(
backgroundColor: ColorConstants.primaryColor,
centerTitle: true,
toolbarHeight: 70,
leading: IconButton(
icon: const Icon(
Icons.arrow_back_ios,
color: Colors.white,
), // :small_blue_diamond: white back arrow
onPressed: () {
Navigator.pop(context); // goes back to previous screen
},
),
title: const Text(
'FAQ',
style: TextStyle(
fontSize: 26, // :small_blue_diamond: larger font size
color: Colors.white, // :small_blue_diamond: white text
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
elevation: 4,
),
body: Obx(() {
final wvc = controller.webViewController;
return Stack(
children: [
if (wvc != null) WebViewWidget(controller: wvc),
if (controller.isLoading.value)
const LinearProgressIndicator(minHeight: 2),
],
);
}),
);
}
}

View File

@@ -0,0 +1,380 @@
import 'package:flutter/material.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
class HelpCenter extends StatelessWidget {
const HelpCenter({super.key});
@override
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
appBar: AppBar(
backgroundColor: ColorConstants.primaryColor,
centerTitle: true,
toolbarHeight: 70,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios, color: Colors.white),
onPressed: () {
Navigator.pop(context);
},
),
title: const Text(
'Help Center',
style: TextStyle(
fontSize: 26,
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
elevation: 4,
),
body: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header text
Text(
"We're here to help you with anything and \neverything on Nearle Xpress",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
fontFamily: FontConstants.fontFamily
),
),
const SizedBox(height: 8),
Text(
"We make sure your delivery experience is smooth and clear. Whether youre on your first trip or your hundredth, weve got your back. Browse through frequently asked questions or reach out directly if you need further help.",
style: TextStyle(fontSize: 18,color: Colors.grey.shade700, height: 1.4, fontFamily: FontConstants.fontFamily),
),
const SizedBox(height: 16),
TextField(
decoration: InputDecoration(
hintText: 'Search help',
prefixIcon: const Icon(Icons.search),
contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 12),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: ColorConstants.primaryColor, width: 1.5),
),
),
),
const SizedBox(height: 15),
Text(
'FAQ',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, fontFamily: FontConstants.fontFamily,color: ColorConstants.primaryColor),
),
Divider(),
_FaqTile(
title: 'What is Nearle Xpress?',
initiallyExpanded: true,
child: const Text(
'Nearle Xpress is a delivery app for riders who complete local deliveries for nearby stores and markets. It helps riders accept tasks, manage pickup and drop points, and update deliveries in real time.',
style: TextStyle(fontSize: 16, color: Colors.black87, height: 1.4),
),
),
_FaqTile(
title: 'How do I accept a delivery task?',
child: const Text(
'You can accept tasks from the Home screen when a new order appears. Tap on the order to view details and then press Accept.',
style: TextStyle(fontSize: 16, color: Colors.black87, height: 1.4),
),
),
_FaqTile(
title: 'How do I update the delivery status?',
child: const Text(
'Open the active task and use the status buttons to mark Pickup, On the way, and Delivered. Ensure accurate updates for better tracking.',
style: TextStyle(fontSize: 16, color: Colors.black87, height: 1.4),
),
),
_FaqTile(
title: 'Can I view my past deliveries?',
child: const Text(
'Yes. Go to the History section from your dashboard to see completed deliveries and earnings.',
style: TextStyle(fontSize: 16, color: Colors.black87, height: 1.4),
),
),
_FaqTile(
title: 'What if I face an issue during delivery?',
child: const Text(
'Use the Help Center to report an issue or contact support. Provide order details and a short description of the problem.',
style: TextStyle(fontSize: 16, color: Colors.black87, height: 1.4),
),
),
SizedBox(height: 10,),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("Still stuck? Help is a mail away!",style: TextStyle(fontSize: 18,fontFamily: FontConstants.fontFamily,fontWeight: FontWeight.bold,color: ColorConstants.primaryColor),),
],
)
],
),
),
bottomNavigationBar: Padding(padding: EdgeInsets.all(16),
child: SizedBox(
height: 55,
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: ColorConstants.primaryColor,
foregroundColor: ColorConstants.primaryColor,
side: BorderSide(color: ColorConstants.primaryColor, width: 1.2),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const HelpCenterMessage(),
),
);
},
child: Text(
'Send a message',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold,fontFamily: FontConstants.fontFamily,color: Colors.white),
),
),
),),
),
);
}
}
class _FaqTile extends StatelessWidget {
final String title;
final Widget child;
final bool initiallyExpanded;
const _FaqTile({
required this.title,
required this.child,
this.initiallyExpanded = false,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 6),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: initiallyExpanded,
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
collapsedShape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text(
title,
style: TextStyle(fontWeight: FontWeight.w600,fontSize: 18,fontFamily: FontConstants.fontFamily),
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: child,
),
],
),
),
);
}
}
// -------------------------------message page------------------------------------
class HelpCenterMessage extends StatefulWidget {
const HelpCenterMessage({super.key});
@override
State<HelpCenterMessage> createState() => _HelpCenterMessageState();
}
class _HelpCenterMessageState extends State<HelpCenterMessage> {
final _subjectController = TextEditingController();
final _messageController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void dispose() {
_subjectController.dispose();
_messageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
appBar: AppBar(
backgroundColor: ColorConstants.primaryColor,
centerTitle: true,
toolbarHeight: 80,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: const Text(
'Help Centre',
style: TextStyle(
fontSize: 26,
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
elevation: 4,
),
body: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Heading
Text(
'Send Us a Message',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
fontFamily: FontConstants.fontFamily,
),
),
const SizedBox(height: 8),
Text(
"Not finding what you're looking for in the FAQs? Don't worry—we're here to help!",
style: TextStyle(
fontSize: 18,
color: Colors.grey.shade700,
height: 1.4,
fontFamily: FontConstants.fontFamily,
),
),
const SizedBox(height: 20),
// Subject label
Text(
'Subject',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _subjectController,
decoration: InputDecoration(
hintText: 'Type Something',
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: ColorConstants.primaryColor, width: 1.5),
),
),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Please enter a subject' : null,
),
const SizedBox(height: 18),
// Message label
Text(
'Your Message',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _messageController,
minLines: 5,
maxLines: 8,
decoration: InputDecoration(
hintText: 'Type Something',
filled: true,
fillColor: Colors.white,
alignLabelWithHint: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: ColorConstants.primaryColor, width: 1.5),
),
),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Please enter your message' : null,
),
SizedBox(height: 30,),
SizedBox(
height: 55,
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: ColorConstants.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () {
if (_formKey.currentState?.validate() ?? false) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Message sent')),
);
Navigator.pop(context);
}
},
child: Text(
'Send a message',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,176 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
class NotificationsPage extends StatefulWidget {
const NotificationsPage({super.key});
@override
State<NotificationsPage> createState() => _NotificationsPageState();
}
class _NotificationsPageState extends State<NotificationsPage> {
List<Map<String, dynamic>> _items = const [];
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString('notifications_log');
List<Map<String, dynamic>> parsed = [];
if (raw != null && raw.isNotEmpty) {
try {
final list = jsonDecode(raw) as List<dynamic>;
parsed = list.map((e) => (e as Map).map((k, v) => MapEntry(k.toString(), v))).toList();
} catch (_) {}
}
if (!mounted) return;
setState(() {
_items = parsed;
_loading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: ColorConstants.primaryColor,
centerTitle: true,
toolbarHeight: 70, // increases AppBar height
elevation: 4,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios, color: Colors.white), // white back arrow
onPressed: () {
Navigator.pop(context);
},
),
title: Text(
'Notifications',
style: const TextStyle(
fontSize: 26, // larger font size
color: Colors.white, // white text
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
).copyWith(fontFamily: FontConstants.fontFamily), // keep your font
),
actions: [
IconButton(
icon: const Icon(Icons.delete_sweep, color: Colors.white), // white icon
onPressed: () async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('notifications_log');
if (!mounted) return;
setState(() => _items = const []);
// ignore: use_build_context_synchronously
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Notifications cleared')),
);
},
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _items.isEmpty
? Center(child: Text('No notifications yet',style: TextStyle(fontSize: 20,fontFamily: FontConstants.fontFamily),))
: RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final it = _items[index];
final title = (it['title'] ?? 'Nearle').toString();
final body = (it['body'] ?? '').toString();
final time = (it['time'] ?? '').toString();
final imageUrl = (it['imageUrl'] ?? '').toString();
final imagePath = (it['imagePath'] ?? '').toString();
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 1.5,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.notifications_active, color: Colors.purple),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
if (time.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
time,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
),
],
),
),
],
),
if (body.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
body,
style: TextStyle(fontFamily: FontConstants.fontFamily, fontSize: 14),
),
),
if (imagePath.isNotEmpty || imageUrl.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 10),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: imagePath.isNotEmpty
? Image.file(
File(imagePath),
height: 170,
width: double.infinity,
fit: BoxFit.cover,
)
: Image.network(
imageUrl,
height: 170,
width: double.infinity,
fit: BoxFit.cover,
),
),
),
],
),
),
);
},
),
),
);
}
}

View File

@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
class OrderAlertSoundPage extends StatefulWidget {
const OrderAlertSoundPage({super.key});
@override
State<OrderAlertSoundPage> createState() => _OrderAlertSoundPageState();
}
class _OrderAlertSoundPageState extends State<OrderAlertSoundPage> {
static const String _prefsKey = 'order_alert_sound';
static const String _defaultSound = 'assets/audio/alert-1.mp3';
final AudioPlayer _player = AudioPlayer();
String _selected = _defaultSound;
bool _loading = true;
// Available sounds from assets/audio/ folder
final List<_SoundOption> _options = const [
_SoundOption(
label: 'Alert 1 (Default)',
assetPath: 'assets/audio/alert-1.mp3',
),
_SoundOption(label: 'Alert 2', assetPath: 'assets/audio/alert-2.mp3'),
_SoundOption(label: 'Alert 3', assetPath: 'assets/audio/alert-3.mp3'),
_SoundOption(label: 'Alert 4', assetPath: 'assets/audio/alert-4.mp3'),
_SoundOption(label: 'Alert 5', assetPath: 'assets/audio/alert-5.mp3'),
_SoundOption(label: 'Alert 6', assetPath: 'assets/audio/alert-6.mp3'),
_SoundOption(label: 'Alert 7', assetPath: 'assets/audio/alert-7.mp3'),
_SoundOption(label: 'Alert 8', assetPath: 'assets/audio/alert-8.mp3'),
_SoundOption(label: 'Alert 9', assetPath: 'assets/audio/alert-9.mp3'),
_SoundOption(label: 'Alert 10', assetPath: 'assets/audio/alert-10.mp3'),
];
@override
void initState() {
super.initState();
_loadSelection();
}
Future<void> _loadSelection() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_prefsKey);
setState(() {
_selected = (saved != null && saved.isNotEmpty) ? saved : _defaultSound;
_loading = false;
});
}
Future<void> _saveSelection(BuildContext context, String assetPath) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, assetPath);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Order alert sound updated'),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
duration: const Duration(seconds: 2),
),
);
}
Future<void> _preview(BuildContext context, String assetPath) async {
try {
await _player.stop();
await _player.play(AssetSource(assetPath.replaceFirst('assets/', '')));
// Note: AssetSource expects relative to assets/ root; hence replaceFirst
} catch (_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Preview unavailable for: $assetPath'),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
duration: const Duration(seconds: 2),
),
);
}
}
@override
void dispose() {
_player.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: ColorConstants.primaryColor,
centerTitle: true,
toolbarHeight: 70, // :small_blue_diamond: increases app bar height
leading: IconButton(
icon: const Icon(
Icons.arrow_back_ios,
color: Colors.white,
), // :small_blue_diamond: white back arrow
onPressed: () {
Navigator.pop(context); // goes back to previous screen
},
),
title: const Text(
'Orders alert Sound',
style: TextStyle(
fontSize: 26, // :small_blue_diamond: larger font size
color: Colors.white, // :small_blue_diamond: white text
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
elevation: 4,
),
body: _loading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
color: Colors.grey.shade200,
child: Row(
children: [
const Icon(Icons.volume_up, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Current: ${_options.firstWhereOrNull((o) => o.assetPath == _selected)?.label ?? 'Unknown'}',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
const Text(
'Tap a sound to select',
style: TextStyle(fontSize: 12),
),
],
),
),
const Divider(height: 1),
Expanded(
child: ListView.separated(
itemCount: _options.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final opt = _options[index];
final isSelected = _selected == opt.assetPath;
return ListTile(
title: Text(
opt.label,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
),
),
leading: Radio<String>(
value: opt.assetPath,
groupValue: _selected,
onChanged: (value) {
if (value == null) return;
setState(() => _selected = value);
_saveSelection(context, value);
},
),
trailing: IconButton(
icon: const Icon(Icons.play_arrow),
onPressed: () => _preview(context, opt.assetPath),
),
onTap: () {
setState(() => _selected = opt.assetPath);
_saveSelection(context, opt.assetPath);
},
subtitle: isSelected
? const Text(
'Selected',
style: TextStyle(fontSize: 12),
)
: null,
);
},
),
),
],
),
);
}
}
class _SoundOption {
final String label;
final String assetPath;
const _SoundOption({required this.label, required this.assetPath});
}

View File

@@ -0,0 +1,280 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:nearle/controllers/profile_controller.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
class Profile extends StatefulWidget {
const Profile({super.key});
@override
State<Profile> createState() => _ProfileState();
}
class _ProfileState extends State<Profile> {
String _name = '';
String _email = '';
String _contact = '';
String _address = '';
final List<Worker> _workers = [];
late final ProfileController _profileController =
Get.isRegistered<ProfileController>()
? Get.find<ProfileController>()
: Get.put(ProfileController(), permanent: true);
@override
void initState() {
super.initState();
_loadProfile();
// Keep in sync with controller
_workers.addAll([
ever(_profileController.userName, (_) => _assignFromController()),
ever(_profileController.userEmail, (_) => _assignFromController()),
ever(_profileController.userContact, (_) => _assignFromController()),
ever(_profileController.userAddress, (_) => _assignFromController()),
]);
_profileController.loadFromPrefs();
}
@override
void dispose() {
for (final worker in _workers) {
worker.dispose();
}
super.dispose();
}
Future<void> _loadProfile() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_name = prefs.getString('user_name') ?? '';
_email = prefs.getString('user_email') ?? '';
_contact = prefs.getString('contactno') ?? '';
_address = prefs.getString('user_address') ?? '';
});
debugPrint('[PROFILE_DETAILS] Loaded - Name: "$_name", Email: "$_email", Contact: "$_contact"');
}
void _assignFromController() {
setState(() {
if (_profileController.userName.value.trim().isNotEmpty) {
_name = _profileController.userName.value.trim();
}
if (_profileController.userEmail.value.trim().isNotEmpty) {
_email = _profileController.userEmail.value.trim();
}
if (_profileController.userContact.value.trim().isNotEmpty) {
_contact = _profileController.userContact.value.trim();
}
if (_profileController.userAddress.value.trim().isNotEmpty) {
_address = _profileController.userAddress.value.trim();
}
});
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final width = size.width;
// ignore: unused_local_variable
final height = size.height;
return Scaffold(
backgroundColor: Colors.grey.shade200,
body: SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.all(width * 0.04),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Profile image
Center(
child: Stack(
children: [
// White border circle
Container(
padding: const EdgeInsets.all(4), // border thickness
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white, // white border
),
child: CircleAvatar(
radius: 60,
backgroundColor: Colors.grey.shade400,
child: const Icon(
Icons.person,
size: 60,
color: Colors.white,
),
),
),
],
),
),
const SizedBox(height: 40),
// Name
_buildLabel("Enter name", required: true),
_buildTextField(
hintText: _name.isNotEmpty ? _name : "EX: Vijayan",
value: _name.isNotEmpty ? _name : null,
readOnly: true,
disabled: true,
),
const SizedBox(height: 16),
// Contact No
_buildLabel("Contact no", required: true),
_buildTextField(
hintText: _contact.isNotEmpty
? "+91 $_contact"
: "EX: +91 8838304677",
value: _contact.isNotEmpty ? "+91 $_contact" : null,
keyboardType: TextInputType.phone,
readOnly: true,
disabled: true,
),
const SizedBox(height: 16),
// Email Id
_buildLabel("Email Id"),
_buildTextField(
hintText: _email.isNotEmpty ? _email : "EX: gmail@gmail.com",
value: _email.isNotEmpty ? _email : null,
keyboardType: TextInputType.emailAddress,
readOnly: true,
disabled: true,
),
const SizedBox(height: 16),
// Location
_buildLabel("Address"),
_buildTextField(hintText: _address.isNotEmpty ? _address : " EX: R.s puram", value: _address.isNotEmpty ? _address : null, readOnly: true, disabled: true),
const SizedBox(height: 40),
],
),
),
),
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: EdgeInsets.all(width * 0.04),
child: SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton(
onPressed: _handleBackNavigation,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF5C1D8D), // Purple button color
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
"Back",
style: TextStyle(
fontSize: 21,
color: Colors.white,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
),
),
),
),
);
}
Future<void> _handleBackNavigation() async {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop();
return;
}
final rootNavigator = Get.key.currentState;
if (rootNavigator != null && rootNavigator.canPop()) {
rootNavigator.pop();
return;
}
if (Get.isOverlaysOpen) {
Get.back(closeOverlays: true);
return;
}
Get.back();
}
// Text label widget
Widget _buildLabel(String text, {bool required = false}) {
return Align(
alignment: Alignment.centerLeft,
child: RichText(
text: TextSpan(
text: text,
style: TextStyle(
fontSize: 20,
color: Colors.black,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
children: required
? const [
TextSpan(
text: " *",
style: TextStyle(color: Colors.red),
),
]
: [],
),
),
);
}
// Reusable TextField
Widget _buildTextField({
required String hintText,
String? value,
TextInputType keyboardType = TextInputType.text,
required bool readOnly,
bool disabled = false,
}) {
return Container(
margin: const EdgeInsets.only(top: 6),
child: SizedBox(
height: 55,
width: 350,
child: TextFormField(
keyboardType: keyboardType,
readOnly: readOnly,
enabled: !disabled,
enableInteractiveSelection: false,
initialValue: value,
decoration: InputDecoration(
hintText: hintText,
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
import 'package:nearle/controllers/rewards_controller.dart';
import 'package:nearle/views/Dashboard/profile/rewards_card.dart';
class RiderRewardsPage extends StatelessWidget {
const RiderRewardsPage({super.key});
@override
Widget build(BuildContext context) {
final RewardsController rewardsController = Get.isRegistered<RewardsController>()
? Get.find<RewardsController>()
: Get.put(RewardsController());
return Scaffold(
backgroundColor: Colors.grey.shade100,
appBar: AppBar(
title: Text(
"REWARDS",
style: TextStyle(
color: Colors.black,
fontSize: FontConstants.xxxLarge(context).sp,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
backgroundColor: Colors.grey.shade100,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black),
onPressed: () => Navigator.pop(context),
),
),
body: SafeArea(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
child: RewardsCard(
controller: rewardsController,
showFullDetails: true,
),
),
),
);
}
}

View File

@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
class SavedAddressPage extends StatefulWidget {
const SavedAddressPage({super.key});
@override
State<SavedAddressPage> createState() => _SavedAddressPageState();
}
class _SavedAddressPageState extends State<SavedAddressPage> {
final TextEditingController _addressController = TextEditingController();
@override
void initState() {
super.initState();
_loadAddress();
}
Future<void> _loadAddress() async {
final prefs = await SharedPreferences.getInstance();
final address = (prefs.getString('user_address') ?? '').trim();
_addressController.text = address;
setState(() {});
}
@override
void dispose() {
_addressController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final addressText = _addressController.text.trim();
return Scaffold(
backgroundColor: const Color(0xFFF8F9FB),
appBar: AppBar(
backgroundColor: ColorConstants.primaryColor,
centerTitle: true,
toolbarHeight: 70,
elevation: 3,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: const Text(
'Saved Address',
style: TextStyle(
fontSize: 24,
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
),
),
),
body: SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: size.width * 0.05, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 🏠 Header Section
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.15),
spreadRadius: 1,
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
decoration: BoxDecoration(
color: ColorConstants.primaryColor.withOpacity(0.1),
shape: BoxShape.circle,
),
padding: const EdgeInsets.all(10),
child: Icon(
Icons.location_on_rounded,
color: ColorConstants.primaryColor,
size: 26,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Current Address',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
color: Colors.black87,
),
),
const SizedBox(height: 8),
Text(
addressText.isNotEmpty ? addressText : 'No address saved yet.',
style: TextStyle(
fontSize: 15,
fontFamily: FontConstants.fontFamily,
color: Colors.grey.shade700,
height: 1.4,
),
),
],
),
),
],
),
),
),
const SizedBox(height: 28),
// ✨ Info Section
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: ColorConstants.primaryColor.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.info_outline_rounded,
color: ColorConstants.primaryColor, size: 24),
const SizedBox(width: 12),
Expanded(
child: Text(
'Your saved address is used for deliveries, pickups, and nearby service accuracy.',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade800,
fontFamily: FontConstants.fontFamily,
height: 1.4,
),
),
),
],
),
),
const Spacer(),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,503 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:nearle/controllers/support_ticket.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
class SupportTicket extends StatefulWidget {
const SupportTicket({super.key});
@override
State<SupportTicket> createState() => _SupportTicketState();
}
class _SupportTicketState extends State<SupportTicket>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
// Form
final _formKey = GlobalKey<FormState>();
final _subjectCtrl = TextEditingController();
final _messageCtrl = TextEditingController();
String _category = 'Account';
String _priority = 'Medium';
int _attachmentCount = 0;
// Image
final ImagePicker _picker = ImagePicker();
final List<XFile> _attachments = [];
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_tabController.addListener(() => setState(() {}));
Get.put(SupportTicketController());
}
@override
void dispose() {
_subjectCtrl.dispose();
_messageCtrl.dispose();
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
appBar: AppBar(
backgroundColor: ColorConstants.primaryColor,
centerTitle: true,
toolbarHeight: 70,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: Text(
'Support Ticket',
style: TextStyle(
fontSize: 26,
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
fontFamily: FontConstants.fontFamily,
),
),
elevation: 4,
),
body: Column(
children: [
Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
indicatorColor: ColorConstants.primaryColor,
labelColor: ColorConstants.primaryColor,
unselectedLabelColor: Colors.grey,
labelStyle: TextStyle(
fontFamily: FontConstants.fontFamily,
fontWeight: FontWeight.bold,
fontSize: 16,
),
tabs: [
Tab(
child: Text(
'Create Tickets',
style: TextStyle(fontFamily: FontConstants.fontFamily, fontSize: 20),
),
),
Tab(
child: Text(
'My Tickets',
style: TextStyle(fontFamily: FontConstants.fontFamily, fontSize: 20),
),
),
],
),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [_buildCreateForm(), _buildTicketsList()],
),
),
],
),
bottomNavigationBar: _tabController.index == 0
? Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
height: 55,
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: ColorConstants.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: _submitTicket,
child: Text(
'Submit Ticket',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
),
),
)
: null,
),
);
}
// ===============================================
// CREATE FORM
// ===============================================
Widget _buildCreateForm() {
final controller = Get.find<SupportTicketController>();
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Create a new support ticket',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, fontFamily: FontConstants.fontFamily),
),
const SizedBox(height: 8),
Text(
'Tell us what went wrong. We\'ll get back to you as soon as possible.',
style: TextStyle(fontSize: 18, color: Colors.grey.shade700, height: 1.4, fontFamily: FontConstants.fontFamily),
),
const SizedBox(height: 20),
// Category
Text('Category', style: _labelStyle()),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
// ignore: deprecated_member_use
value: _category,
items: ['Account', 'Orders', 'Payments', 'App issue', 'Other']
.map((e) => DropdownMenuItem(value: e, child: Text(e, style: TextStyle(fontFamily: FontConstants.fontFamily))))
.toList(),
onChanged: (v) => setState(() => _category = v ?? _category),
decoration: _inputDecoration(),
),
const SizedBox(height: 16),
// Priority
Text('Priority', style: _labelStyle()),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: ['Low', 'Medium', 'High'].map((p) {
final selected = _priority == p;
return ChoiceChip(
label: Text(p, style: TextStyle(fontFamily: FontConstants.fontFamily, fontWeight: FontWeight.w600, fontSize: 16)),
selected: selected,
selectedColor: ColorConstants.primaryColor.withOpacity(0.15),
labelStyle: TextStyle(
color: selected ? ColorConstants.primaryColor : Colors.black87,
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
),
onSelected: (_) => setState(() => _priority = p),
);
}).toList(),
),
const SizedBox(height: 16),
// Subject
Text('Subject', style: _labelStyle()),
const SizedBox(height: 8),
TextFormField(
controller: _subjectCtrl,
decoration: _inputDecoration(hint: 'Type Something'),
style: TextStyle(fontFamily: FontConstants.fontFamily),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Please enter a subject' : null,
),
const SizedBox(height: 16),
// Message
Text('Describe the issue', style: _labelStyle()),
const SizedBox(height: 8),
TextFormField(
controller: _messageCtrl,
minLines: 5,
maxLines: 8,
decoration: _inputDecoration(hint: 'Type Something'),
style: TextStyle(fontFamily: FontConstants.fontFamily),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Please enter your message' : null,
),
const SizedBox(height: 16),
// Attachments
Row(
children: [
OutlinedButton.icon(
onPressed: _addAttachment,
icon: const Icon(Icons.attach_file),
label: Text('Add screenshot', style: TextStyle(fontFamily: FontConstants.fontFamily)),
),
const SizedBox(width: 12),
if (_attachmentCount > 0)
Text('$_attachmentCount attached', style: TextStyle(fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily)),
],
),
const SizedBox(height: 8),
if (_attachments.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
children: _attachments.asMap().entries.map((entry) {
final idx = entry.key;
final file = entry.value;
return Stack(
clipBehavior: Clip.none,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(File(file.path), width: 80, height: 80, fit: BoxFit.cover),
),
Positioned(
top: -8,
right: -8,
child: InkWell(
onTap: () => setState(() {
_attachments.removeAt(idx);
_attachmentCount = _attachments.length;
}),
child: Container(
width: 22,
height: 22,
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
child: const Icon(Icons.close, size: 16, color: Colors.white),
),
),
),
],
);
}).toList(),
),
// Submit loading
Obx(() => controller.isSubmitting.value
? const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
)
: const SizedBox.shrink()),
],
),
),
);
}
// ===============================================
// MY TICKETS LIST
// ===============================================
Widget _buildTicketsList() {
final controller = Get.find<SupportTicketController>();
return Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.errorMessage.value.isNotEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 12),
Text('Failed to load tickets', style: TextStyle(fontFamily: FontConstants.fontFamily, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Text(controller.errorMessage.value, textAlign: TextAlign.center, style: TextStyle(color: Colors.grey.shade600, fontFamily: FontConstants.fontFamily)),
const SizedBox(height: 16),
ElevatedButton(onPressed: controller.fetchTickets, child: const Text('Retry')),
],
),
),
);
}
if (controller.tickets.isEmpty) {
return _buildEmptyState();
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
itemCount: controller.tickets.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, i) {
final t = controller.tickets[i];
final statusColor = _getPriorityColor(t.priority);
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 2,
child: ListTile(
contentPadding: const EdgeInsets.all(12),
title: Text(t.subject, style: TextStyle(fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text('Category: ${t.category} • Priority: ${t.priority}', style: TextStyle(fontFamily: FontConstants.fontFamily)),
const SizedBox(height: 4),
Text('Created: ${_formatDate(t.created)}', style: TextStyle(fontFamily: FontConstants.fontFamily)),
],
),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(color: statusColor.withOpacity(0.15), borderRadius: BorderRadius.circular(20)),
child: Text(t.priority, style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontFamily: FontConstants.fontFamily)),
),
),
);
},
);
});
}
Widget _buildEmptyState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.support_agent, size: 48, color: Colors.grey),
const SizedBox(height: 12),
Text('No tickets yet', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, fontFamily: FontConstants.fontFamily)),
const SizedBox(height: 8),
Text('Create your first ticket from the Create tab.', style: TextStyle(color: Colors.grey.shade700, fontFamily: FontConstants.fontFamily)),
],
),
),
);
}
// ===============================================
// HELPERS
// ===============================================
TextStyle _labelStyle() => TextStyle(fontSize: 20, fontWeight: FontWeight.bold, fontFamily: FontConstants.fontFamily);
InputDecoration _inputDecoration({String? hint}) {
return InputDecoration(
hintText: hint,
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.grey.shade300)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: Colors.grey.shade300)),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: ColorConstants.primaryColor, width: 1.5)),
hintStyle: TextStyle(fontFamily: FontConstants.fontFamily),
);
}
Color _getPriorityColor(String priority) {
return switch (priority.toLowerCase()) {
'high' => Colors.red,
'medium' => Colors.orange,
'low' => Colors.green,
_ => Colors.grey,
};
}
String _formatDate(DateTime date) {
return '${date.day}/${date.month}/${date.year} ${date.hour}:${date.minute.toString().padLeft(2, '0')}';
}
// ===============================================
// IMAGE PICKER
// ===============================================
Future<void> _addAttachment() async {
final source = await showModalBottomSheet<ImageSource>(
context: context,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_library),
title: Text('Gallery', style: TextStyle(fontFamily: FontConstants.fontFamily)),
onTap: () => Navigator.pop(ctx, ImageSource.gallery),
),
ListTile(
leading: const Icon(Icons.camera_alt),
title: Text('Camera', style: TextStyle(fontFamily: FontConstants.fontFamily)),
onTap: () => Navigator.pop(ctx, ImageSource.camera),
),
],
),
),
);
if (source == null) return;
try {
if (source == ImageSource.gallery) {
final multi = await _picker.pickMultiImage(imageQuality: 85);
if (multi.isNotEmpty) {
setState(() => _attachments.addAll(multi));
} else {
final one = await _picker.pickImage(source: ImageSource.gallery, imageQuality: 85);
if (one != null) setState(() => _attachments.add(one));
}
} else {
final captured = await _picker.pickImage(source: ImageSource.camera, imageQuality: 85);
if (captured != null) setState(() => _attachments.add(captured));
}
} catch (_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to pick image', style: TextStyle(fontFamily: FontConstants.fontFamily))),
);
}
setState(() => _attachmentCount = _attachments.length);
}
// ===============================================
// SUBMIT TICKET
// ===============================================
Future<void> _submitTicket() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
final controller = Get.find<SupportTicketController>();
final success = await controller.createTicket(
userid: 1242,
category: _category,
priority: _priority,
subject: _subjectCtrl.text.trim(),
issue: _messageCtrl.text.trim(),
attachments: _attachments.isEmpty ? null : _attachments,
);
if (success) {
_subjectCtrl.clear();
_messageCtrl.clear();
_attachments.clear();
_attachmentCount = 0;
setState(() {});
_tabController.animateTo(1);
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Ticket Submitted!'),
content: const Text('Your ticket has been created and saved. Our team will get back to you soon.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK')),
],
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to submit ticket: ${controller.errorMessage.value}'),
backgroundColor: Colors.red,
),
);
}
}
}

View File

@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:get/get.dart';
import 'package:webview_flutter/webview_flutter.dart';
// ===== Controller =====
class TermsController extends GetxController {
WebViewController? webViewController;
var isLoading = true.obs;
@override
void onInit() {
super.onInit();
initializeWebView();
}
void initializeWebView() {
webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(const Color(0x00000000))
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (url) {
isLoading.value = true;
print('Started loading: $url');
},
onPageFinished: (url) {
isLoading.value = false;
print('Finished loading: $url');
},
onWebResourceError: (error) {
isLoading.value = false;
print('WebView error: ${error.description}');
},
),
);
loadTermsUrl();
}
Future<void> loadTermsUrl() async {
if (webViewController != null) {
try {
await webViewController!.loadRequest(
Uri.parse('https://nearle.in/terms'),
);
} catch (e) {
print('Error loading URL: $e');
}
}
}
}
// ===== Page =====
class TermsCondition extends StatelessWidget {
const TermsCondition({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.put(TermsController());
return Scaffold(
appBar: AppBar(
backgroundColor: ColorConstants.primaryColor,
centerTitle: true,
toolbarHeight: 70,
leading: IconButton(
icon: const Icon(
Icons.arrow_back_ios,
color: Colors.white,
),
onPressed: () {
Navigator.pop(context);
},
),
title: const Text(
'Terms & Conditions',
style: TextStyle(
fontSize: 26,
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
elevation: 4,
),
body: SafeArea(
child: Obx(() {
final wvc = controller.webViewController;
return Stack(
children: [
if (wvc != null) WebViewWidget(controller: wvc),
if (controller.isLoading.value)
const LinearProgressIndicator(minHeight: 2),
],
);
}),
),
);
}
}

View File

@@ -0,0 +1,474 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:nearle/controllers/rewards_controller.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
import 'package:nearle/views/Dashboard/profile/informations/rider_rewards_page.dart';
class RewardsCard extends StatelessWidget {
final RewardsController controller;
final bool showFullDetails;
const RewardsCard({
super.key,
required this.controller,
this.showFullDetails = false,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Original Rewards Card (The Gradient Card)
GestureDetector(
onTap: () {
if (!showFullDetails) {
Get.to(() => const RiderRewardsPage());
}
},
child: Container(
width: double.infinity,
padding: EdgeInsets.all(16.r),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20.r),
gradient: const LinearGradient(
colors: [
Color(0xFF2C3E50), // Dark blue/grey
Color(0xFF4CA1AF), // Tealish
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Nearle Rewards",
style: TextStyle(
fontSize: 22.sp,
fontWeight: FontWeight.bold,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 4.h),
Text(
"Ride more to get more rewards 🚴",
style: TextStyle(
fontSize: 14.sp,
color: Colors.white70,
fontFamily: FontConstants.fontFamily,
),
),
],
),
Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20.r),
border: Border.all(color: Colors.white30, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.stars_rounded, // Coin-like icon
color: Colors.amberAccent,
size: 24.sp,
),
SizedBox(width: 8.w),
Obx(() {
return Text(
controller.isLoading.value
? "..."
: "${controller.totalPoints.value}",
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.bold,
color: Colors.amberAccent,
fontFamily: FontConstants.fontFamily,
),
);
}),
],
),
),
],
),
SizedBox(height: 16.h),
Container(
padding: EdgeInsets.all(12.r),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(12.r),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Keep riding correctly to get more points!",
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 4.h),
Text(
"Earn 100 points to unlock new rewards",
style: TextStyle(
fontSize: 14.sp,
color: Colors.white70,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
Icon(
Icons.emoji_events,
color: Colors.amber,
size: 32.sp,
),
],
),
),
],
),
),
),
if (showFullDetails) ...[
SizedBox(height: 24.h),
// 2. Surprise Gift Section
_buildSurpriseGiftCard(),
SizedBox(height: 24.h),
// 3. The 4 Cards Section
Text(
"Redeem Your Points",
style: TextStyle(
fontSize: 22.sp,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: Colors.black87,
),
),
SizedBox(height: 16.h),
// Card 1: Data Recharge
_buildRewardOptionCard(
title: "Data Recharge",
subtitle: "Get free data for 1 month",
points: "300 Points",
icon: Icons.wifi,
color1: const Color(0xFF11998e),
color2: const Color(0xFF38ef7d),
),
SizedBox(height: 16.h),
// Card 2: Bonus Fuel
_buildRewardOptionCard(
title: "Bonus Fuel",
subtitle: "Fuel support for your vehicle",
points: "600 Points",
icon: Icons.local_gas_station,
color1: const Color(0xFFFF5F6D),
color2: const Color(0xFFFFC371),
),
SizedBox(height: 16.h),
// Card 3: Gadgets
_buildRewardOptionCard(
title: "Gadgets Support",
subtitle: "Powerbank or New Mobile support",
points: "1000 - 1500 Points",
icon: Icons.devices_other,
color1: const Color(0xFF2193b0),
color2: const Color(0xFF6dd5ed),
),
SizedBox(height: 16.h),
// Card 4: Vehicle Support
_buildRewardOptionCard(
title: "Vehicle Support",
subtitle: "New vehicle or 50% loan support",
points: "2000 Bonus Points",
description: "Earn 2000 bonus without any loses in bonus point",
icon: Icons.motorcycle,
color1: const Color(0xFF8E2DE2),
color2: const Color(0xFF4A00E0),
isPremium: true,
),
SizedBox(height: 30.h),
// 4. Bottom Warning / Info Section
Container(
padding: EdgeInsets.all(16.r),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, color: Colors.red.shade700, size: 24.sp),
SizedBox(width: 12.w),
Expanded(
child: Text(
"Note: If you miss any deliveries or if requirements are not met for each delivery, negative bonus points will affect your board.",
style: TextStyle(
fontSize: 15.sp,
color: Colors.red.shade900,
fontFamily: FontConstants.fontFamily,
height: 1.4,
),
),
),
],
),
),
],
],
);
}
Widget _buildSurpriseGiftCard() {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16.r),
boxShadow: [
BoxShadow(
color: Colors.purple.withOpacity(0.1),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
),
child: Stack(
children: [
Positioned(
right: -20,
top: -20,
child: Icon(
Icons.card_giftcard,
size: 100.sp,
color: Colors.purple.withOpacity(0.05),
),
),
Padding(
padding: EdgeInsets.all(20.r),
child: Row(
children: [
Container(
padding: EdgeInsets.all(12.r),
decoration: BoxDecoration(
color: Colors.purple.shade50,
borderRadius: BorderRadius.circular(12.r),
),
child: Icon(Icons.card_giftcard, color: Colors.purple, size: 30.sp),
),
SizedBox(width: 16.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Monthly Surprise Gift",
style: TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: Colors.black87,
),
),
SizedBox(height: 4.h),
Text(
"If you didn't skip any orders in 1 month!",
style: TextStyle(
fontSize: 14.sp,
color: Colors.black54,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
],
),
),
],
),
);
}
Widget _buildRewardOptionCard({
required String title,
required String subtitle,
required String points,
required IconData icon,
required Color color1,
required Color color2,
String? description,
bool isPremium = false,
}) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20.r),
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20.r),
child: Stack(
children: [
// Decorative Background Circle
Positioned(
right: -30,
top: -30,
child: Container(
width: 120.w,
height: 120.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [color1.withOpacity(0.2), color2.withOpacity(0.0)],
begin: Alignment.bottomLeft,
end: Alignment.topRight,
),
),
),
),
Padding(
padding: EdgeInsets.all(20.r),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: EdgeInsets.all(10.r),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [color1, color2],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
shape: BoxShape.circle,
),
child: Icon(icon, color: Colors.white, size: 24.sp),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h),
decoration: BoxDecoration(
color: Colors.amber.shade50,
borderRadius: BorderRadius.circular(20.r),
border: Border.all(color: Colors.amber.shade200),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
points,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.bold,
color: Colors.amber.shade900,
fontFamily: FontConstants.fontFamily,
),
),
),
),
],
),
SizedBox(height: 16.h),
Text(
title,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: Colors.black87,
),
),
SizedBox(height: 4.h),
Text(
subtitle,
style: TextStyle(
fontSize: 15.sp,
color: Colors.black54,
fontFamily: FontConstants.fontFamily,
),
),
if (description != null) ...[
SizedBox(height: 12.h),
Container(
padding: EdgeInsets.all(10.r),
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: Colors.grey.shade200),
),
child: Row(
children: [
Icon(Icons.star_outline, size: 16.sp, color: Colors.blueGrey),
SizedBox(width: 6.w),
Expanded(
child: Text(
description,
style: TextStyle(
fontSize: 14.sp,
color: Colors.black87,
fontStyle: FontStyle.italic,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
)
],
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,587 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:nearle/Models/summary/riderweeklykms.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
import 'package:nearle/controllers/summary_controller.dart';
import 'package:shared_preferences/shared_preferences.dart';
class Summary extends StatefulWidget {
const Summary({super.key});
@override
State<Summary> createState() => _SummaryState();
}
class _SummaryState extends State<Summary> {
final SummaryController controller = Get.put(SummaryController());
int _userId = 0;
int _refreshTick = 0;
@override
void initState() {
super.initState();
_refreshData();
}
Future<void> _refreshData() async {
try {
final prefs = await SharedPreferences.getInstance();
final uid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0;
if (mounted) {
setState(() {
_userId = uid;
});
}
if (uid > 0) {
await controller.fetchSummaryStats(uid);
}
} catch (e) {
debugPrint("❌ Error fetching summary: $e");
} finally {
if (mounted) {
setState(() {
_refreshTick++;
});
}
}
}
// ------------------------
// RESPONSIVE CARD
// ------------------------
Widget _buildCard({
required String title,
required String value,
required String imagePath,
bool isCancelled = false,
}) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(
color: isCancelled
? const Color(0xFFFF5C5C)
: const Color.fromARGB(255, 159, 139, 163),
width: 1.2.w,
),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Image.asset(
imagePath,
height: 36.h,
width: 36.w,
color: isCancelled
? const Color(0xFFFF5C5C)
: const Color(0xFF9C27B0),
),
SizedBox(width: 10.w),
Expanded(
child: Text(
title,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
color: Colors.grey.shade700,
fontSize: 20.sp,
fontWeight: FontWeight.w400,
),
),
),
],
),
SizedBox(height: 30.h),
Text(
value,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 29.sp,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
],
),
),
);
}
// ------------------------
// RESPONSIVE CANCELLED CARD
// ------------------------
Widget _buildCancelledCard(String value) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(
color: const Color.fromARGB(255, 232, 167, 167),
width: 1.3.w,
),
borderRadius: BorderRadius.circular(10.r),
),
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h),
child: Row(
children: [
Image.asset(
'assets/images/cancel.png',
height: 36.h,
width: 36.w,
color: const Color(0xFFFF5C5C),
),
SizedBox(width: 15.w),
Expanded(
child: Text(
'Cancelled Orders',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
color: Colors.grey.shade700,
fontSize: 20.sp,
fontWeight: FontWeight.w400,
),
),
),
Text(
value,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 34.sp,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
],
),
);
}
// ------------------------
// MAIN UI
// ------------------------
@override
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
backgroundColor: Colors.grey.shade200,
appBar: AppBar(
backgroundColor: Colors.grey.shade200,
elevation: 0,
centerTitle: false,
toolbarHeight: 70.h,
title: Padding(
padding: EdgeInsets.only(top: 12.h),
child: Text(
"SUMMARY",
style: TextStyle(
fontSize: FontConstants.xxxLarge(context).sp,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: Colors.black,
),
),
),
bottom: PreferredSize(
preferredSize: Size.fromHeight(1.h),
child: Divider(height: 1.h, color: Colors.grey),
),
),
body: Obx(() {
return RefreshIndicator(
onRefresh: _refreshData,
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.all(16.r),
child: Column(
children: [
GridView.count(
crossAxisCount: 2,
crossAxisSpacing: 12.w,
mainAxisSpacing: 12.h,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildCard(
title: 'Today',
value: controller.today.value.toString(),
imagePath: 'assets/images/today.png',
),
_buildCard(
title: 'Week',
value: controller.week.value.toString(),
imagePath: 'assets/images/week.png',
),
_buildCard(
title: 'Month',
value: controller.month.value.toString(),
imagePath: 'assets/images/week.png',
),
_buildCard(
title: 'Total',
value: controller.total.value.toString(),
imagePath: 'assets/images/total.png',
),
],
),
SizedBox(height: 12.h),
_buildCancelledCard(controller.cancelled.value.toString()),
SizedBox(height: 12.h),
Row(
children: [
Text(
"Statistics",
style: TextStyle(
fontSize: 26.sp,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: Colors.black87,
),
),
],
),
SizedBox(height: 10.h),
TotalDistanceCard(userId: _userId, refreshTick: _refreshTick),
],
),
),
);
}),
),
);
}
}
// ========================================================
// RESPONSIVE GRAPH CARD
// ========================================================
class TotalDistanceCard extends StatefulWidget {
final int userId;
final int refreshTick;
const TotalDistanceCard({
super.key,
required this.userId,
this.refreshTick = 0,
});
@override
State<TotalDistanceCard> createState() => _TotalDistanceCardState();
}
class _TotalDistanceCardState extends State<TotalDistanceCard> {
late Future<Map<String, dynamic>> _futureKms;
@override
void initState() {
super.initState();
_futureKms = _fetchWeeklyKms();
}
@override
void didUpdateWidget(covariant TotalDistanceCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.userId != widget.userId ||
oldWidget.refreshTick != widget.refreshTick) {
setState(() {
_futureKms = _fetchWeeklyKms();
});
}
}
double _toDouble(dynamic v) {
if (v == null) return 0.0;
if (v is num) return v.toDouble();
return double.tryParse(v.toString()) ?? 0.0;
}
Future<Map<String, dynamic>> _fetchWeeklyKms() async {
if (widget.userId == 0) {
return {'details': <RiderWeeklyKms>[], 'total_kms': 0.0};
}
try {
final uri = Uri.parse(
'https://jupiter.nearle.app/live/api/v1/partners/getriderweeklykms?userid=${widget.userId}',
);
final response = await http.get(uri);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data is Map && data['status'] == true) {
final rawDetails = (data['details'] is List)
? data['details'] as List
: const [];
final details = rawDetails
.map((e) => RiderWeeklyKms.fromJson(e))
.toList();
final total = _toDouble(data['total_kms']);
return {'details': details, 'total_kms': total};
}
}
} catch (e) {
debugPrint('❌ _fetchWeeklyKms Error: $e');
}
return {'details': <RiderWeeklyKms>[], 'total_kms': 0.0};
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return FutureBuilder<Map<String, dynamic>>(
future: _futureKms,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 24.h),
child: const Center(
child: CircularProgressIndicator(color: Colors.deepPurple),
),
);
}
final List<RiderWeeklyKms> details =
snapshot.data?['details'] ?? <RiderWeeklyKms>[];
final double totalKms = snapshot.data?['total_kms'] ?? 0.0;
return _buildDistanceCard(details, totalKms, size);
},
);
}
Widget _buildDistanceCard(
List<RiderWeeklyKms> details,
double totalKms,
Size size,
) {
final double maxY = details.isEmpty ? 10 : _getMaxY(details);
final double chartHeight = (size.height * 0.25).clamp(160.h, 280.h);
final double leftInterval = _calculateInterval(maxY);
final double maxK = _getMaxKms(details);
return Container(
width: double.infinity,
margin: EdgeInsets.only(top: 4.h),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(
color: const Color.fromARGB(255, 222, 161, 235),
width: 1.3.w,
),
borderRadius: BorderRadius.circular(10.r),
),
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Total Distance",
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w700,
color: Colors.black,
),
),
Text(
"${totalKms.toStringAsFixed(2)} Km",
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w700,
color: Colors.black,
),
),
],
),
SizedBox(height: 30.h),
if (details.isEmpty)
Padding(
padding: EdgeInsets.symmetric(vertical: 8.h),
child: Center(
child: Text(
"No weekly data available",
style: TextStyle(color: Colors.grey, fontSize: 16.sp),
),
),
),
SizedBox(
height: chartHeight,
width: double.infinity,
child: BarChart(
BarChartData(
maxY: maxY,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (value) => FlLine(
color: Colors.grey.withOpacity(0.12),
strokeWidth: 1,
),
),
borderData: FlBorderData(show: false),
alignment: BarChartAlignment.spaceAround,
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 60.w,
interval: leftInterval,
getTitlesWidget: (value, _) => Text(
"${value.toInt()} km",
style: TextStyle(
fontSize: 14.sp,
color: Colors.black87,
),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, _) {
final idx = value.toInt();
if (idx >= 0 && idx < details.length) {
return Text(
details[idx].day,
style: TextStyle(
fontSize: 14.sp,
color: Colors.black87,
),
);
}
return const SizedBox.shrink();
},
),
),
),
barGroups: List.generate(details.isEmpty ? 7 : details.length, (
i,
) {
final kms = details.isEmpty ? 0.0 : details[i].kms.toDouble();
return BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: kms,
color: (details.isNotEmpty && kms == maxK)
? const Color(0xFF8124DB)
: const Color(0xFFB274F3),
width: 20.w,
borderRadius: BorderRadius.circular(6.r),
),
],
);
}),
barTouchData: BarTouchData(
enabled: true,
touchTooltipData: BarTouchTooltipData(
tooltipPadding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 8.h,
),
getTooltipItem: (group, index, rod, rodIndex) {
return BarTooltipItem(
"${rod.toY.toStringAsFixed(2)} Km",
TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.bold,
),
);
},
),
),
),
),
),
],
),
);
}
double _getMaxY(List<RiderWeeklyKms> details) {
if (details.isEmpty) return 10;
double maxVal = details.map((e) => e.kms).reduce(max);
if (maxVal <= 5) return 10;
final double withPadding = maxVal * 1.2;
return _roundUpNice(withPadding);
}
double _roundUpNice(double v) {
final exponent = pow(10, (log(v) / ln10).floor());
final mantissa = v / exponent;
double niceMantissa;
if (mantissa <= 1) {
niceMantissa = 1;
} else if (mantissa <= 2)
niceMantissa = 2;
else if (mantissa <= 5)
niceMantissa = 5;
else
niceMantissa = 10;
return (niceMantissa * exponent).ceilToDouble();
}
double _calculateInterval(double maxY) {
const int desiredTicks = 5;
double rough = max(1, (maxY / desiredTicks));
final exponent = pow(10, (log(rough) / ln10).floor());
final mantissa = rough / exponent;
double niceMantissa;
if (mantissa <= 1) {
niceMantissa = 1;
} else if (mantissa <= 2)
niceMantissa = 2;
else if (mantissa <= 5)
niceMantissa = 5;
else
niceMantissa = 10;
return (niceMantissa * exponent).toDouble();
}
double _getMaxKms(List<RiderWeeklyKms> details) {
if (details.isEmpty) return 0;
return details.map((e) => e.kms).reduce(max);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class ColorConstants {
static const primaryColor = Color(0xFF662582);
static Color? primaryColor1 = const Color(0xFFE7D3EF);
static Color? secondaryColor = Colors.white;
static Color? ternaryColor = "#E7D3EF".toColor();
static Color? darkGreyColor = "575756".toColor();
static Color? lightGrey = "b2b2b2".toColor();
static Color? lightGreyBg = Colors.grey.shade100;
static Color? greenColor = "00b894".toColor();
static Color? mintColor = "69c0ac".toColor();
static Color restaurantColor = Colors.amber[100]!;
static Color groceriesColor = Colors.purple[100]!;
static Color shoppingColor = Colors.orange[100]!;
static Color healthColor = Colors.cyan[100]!;
static Color handymanColor = Colors.red[100]!;
static const blueColor = 0xff007AC2;
static const redColor = 0xffEF3F42;
static const orangeColor = 0xffFAAB53;
static Color? lightColor = const Color.fromRGBO(244, 244, 244, 1);
}
extension ColorExtenstion on String {
// ignore: body_might_complete_normally_nullable
Color? toColor() {
var hexColor = replaceAll("#", "");
if (hexColor.length == 6) {
hexColor = "FF$hexColor";
}
if (hexColor.length == 8) {
return Color(int.parse("0x$hexColor"));
}
}
}

View File

@@ -0,0 +1,111 @@
// ignore: file_names
import 'package:flutter/material.dart';
class FontConstants {
static String fontFamily = 'Proxima Nova';
// Base screen width for scaling (iPhone standard: 375)
static const double _baseWidth = 375.0;
// Base font sizes (for baseWidth = 375)
static const double _baseExtraSmall = 10.0;
static const double _baseSmall = 12.0;
static const double _baseMedium = 14.0;
static const double _baseRegular = 16.0;
static const double _baseLarge = 20.0;
static const double _baseXLarge = 21.0;
static const double _baseXXLarge = 22.0;
static const double _baseXXXLarge = 24.0;
static const double _baseHuge = 30.0;
/// Get fixed font size (no width scaling) so small & large phones look same.
static double getResponsiveFontSize(BuildContext context, double baseSize) {
return baseSize;
}
/// Extra Small Text (10px base) - For labels, captions
static double extraSmall(BuildContext context) =>
getResponsiveFontSize(context, _baseExtraSmall);
/// Small Text (12px base) - For small labels, timestamps
static double small(BuildContext context) =>
getResponsiveFontSize(context, _baseSmall);
/// Medium Text (14px base) - For body text, descriptions
static double medium(BuildContext context) =>
getResponsiveFontSize(context, _baseMedium);
/// Regular Text (16px base) - Standard body text, most common
static double regular(BuildContext context) =>
getResponsiveFontSize(context, _baseRegular);
/// Large Text (18px base) - For subheadings, important text
static double large(BuildContext context) =>
getResponsiveFontSize(context, _baseLarge);
/// Extra Large Text (20px base) - For headings, titles
static double xLarge(BuildContext context) =>
getResponsiveFontSize(context, _baseXLarge);
/// 2X Large Text (22px base) - For main headings
static double xxLarge(BuildContext context) =>
getResponsiveFontSize(context, _baseXXLarge);
/// 3X Large Text (24px base) - For prominent headings
static double xxxLarge(BuildContext context) =>
getResponsiveFontSize(context, _baseXXXLarge);
/// Huge Text (26px base) - For hero text, very prominent headings
static double huge(BuildContext context) =>
getResponsiveFontSize(context, _baseHuge);
}
class ReusableTextWidget extends StatelessWidget {
final String text;
final double? fontSize;
final double? textHeight;
final String? fontFamily;
final FontWeight? fontWeight;
final FontStyle? fontStyle;
final Color? color;
final TextAlign? textAlign;
final int? maxLines;
final TextDecoration? isUnderText;
const ReusableTextWidget({
super.key,
required this.text,
this.fontSize,
this.textHeight,
this.fontFamily,
this.fontWeight,
this.fontStyle,
this.color,
this.textAlign,
this.maxLines,
this.isUnderText,
});
@override
Widget build(BuildContext context) {
return Text(
text,
softWrap: true,
style: TextStyle(
fontSize: fontSize ?? FontConstants.medium(context),
decoration: isUnderText,
fontFamily: fontFamily ?? FontConstants.fontFamily,
decorationColor: color,
fontWeight: fontWeight ?? FontWeight.normal,
fontStyle: fontStyle ?? FontStyle.normal,
color: color ?? Colors.grey.shade900,
overflow: TextOverflow.ellipsis,
decorationStyle: TextDecorationStyle.solid,
decorationThickness: 1,
height: textHeight,
),
maxLines: maxLines,
textAlign: textAlign ?? TextAlign.start,
);
}
}

View File

@@ -0,0 +1,78 @@
class ApiConstants {
static String mainDev = "dev";
static String mainRoute = "live";
//Delivery Queue - v2
static String deliveryQueueDev =
"https://jupiter.nearle.app/$mainDev/api/v2/deliveries/getdeliveryqueues";
static String deliveryQueueLive =
"https://jupiter.nearle.app/$mainRoute/api/v2/deliveries/getdeliveryqueues";
//Current Delivery - v1
static String currentDeliveryDev =
"https://jupiter.nearle.app/$mainDev/api/v1/deliveries/getdeliveries";
static String currentDeliveryLive =
"https://jupiter.nearle.app/$mainRoute/api/v1/deliveries/getdeliveries";
//Current Delivery V3 - v3 (date-bounded)
static String currentDeliveryV3Dev =
"https://jupiter.nearle.app/$mainDev/api/v3/deliveries/getdeliveries";
static String currentDeliveryV3Live =
"https://jupiter.nearle.app/$mainRoute/api/v3/deliveries/getdeliveries";
//Update Delivery - v1
static String updateDeliveryDev =
"https://queue.workolik.com/live/api/v1/deliveries/updatedelivery";
static String updateDeliveryLive =
"https://queue.workolik.com/live/api/v1/deliveries/updatedelivery";
//Get Rider Log - v1
static String getRiderLogDev =
"https://jupiter.nearle.app/$mainDev/api/v1/partners/getriderlog";
static String getRiderLogLive =
"https://jupiter.nearle.app/$mainRoute/api/v1/partners/getriderlog";
//Create Rider Log - v2
static String createRiderLogDev =
"https://queue.workolik.com/live/api/v2/partners/createriderlog";
static String createRiderLogLive =
"https://queue.workolik.com/live/api/v2/partners/createriderlog";
//Update Rider Log - v1
static String updateRiderLogDev =
"https://jupiter.nearle.app/$mainDev/api/v1/partners/updateriderlog";
static String updateRiderLogLive =
"https://jupiter.nearle.app/$mainRoute/api/v1/partners/updateriderlog";
//Get Rider Count - v1
static String getRiderCountDev =
"https://jupiter.nearle.app/$mainDev/api/v1/partners/getridercount";
static String getRiderCountLive =
"https://jupiter.nearle.app/$mainRoute/api/v1/partners/getridercount";
//Create Break Rider Log - v2
static String createBreakRiderLogDev =
"https://queue.workolik.com/live/api/v2/partners/createbreaklog";
static String createBreakRiderLogLive =
"https://queue.workolik.com/live/api/v2/partners/createbreaklog";
//Update Break Rider Log - v2
static String updateBreakRiderLogDev =
"https://queue.workolik.com/live/api/v2/partners/updatebreaklog";
static String updateBreakRiderLogLive =
"https://queue.workolik.com/live/api/v2/partners/updatebreaklog";
//Create Delivery Log - v2
static String createDeliveryLogDev =
"https://queue.workolik.com/live/api/v2/deliveries/createdeliverylog";
static String createDeliveryLogLive =
"https://queue.workolik.com/live/api/v2/deliveries/createdeliverylog";
//Summary API - v2
static String summaryApiLive =
'https://jupiter.nearle.app/$mainRoute/api/v2/partners';
//Summary Rider Weekly KMs - v1
static String summaryriderkmLive =
'https://jupiter.nearle.app/$mainRoute/api/v1/partners/getriderweeklykms';
}

View File

@@ -0,0 +1,18 @@
class MqttConstants {
static const String brokerHost = '66.116.225.226'; // Updated with VPS IP
static const int brokerPort = 1883;
static const String username = 'admin';
static const String passwordString = 'Package@321#'; // Provided by user
// Topic Structure
static const String topicRiderStatus = 'nearle/riders/{riderId}/status';
static const String topicRiderProfile = 'nearle/riders/{riderId}/profile';
static const String topicRiderLocation = 'nearle/riders/{riderId}/location';
static const String topicRiderTelemetry = 'nearle/riders/{riderId}/telemetry';
static const String topicRiderLogs = 'nearle/riders/{riderId}/logs';
// Status Values
static const String statusOnline = 'Online';
static const String statusOffline = 'Offline';
static const String statusIdle = 'Idle';
}

View File

@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
class Intro1 extends GetResponsiveView {
Intro1({super.key});
@override
Widget builder() {
// 🔹 Use `screen.height` and `screen.width` safely here
final height = screen.height;
final width = screen.width;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark.copyWith(
// Make the status bar area white instead of black, with dark icons
statusBarColor: Colors.white,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: Colors.white,
),
child: Scaffold(
backgroundColor: Colors.white,
appBar: PreferredSize(
preferredSize: Size.fromHeight(height * 0.12),
child: AppBar(
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
elevation: 0,
systemOverlayStyle: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.white,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: Colors.white,
),
flexibleSpace: Align(
alignment: Alignment.topLeft,
child: Container(
height: height * 0.14,
width: width * 0.25,
decoration: BoxDecoration(
color: ColorConstants.primaryColor,
borderRadius: BorderRadius.only(
bottomRight: Radius.circular(width * 0.25),
),
),
),
),
),
),
body: SafeArea(
child: Stack(
children: [
Positioned(
bottom: -height * 0.12,
left: -width * 0.1,
right: -width * 0.1,
child: Container(
width: width * 1.2,
height: height * 0.28,
decoration: BoxDecoration(
color: const Color(0xFFF3EAF9),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(width * 0.8),
topRight: Radius.circular(width * 0.8),
),
),
),
),
Center(
child: Padding(
padding: EdgeInsets.only(top: height * 0.08),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Column(
children: [
Transform.translate(
offset: Offset(0, -height * 0.08),
child: Text(
'Welcome to',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: height * 0.045,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
),
Transform.translate(
offset: Offset(0, -height * 0.1),
child: Text(
'Nearle !',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: height * 0.045,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
Transform.translate(
offset: Offset(0, -height * 0.08),
child: Text(
'Find delivery opportunities anytime,\nanywhere | Earn with ease!',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: height * 0.022,
color: Colors.grey,
fontFamily: FontConstants.fontFamily,
),
),
),
Transform.translate(
offset: Offset(0, -height * 0.09),
child: Image.asset(
'assets/images/intro1.png',
height: height * 0.35,
width: width * 0.75,
fit: BoxFit.contain,
),
),
],
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
class Intro2 extends GetResponsiveView {
Intro2({super.key});
@override
Widget builder() {
final height = screen.height;
final width = screen.width;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark.copyWith(
// Make the status bar area white instead of black, with dark icons
statusBarColor: Colors.white,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: Colors.white,
),
child: Scaffold(
backgroundColor: Colors.white,
appBar: PreferredSize(
preferredSize: Size.fromHeight(height * 0.12),
child: AppBar(
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
elevation: 0,
systemOverlayStyle: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.white,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: Colors.white,
),
flexibleSpace: Align(
alignment: Alignment.topRight,
child: Container(
height: height * 0.14,
width: width * 0.25,
decoration: BoxDecoration(
color: ColorConstants.primaryColor,
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(width * 0.25),
),
),
),
),
),
),
body: SafeArea(
child: Stack(
children: [
// Bottom curve
Positioned(
bottom: -height * 0.12,
left: -width * 0.1,
right: -width * 0.1,
child: Container(
width: width * 1.2,
height: height * 0.28,
decoration: BoxDecoration(
color: const Color(0xFFF3EAF9),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(width * 0.8),
topRight: Radius.circular(width * 0.8),
),
),
),
),
// Center content
Center(
child: Padding(
padding: EdgeInsets.only(top: height * 0.08),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Title
Column(
children: [
Transform.translate(
offset: Offset(0, -height * 0.08),
child: Text(
'Orders That',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: height * 0.045,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
),
Transform.translate(
offset: Offset(0, -height * 0.1),
child: Text(
'Find You',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: height * 0.045,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
Transform.translate(
offset: Offset(0, -height * 0.08),
child: Text(
'Get assigned deliveries based on your\nlocation for faster and smarter work.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: height * 0.022,
color: Colors.grey,
fontFamily: FontConstants.fontFamily,
),
),
),
// Image in center
Transform.translate(
offset: Offset(0, -height * 0.09),
child: Image.asset(
'assets/images/intro2.png',
height: height * 0.35,
width: width * 0.75,
fit: BoxFit.contain,
),
),
],
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,152 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
class Intro3 extends GetResponsiveView {
final VoidCallback onFinish;
Intro3({
super.key,
required this.onFinish,
required PageController controller,
});
@override
Widget builder() {
final height = screen.height;
final width = screen.width;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light.copyWith(
// Keep status bar transparent over the purple gradient on this screen
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
systemNavigationBarColor: Colors.white,
),
child: Scaffold(
backgroundColor: Colors.white,
appBar: PreferredSize(
preferredSize: Size.fromHeight(height * 0.35),
child: AppBar(
automaticallyImplyLeading: false,
elevation: 0,
backgroundColor: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle.light.copyWith(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
),
flexibleSpace: Container(
width: double.infinity,
height: height * 0.36,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
ColorConstants.primaryColor,
ColorConstants.primaryColor,
],
),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(width * 0.49),
bottomRight: Radius.circular(width * 0.49),
),
),
child: SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: width * 0.06,
vertical: height * 0.02,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(height: height * 0.06),
Text(
'Deliver. Earn.\nRepeat.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: height * 0.045,
fontWeight: FontWeight.bold,
color: Colors.white,
height: 1.2,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: height * 0.02),
Text(
'Track your trips, and enjoy \npayouts every week.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: height * 0.019,
color: Colors.white.withOpacity(0.9),
height: 1.4,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
),
),
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: width * 0.05),
child: Transform.translate(
offset: Offset(0, -height * 0.01),
child: Image.asset(
'assets/images/intro3.png',
height: height * 0.35,
width: width * 0.75,
fit: BoxFit.contain,
),
),
),
),
SizedBox(height: height * 0.04),
// Button at bottom
Padding(
padding: EdgeInsets.symmetric(horizontal: width * 0.08),
child: SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton(
onPressed: onFinish,
style: ElevatedButton.styleFrom(
backgroundColor: ColorConstants.primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
"Start",
style: TextStyle(
fontSize: 21,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: Colors.white,
),
),
),
),
),
SizedBox(height: height * 0.03),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:nearle/views/introscreens/intro1.dart';
import 'package:nearle/views/introscreens/intro2.dart';
import 'package:nearle/views/introscreens/intro3.dart';
import 'package:nearle/views/onboardscreens/Sign_in.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:smooth_page_indicator/smooth_page_indicator.dart';
class Introscreen extends StatefulWidget {
const Introscreen({super.key});
@override
State<Introscreen> createState() => _IntroscreenState();
}
class _IntroscreenState extends State<Introscreen> {
late final PageController _controller;
static const String _prefsHasSeenIntroKey = 'has_seen_intro';
void _finishOnboarding() {
_completeOnboarding();
}
Future<void> _completeOnboarding() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsHasSeenIntroKey, true);
} catch (_) {}
if (!mounted) return;
Get.offAll(() => const SignIn());
}
@override
void initState() {
super.initState();
_controller = PageController();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
alignment: Alignment.bottomCenter, // positions the indicator
children: [
PageView(
controller: _controller,
children: [
Intro1(),
Intro2(),
Intro3(controller: _controller, onFinish: _finishOnboarding),
],
),
Container(
// Move indicator slightly up so it doesn't sit under the bottom curve
alignment: const Alignment(0, 0.55),
child: SmoothPageIndicator(
controller: _controller,
count: 3,
effect: ExpandingDotsEffect(
expansionFactor: 3, // How much the active dot expands
dotHeight: 7,
dotWidth: 7,
spacing: 8,
dotColor: Colors.grey,
activeDotColor: ColorConstants.primaryColor,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'dart:async';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:nearle/views/introscreens/introscreen.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:nearle/widget/Bottom_page.dart';
import 'package:nearle/views/onboardscreens/Sign_in.dart';
import 'package:nearle/views/onboardscreens/signin_banner.dart';
import 'package:nearle/views/updatescreen/UpdateScreen.dart';
import 'package:new_version_plus/new_version_plus.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:nearle/views/onboardscreens/Mpin.dart';
import 'package:nearle/controllers/auth.dart';
class Splashscreen extends StatefulWidget {
const Splashscreen({super.key});
@override
State<Splashscreen> createState() => _SplashscreenState();
}
class _SplashscreenState extends State<Splashscreen> {
late final ImageProvider _logoProvider;
bool _imagePrecached = false;
bool _hasCheckedUpdate = false;
@override
void initState() {
super.initState();
_logoProvider = const AssetImage("assets/images/nearlesplash2.png");
WidgetsBinding.instance.addPostFrameCallback((_) async {
try {
await precacheImage(_logoProvider, context);
if (mounted) {
setState(() {
_imagePrecached = true;
});
}
} catch (e) {
if (mounted) {
setState(() {
_imagePrecached = true;
});
}
}
});
// FAST splash: 1 second
Timer(const Duration(seconds: 1), () {
if (mounted) {
_startNextStep();
}
});
}
// FAST second step: 0.2 sec
void _startNextStep() {
Timer(const Duration(milliseconds: 200), () {
if (mounted && !_hasCheckedUpdate) {
_checkForUpdateAndNavigate();
}
});
}
Future<void> _checkForUpdateAndNavigate() async {
if (_hasCheckedUpdate) return;
_hasCheckedUpdate = true;
try {
final newVersion = NewVersionPlus(
iOSId: '284882215',
androidId: "com.nearle.partner",
);
final status = await newVersion.getVersionStatus();
if (status != null && status.canUpdate) {
if (mounted) {
Get.offAll(
() => UpdateScreen(
mCurrentVersion: status.localVersion,
mUpdateVersion: status.storeVersion,
mIsForceUpdate: true,
),
transition: Transition.fadeIn,
);
}
return;
}
} catch (e) {}
_navigateToNextScreen();
}
Future<void> _navigateToNextScreen() async {
final prefs = await SharedPreferences.getInstance();
final bool isLoggedOut = prefs.getBool('logged_out') ?? false;
final bool hasSeenIntro = prefs.getBool('has_seen_intro') ?? false;
final int? savedUserId = prefs.getInt('userid');
// 🚀 Check for App Update (Force Login if version changed)
try {
final packageInfo = await PackageInfo.fromPlatform();
final currentVersion = packageInfo.version;
final lastRunVersion = prefs.getString('last_run_version');
if (lastRunVersion != null && lastRunVersion != currentVersion) {
debugPrint(
'[SPLASH] App update detected: $lastRunVersion -> $currentVersion. Forcing re-verification.',
);
final String? savedPhone = prefs.getString('contactno');
final int? savedUserId = prefs.getInt('userid');
// Update stored version
await prefs.setString('last_run_version', currentVersion);
if (savedUserId != null && savedPhone != null && savedPhone.isNotEmpty) {
debugPrint('[SPLASH] User was logged in. Redirecting to MPIN page.');
// Initialize AuthController and set the phone for MPIN verification
final auth = Get.put(AuthController());
auth.currentPhone = savedPhone;
// Clear sensitive session data to force re-verification
await prefs.remove('userid');
await prefs.remove('partnerid');
await prefs.remove('onduty');
await prefs.setBool('logged_out', true);
if (mounted) {
Get.offAll(() => Mpin());
}
return;
} else {
debugPrint('[SPLASH] User was not logged in. Redirecting to Sign In.');
if (mounted) {
Get.offAll(() => const SignIn());
}
return;
}
}
// Save current version for next run
await prefs.setString('last_run_version', currentVersion);
} catch (e) {
debugPrint('[SPLASH] Version check error: $e');
}
if (!mounted) return;
if (!isLoggedOut && savedUserId != null && savedUserId > 0) {
final onduty = prefs.getInt('onduty') ?? 0;
if (onduty == 0) {
Get.offAll(() => const SigninBanner());
} else {
Get.offAll(() => const BottomPage());
}
} else {
if (hasSeenIntro) {
Get.offAll(() => const SignIn());
} else {
Get.offAll(() => Introscreen());
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorConstants.secondaryColor,
body: SafeArea(
child: Center(
child: _imagePrecached
? Image(
image: _logoProvider,
fit: BoxFit.contain,
)
: const SizedBox(),
),
),
);
}
}

View File

@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
import 'package:get/get.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:nearle/views/introscreens/splashscreen.dart';
class OfflinePage extends StatelessWidget {
const OfflinePage({super.key});
Future<void> _handleRetry(BuildContext context) async {
try {
final results = await Connectivity().checkConnectivity();
final isOnline = results.isNotEmpty && results.any((r) => r != ConnectivityResult.none);
if (isOnline) {
// Return to normal app flow; Splashscreen decides login vs home
Get.offAll(() => Splashscreen());
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Still offline. Please check your internet connection.'),
backgroundColor: Colors.black87,
behavior: SnackBarBehavior.floating,
),
);
} catch (_) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
}
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final height = MediaQuery.of(context).size.height;
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Stack(
children: [
Positioned(
bottom: -height * 0.12,
left: -width * 0.1,
right: -width * 0.1,
child: Container(
width: width * 1.2,
height: height * 0.28,
decoration: BoxDecoration(
color: const Color(0xFFF3EAF9),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(width * 0.8),
topRight: Radius.circular(width * 0.8),
),
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: ColorConstants.primaryColor.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.wifi_off,
color: ColorConstants.primaryColor,
size: 64,
),
),
const SizedBox(height: 24),
Text(
'You are offline',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: Colors.black,
),
),
const SizedBox(height: 12),
Text(
'Please check your internet connection. We\'ll reconnect automatically when you\'re back online.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.grey[700],
fontFamily: FontConstants.fontFamily,
),
),
const SizedBox(height: 24),
SizedBox(
width: 180,
height: 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: ColorConstants.primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: () => _handleRetry(context),
child: const Text(
'Retry',
style: TextStyle(color: Colors.white, fontSize: 16),
),
),
),
],
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,322 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
import 'package:nearle/controllers/auth.dart';
import 'package:nearle/views/onboardscreens/Mpin.dart';
class CreateMpin extends GetResponsiveView {
CreateMpin({super.key});
@override
Widget builder() {
return const _CreateMpinBody();
}
}
class _CreateMpinBody extends StatefulWidget {
const _CreateMpinBody();
@override
State<_CreateMpinBody> createState() => _CreateMpinBodyState();
}
class _CreateMpinBodyState extends State<_CreateMpinBody> {
final AuthController _auth = Get.put(AuthController());
final List<TextEditingController> _newMpinControllers = List.generate(
4,
(_) => TextEditingController(),
);
final List<TextEditingController> _confirmMpinControllers = List.generate(
4,
(_) => TextEditingController(),
);
final List<FocusNode> _newFocusNodes = List.generate(4, (_) => FocusNode());
final List<FocusNode> _confirmFocusNodes = List.generate(
4,
(_) => FocusNode(),
);
bool isLoading = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_newFocusNodes[0].requestFocus();
});
}
@override
void dispose() {
for (var c in [..._newMpinControllers, ..._confirmMpinControllers]) {
c.dispose();
}
for (var f in [..._newFocusNodes, ..._confirmFocusNodes]) {
f.dispose();
}
super.dispose();
}
void _onMpinChange(
String value,
int index,
List<TextEditingController> controllers,
List<FocusNode> nodes,
) {
if (value.isNotEmpty && index < 3) {
nodes[index + 1].requestFocus();
} else if (value.isEmpty && index > 0) {
nodes[index - 1].requestFocus();
}
final isGroupFilled = controllers.every((c) => c.text.isNotEmpty);
if (controllers == _newMpinControllers && isGroupFilled) {
_confirmFocusNodes[0].requestFocus();
}
if (controllers == _confirmMpinControllers && isGroupFilled) {
FocusScope.of(context).unfocus();
}
setState(() {});
}
String getMpin(List<TextEditingController> controllers) =>
controllers.map((c) => c.text).join();
bool get isMpinMatched =>
getMpin(_newMpinControllers) == getMpin(_confirmMpinControllers);
bool get isAllFilled => [
..._newMpinControllers,
..._confirmMpinControllers,
].every((c) => c.text.isNotEmpty);
@override
Widget build(BuildContext context) {
final screen = context.width < 600
? "mobile"
: context.width < 1100
? "tablet"
: "desktop";
final height = Get.height;
final width = Get.width;
// Adjust scale based on device
final scale = screen == "mobile"
? 1.0
: screen == "tablet"
? 1.3
: 1.6;
return Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: width * 0.06),
child: Column(
children: [
SizedBox(height: height * 0.04 * scale),
SizedBox(
height: height * 0.25 * scale,
width: width * 0.6,
child: Image.asset(
"assets/images/CreateMpin.png",
fit: BoxFit.contain,
),
),
SizedBox(height: height * 0.03 * scale),
Text(
"Create Your MPIN",
style: TextStyle(
fontSize: height * 0.04 * scale,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.primaryColor,
),
),
SizedBox(height: height * 0.015 * scale),
Text(
"Enter a 4-digit MPIN and confirm it to secure your account.",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: height * 0.02 * scale,
color: Colors.black54,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: height * 0.04 * scale),
_buildMpinField(
"Enter New MPIN",
_newMpinControllers,
_newFocusNodes,
scale,
),
SizedBox(height: height * 0.03 * scale),
_buildMpinField(
"Confirm MPIN",
_confirmMpinControllers,
_confirmFocusNodes,
scale,
),
],
),
),
),
Positioned(
top: height * 0.05,
left: width * 0.04,
child: InkWell(
onTap: () => Get.back(),
borderRadius: BorderRadius.circular(30),
child: Container(
padding: EdgeInsets.all(width * 0.02),
decoration: const BoxDecoration(
color: Colors.black12,
shape: BoxShape.circle,
),
child: Icon(
Icons.arrow_back,
color: Colors.black,
size: width * 0.06,
),
),
),
),
],
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: EdgeInsets.all(width * 0.04),
child: SizedBox(
width: double.infinity,
height: height * 0.065 * scale,
child: ElevatedButton(
onPressed: isAllFilled && isMpinMatched && !isLoading
? () async {
setState(() => isLoading = true);
final newPin = getMpin(_newMpinControllers);
// Set PIN directly - user ID should already be available from login flow
final ok = await _auth.setPin(newPin);
setState(() => isLoading = false);
if (ok) {
// After setting PIN, go to verify PIN page to sign-in with new PIN
Get.to(() => Mpin());
} else {
Get.snackbar(
'Failed',
'Unable to set PIN. Please try again.',
);
}
}
: null,
style: ElevatedButton.styleFrom(
backgroundColor: ColorConstants.primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(width * 0.03),
),
),
child: isLoading
? SizedBox(
height: height * 0.03,
width: height * 0.03,
child: const CircularProgressIndicator(
strokeWidth: 3,
color: Colors.white,
),
)
: Text(
"Continue",
style: TextStyle(
fontSize: height * 0.024 * scale,
fontWeight: FontWeight.bold,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
),
),
),
);
}
Widget _buildMpinField(
String label,
List<TextEditingController> controllers,
List<FocusNode> focusNodes,
double scale,
) {
final height = Get.height;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: height * 0.02 * scale,
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: height * 0.015 * scale),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(4, (index) {
return SizedBox(
width: 50 * scale,
height: 55 * scale,
child: TextField(
controller: controllers[index],
focusNode: focusNodes[index],
textAlign: TextAlign.center,
obscureText: true,
maxLength: 1,
keyboardType: TextInputType.number,
style: TextStyle(
fontSize: height * 0.025 * scale,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
decoration: InputDecoration(
counterText: "",
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8 * scale),
borderSide: const BorderSide(color: Colors.grey),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8 * scale),
borderSide: const BorderSide(
color: ColorConstants.primaryColor,
width: 2,
),
),
),
onChanged: (val) =>
_onMpinChange(val, index, controllers, focusNodes),
),
);
}),
),
],
);
}
}

View File

@@ -0,0 +1,343 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
import 'package:nearle/views/onboardscreens/Sign_in.dart';
import 'package:nearle/views/onboardscreens/otp_page.dart';
import 'package:nearle/widget/Bottom_page.dart';
import 'package:nearle/controllers/auth.dart';
import 'package:nearle/views/onboardscreens/signin_banner.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/services.dart';
class Mpin extends GetResponsiveView {
Mpin({super.key});
@override
Widget builder() {
return const _MpinView();
}
}
class _MpinView extends StatefulWidget {
const _MpinView();
@override
State<_MpinView> createState() => _MpinViewState();
}
class _MpinViewState extends State<_MpinView> {
final AuthController _auth = Get.put(AuthController());
final List<TextEditingController> _mpinControllers = List.generate(
4,
(_) => TextEditingController(),
);
final List<FocusNode> _focusNodes = List.generate(4, (_) => FocusNode());
bool isVerifying = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNodes[0].requestFocus();
_maybeShowMasterPinReminder();
});
}
@override
void dispose() {
for (var c in _mpinControllers) {
c.dispose();
}
for (var f in _focusNodes) {
f.dispose();
}
super.dispose();
}
void _onMpinChange(String value, int index) {
if (value.isNotEmpty && index < 3) {
_focusNodes[index + 1].requestFocus();
} else if (value.isEmpty && index > 0) {
_focusNodes[index - 1].requestFocus();
}
final filled = _mpinControllers.every((c) => c.text.isNotEmpty);
if (filled) {
FocusScope.of(context).unfocus();
_submitMpin(); // auto-verify when 4 digits are filled
}
setState(() {});
}
String getMpin() => _mpinControllers.map((c) => c.text).join();
bool get isMpinFilled => _mpinControllers.every((c) => c.text.isNotEmpty);
void _clearMpinAndFocus() {
for (final c in _mpinControllers) {
c.clear();
}
if (_focusNodes.isNotEmpty) {
FocusScope.of(context).requestFocus(_focusNodes[0]);
}
setState(() {});
}
Future<void> _submitMpin() async {
if (!mounted || isVerifying || !isMpinFilled) return;
// Register retry callback so AuthController bottom-sheet "Retry" button
// can clear MPIN boxes and bring back keyboard when PIN is wrong.
_auth.onPinRetry = _clearMpinAndFocus;
setState(() => isVerifying = true);
final mpin = getMpin();
final ok = await _auth.verifyPinWithServer(mpin);
if (!mounted) return;
setState(() => isVerifying = false);
if (ok) {
// ✅ Wait a moment to ensure onduty is saved, then read it
await Future.delayed(const Duration(milliseconds: 100));
final prefs = await SharedPreferences.getInstance();
final onduty = prefs.getInt('onduty') ?? 0;
debugPrint('[MPIN] After verification - onduty=$onduty');
// Navigate based on onduty value
if (onduty == 0) {
debugPrint('[MPIN] Navigating to Introscreen (onduty=0)');
Get.offAll(() => SigninBanner());
} else {
debugPrint('[MPIN] Navigating to BottomPage (onduty=1)');
Get.offAll(() => const BottomPage());
}
} else {
// Wrong MPIN: show alert/snackbar and keep user on MPIN screen
}
}
Future<void> _maybeShowMasterPinReminder() async {
try {
final prefs = await SharedPreferences.getInstance();
final forceMasterPin =
prefs.getBool(AuthController.forceMasterPinPrefKey) ?? false;
if (forceMasterPin) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Use ${AuthController.masterPinValue} as PIN to continue.',
),
duration: const Duration(seconds: 4),
behavior: SnackBarBehavior.floating,
),
);
}
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final height = size.height;
final width = size.width;
double scale = 1.0;
if (Get.width < 380) scale = 0.9;
if (Get.width > 800) scale = 1.2;
return Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: width * 0.05),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(height: height * 0.04),
SizedBox(
height: height * 0.25 * scale,
width: width * 0.6,
child: Image.asset(
"assets/images/Mpin.png",
fit: BoxFit.contain,
),
),
SizedBox(height: height * 0.04),
Text(
"Enter Your MPIN",
style: TextStyle(
fontSize: height * 0.05 * scale,
fontWeight: FontWeight.bold,
color: ColorConstants.primaryColor,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: height * 0.01),
Text(
"Access your account securely",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: height * 0.024 * scale,
color: Colors.black54,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: height * 0.06),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(4, (index) {
final isFilled = _mpinControllers[index].text.isNotEmpty;
return SizedBox(
width: 50 * scale,
height: 55 * scale,
child: RawKeyboardListener(
focusNode: FocusNode(),
onKey: (event) {
if (event is RawKeyDownEvent &&
event.logicalKey ==
LogicalKeyboardKey.backspace &&
_mpinControllers[index].text.isEmpty &&
index > 0) {
_mpinControllers[index - 1].clear();
_focusNodes[index - 1].requestFocus();
setState(() {});
}
},
child: TextField(
controller: _mpinControllers[index],
focusNode: _focusNodes[index],
textAlign: TextAlign.center,
textAlignVertical: TextAlignVertical.center,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 1,
style: TextStyle(
fontSize: height * 0.028 * scale,
fontWeight: FontWeight.bold,
color: isFilled ? Colors.white : Colors.black,
),
decoration: InputDecoration(
counterText: "",
contentPadding: EdgeInsets.zero,
filled: true,
fillColor: isFilled
? ColorConstants.primaryColor
: Colors.transparent,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8 * scale),
borderSide: const BorderSide(
color: Colors.grey,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8 * scale),
borderSide: const BorderSide(
color: Color(0xFF662582),
width: 2,
),
),
),
onChanged: (value) => _onMpinChange(value, index),
),
),
);
}),
),
SizedBox(height: height * 0.02),
// Retry + Forget Pin row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: () {
// Clear all MPIN boxes and focus first, bringing up keyboard
for (final c in _mpinControllers) {
c.clear();
}
if (_focusNodes.isNotEmpty) {
FocusScope.of(context).requestFocus(_focusNodes[0]);
}
setState(() {});
},
child: Text(
"",
style: TextStyle(
fontSize: height * 0.022 * scale,
fontWeight: FontWeight.w600,
color: Colors.black87,
fontFamily: FontConstants.fontFamily,
),
),
),
InkWell(
onTap: () async {
await _auth.sendOtp();
Get.to(OtpPage());
},
child: Transform.translate(
offset: Offset(-18, 0),
child: Text(
"Forget Pin?",
style: TextStyle(
fontSize: height * 0.025 * scale,
fontWeight: FontWeight.bold,
color: ColorConstants.primaryColor,
fontFamily: FontConstants.fontFamily,
decoration: TextDecoration.underline,
),
),
),
),
],
),
],
),
),
),
Positioned(
top: height * 0.05,
left: width * 0.04,
child: InkWell(
onTap: () {
Get.to(SignIn());
},
borderRadius: BorderRadius.circular(30),
child: Container(
padding: EdgeInsets.all(width * 0.02),
decoration: const BoxDecoration(
color: Colors.black12,
shape: BoxShape.circle,
),
child: Icon(
Icons.arrow_back,
color: Colors.black,
size: width * 0.06,
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,339 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
import 'package:nearle/views/onboardscreens/otp_page.dart';
import 'package:nearle/views/onboardscreens/Mpin.dart';
import 'package:nearle/controllers/auth.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter/gestures.dart';
import 'package:nearle/providers/notifications/notificationservce.dart';
class LoginController extends GetxController {
var isChecked = true.obs;
final AuthController auth = Get.put(AuthController());
}
class SignIn extends StatefulWidget {
const SignIn({super.key});
@override
State<SignIn> createState() => _SignInState();
}
class _SignInState extends State<SignIn> {
final LoginController controller = Get.put(LoginController());
final TextEditingController phoneController = TextEditingController();
final FocusNode _phoneFocusNode = FocusNode();
bool isPhoneValid = false;
bool isLoading = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
FocusScope.of(context).requestFocus(_phoneFocusNode);
// Request notification permission the first time Sign In screen is shown
NotificationServce.initialize(context);
});
}
bool _validatePhoneNumber(String number) {
final RegExp regExp = RegExp(r'^[6-9]\d{9}$');
return regExp.hasMatch(number);
}
@override
void dispose() {
_phoneFocusNode.dispose();
phoneController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final height = size.height;
final width = size.width;
double scale = 1.0;
if (width < 380) scale = 0.9;
if (width > 800) scale = 1.2;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.white,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: Colors.white,
),
child: Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
top: false,
left: true,
right: true,
bottom: true,
child: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: width * 0.05,
vertical: height * 0.02,
),
child: GetBuilder<LoginController>(
builder: (_) => Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: height * 0.04),
// Restore hero image at the top like before
Image.asset(
'assets/images/Nearle Bike.png',
height: height * 0.28,
fit: BoxFit.contain,
),
SizedBox(height: height * 0.03),
Text(
"Sign In",
style: TextStyle(
fontSize: height * 0.05 * scale,
fontWeight: FontWeight.bold,
color: ColorConstants.primaryColor,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: height * 0.02),
Text(
"Enter your mobile number to get started.",
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black54,
fontSize: FontConstants.xLarge(context),
fontFamily: FontConstants.fontFamily,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: height * 0.05),
// Phone number field
SizedBox(
height: height * 0.07,
width: width * 0.9,
child: TextField(
controller: phoneController,
focusNode: _phoneFocusNode,
keyboardType: TextInputType.number,
maxLength: 10,
style: TextStyle(
fontSize: FontConstants.large(context),
fontWeight: FontWeight.w500,
color: Colors.black,
),
onChanged: (value) {
setState(() {
isPhoneValid = _validatePhoneNumber(value);
});
if (value.length == 10 && isPhoneValid) {
FocusScope.of(context).unfocus();
}
},
decoration: InputDecoration(
counterText: '',
labelText: 'Enter mobile number',
labelStyle: TextStyle(
color: Colors.grey,
fontSize: width * 0.04,
fontFamily: FontConstants.fontFamily,
),
prefixIcon: Padding(
padding: EdgeInsets.symmetric(
horizontal: width * 0.02,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
"assets/images/in.png",
height: height * 0.045,
width: width * 0.09,
),
SizedBox(width: width * 0.01),
Text(
"+91",
style: TextStyle(
fontSize: FontConstants.large(context),
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFF662582),
width: 1.5,
),
),
),
),
),
// Validation message
if (!isPhoneValid && phoneController.text.isNotEmpty)
Padding(
padding: EdgeInsets.only(
left: width * 0.02,
top: height * 0.005,
),
child: Text(
'Enter a valid 10-digit mobile number',
style: TextStyle(
color: Colors.red.shade700,
fontSize: width * 0.03,
),
),
),
SizedBox(height: height * 0.02),
// Terms text with clickable T&C and Privacy Policy
RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: TextStyle(
fontSize: 15,
fontFamily: FontConstants.fontFamily,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
children: [
const TextSpan(text: 'By continuing, you agree to '),
TextSpan(
text: 'T&C',
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.none,
),
recognizer: TapGestureRecognizer()
..onTap = () async {
final uri = Uri.parse(
'https://nearle.in/terms',
);
final ok = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!ok) {
await launchUrl(
uri,
mode: LaunchMode.inAppWebView,
);
}
},
),
const TextSpan(text: ' and '),
TextSpan(
text: 'Privacy Policy',
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.none,
),
recognizer: TapGestureRecognizer()
..onTap = () async {
final uri = Uri.parse(
'https://nearle.in/privacy',
);
final ok = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!ok) {
await launchUrl(
uri,
mode: LaunchMode.inAppWebView,
);
}
},
),
],
),
),
SizedBox(height: height * 0.02),
],
),
),
),
),
),
// Bottom Button
bottomNavigationBar: SafeArea(
child: Padding(
padding: EdgeInsets.all(width * 0.04),
child: SizedBox(
height: height * 0.065,
width: double.infinity,
child: ElevatedButton(
onPressed: isPhoneValid && !isLoading
? () async {
setState(() => isLoading = true);
final decision = await controller.auth.precheckPhone(
phoneController.text,
);
setState(() => isLoading = false);
if (decision == AuthNext.notRegistered) {
return;
} else if (decision == AuthNext.otp) {
await controller.auth.sendOtp(phoneController.text);
Get.to(OtpPage());
} else if (decision == AuthNext.verifyPin) {
Get.to(() => Mpin());
} else {
Get.snackbar(
'Error',
'Unable to proceed. Please try again.',
);
}
}
: null,
style: ElevatedButton.styleFrom(
backgroundColor: isPhoneValid
? const Color(0xFF662582)
: Colors.grey.shade400,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: EdgeInsets.symmetric(vertical: height * 0.015),
),
child: isLoading
? SizedBox(
height: height * 0.035,
width: height * 0.035,
child: const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
)
: Text(
'Next',
style: TextStyle(
color: Colors.white,
fontSize: width * 0.06,
fontWeight: FontWeight.bold,
),
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,395 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
import 'package:nearle/views/onboardscreens/Sign_in.dart';
import 'package:nearle/controllers/auth.dart';
import 'package:nearle/views/onboardscreens/Creat_mpin.dart';
import 'package:sms_autofill/sms_autofill.dart';
import 'package:shared_preferences/shared_preferences.dart';
class OtpPage extends GetResponsiveView {
OtpPage({super.key});
@override
Widget? phone() => _OtpPageLayout();
@override
Widget? tablet() => _OtpPageLayout(scale: 1.2);
@override
Widget? desktop() => _OtpPageLayout(scale: 1.3);
}
class _OtpPageLayout extends StatefulWidget {
final double scale;
const _OtpPageLayout({this.scale = 1.0});
@override
State<_OtpPageLayout> createState() => _OtpPageLayoutState();
}
class _OtpPageLayoutState extends State<_OtpPageLayout> with CodeAutoFill {
final AuthController _auth = Get.put(AuthController());
final List<TextEditingController> _otpControllers = List.generate(
6,
(_) => TextEditingController(),
);
final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode());
bool isVerifying = false;
int _secondsRemaining = 60;
Timer? _timer;
String _appSignature = '';
// Consent fallback removed due to plugin AGP incompatibility
int _smsDefaultProvider = 0; // 0 = normal, 1 = passkey provider
int? _smsPassKey; // when provider is passkey based
@override
void initState() {
super.initState();
_startTimer();
_listenForOtp();
_loadOtpProviderPrefs();
// Ensure cursor starts in first box
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _focusNodes.isNotEmpty) {
_focusNodes[0].requestFocus();
}
});
}
void _startTimer() {
_secondsRemaining = 60;
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_secondsRemaining > 0) {
setState(() => _secondsRemaining--);
} else {
timer.cancel();
}
});
}
Future<void> _resendOtp() async {
setState(() => _secondsRemaining = 60);
_timer?.cancel();
_startTimer();
await _auth.sendOtp();
}
Future<void> _listenForOtp() async {
try {
await SmsAutoFill().unregisterListener();
listenForCode();
// Fetch and cache app hash for SMS retriever compatibility
try {
final sig = await SmsAutoFill().getAppSignature;
if (sig.isNotEmpty) {
_appSignature = sig;
// Helpful for integrating with SMS provider templates
debugPrint('[OTP] App signature hash: $_appSignature');
}
} catch (_) {}
} catch (_) {}
// Consent fallback temporarily disabled; use SMS Retriever with app hash
}
Future<void> _loadOtpProviderPrefs() async {
try {
final prefs = await SharedPreferences.getInstance();
_smsDefaultProvider = prefs.getInt('smsDefaultProvider') ?? 0;
_smsPassKey = prefs.getInt('smsPassKey');
if (_smsDefaultProvider == 1 && _smsPassKey != null) {
final passKeyStr = _smsPassKey!.toString().padLeft(6, '0');
// Prefill only if fields are empty
if (mounted && !_otpControllers.any((c) => c.text.isNotEmpty)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
for (int i = 0; i < 6 && i < passKeyStr.length; i++) {
_otpControllers[i].text = passKeyStr[i];
}
setState(() {});
});
}
}
} catch (_) {}
}
@override
void codeUpdated() {
final received = code ?? '';
if (received.isNotEmpty) {
final digits = received.replaceAll(RegExp(r'\D'), '');
if (digits.length >= 6) {
final otp = digits.substring(0, 6);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
for (int i = 0; i < 6; i++) {
_otpControllers[i].text = otp[i];
}
setState(() {});
// hide keyboard on autofill and verify with delay before navigation
FocusScope.of(context).unfocus();
_autoVerify(delayBeforeNav: true);
});
}
}
}
Future<void> _autoVerify({bool delayBeforeNav = false}) async {
if (!mounted) return;
if (!_otpControllers.every((c) => c.text.isNotEmpty)) return;
if (isVerifying) return;
setState(() => isVerifying = true);
final entered = getOtp();
bool ok = false;
// Accept provider passkey as valid OTP when enabled
if (_smsDefaultProvider == 1 &&
_smsPassKey != null &&
entered == _smsPassKey!.toString().padLeft(6, '0')) {
ok = true;
} else {
ok = await _auth.verifyOtp(entered);
}
setState(() => isVerifying = false);
if (ok) {
if (delayBeforeNav) {
await Future.delayed(const Duration(seconds: 3));
}
Get.to(() => CreateMpin());
}
}
@override
void dispose() {
for (final controller in _otpControllers) {
controller.dispose();
}
for (final node in _focusNodes) {
node.dispose();
}
_timer?.cancel();
try {
cancel();
} catch (_) {}
super.dispose();
}
void _onOtpChange(String value, int index) {
if (value.isNotEmpty && index < 5) {
_focusNodes[index + 1].requestFocus();
} else if (value.isNotEmpty && index == 5) {
FocusScope.of(context).unfocus();
}
if (value.isEmpty && index > 0) {
// backspace: jump focus back
_focusNodes[index - 1].requestFocus();
_otpControllers[index - 1].selection = TextSelection(
baseOffset: 0,
extentOffset: _otpControllers[index - 1].text.length,
);
}
setState(() {});
}
String getOtp() => _otpControllers.map((e) => e.text).join();
bool get isOtpFilled => _otpControllers.every((c) => c.text.isNotEmpty);
@override
Widget build(BuildContext context) {
final scale = widget.scale;
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Stack(
children: [
Padding(
padding: EdgeInsets.symmetric(
horizontal: 20 * scale,
vertical: 20 * scale,
),
child: SingleChildScrollView(
child: Column(
children: [
SizedBox(height: 60 * scale),
Image.asset(
'assets/images/verify.png',
fit: BoxFit.contain,
height: 200 * scale,
),
SizedBox(height: 24 * scale),
Text(
"Verify OTP",
style: TextStyle(
fontSize: 28 * scale,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.primaryColor,
),
),
SizedBox(height: 24 * scale),
Text(
"Enter the 6-digit code sent to your number",
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black54,
fontSize: 19 * scale,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 40 * scale),
// OTP Fields (6 boxes)
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(6, (index) {
final isFilled = _otpControllers[index].text.isNotEmpty;
return SizedBox(
width: 45 * scale,
height: 50 * scale,
child: TextField(
controller: _otpControllers[index],
focusNode: _focusNodes[index],
autofocus: index == 0,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 1,
style: TextStyle(
fontSize: 17 * scale,
fontWeight: FontWeight.bold,
color: isFilled ? Colors.white : Colors.black,
),
decoration: InputDecoration(
counterText: '',
filled: true,
fillColor: isFilled
? ColorConstants.primaryColor
: Colors.transparent,
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(
color: Colors.grey,
),
borderRadius: BorderRadius.circular(8 * scale),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: ColorConstants.primaryColor,
width: 2,
),
borderRadius: BorderRadius.circular(8 * scale),
),
),
onChanged: (value) => _onOtpChange(value, index),
),
);
}),
),
SizedBox(height: 16 * scale),
// Resend OTP
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(
onPressed: _secondsRemaining == 0
? () async {
await _resendOtp();
}
: null,
child: Transform.translate(
offset: Offset(-10, 0),
child: Text(
_secondsRemaining == 0
? "Resend OTP"
: "Resend in 00:${_secondsRemaining.toString().padLeft(2, '0')}",
style: TextStyle(
fontSize: 18 * scale,
color: _secondsRemaining == 0
? ColorConstants.primaryColor
: Colors.black,
fontFamily: FontConstants.fontFamily,
),
),
),
),
],
),
],
),
),
),
// Back button
Positioned(
top: 40 * scale,
left: 16 * scale,
child: InkWell(
onTap: () => Get.to(() => SignIn()),
borderRadius: BorderRadius.circular(30 * scale),
child: Container(
padding: EdgeInsets.all(8 * scale),
decoration: BoxDecoration(
color: Colors.black12,
shape: BoxShape.circle,
),
child: Icon(
Icons.arrow_back,
color: Colors.black,
size: 28 * scale,
),
),
),
),
],
),
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: EdgeInsets.all(16 * scale),
child: SizedBox(
height: 55 * scale,
width: double.infinity,
child: ElevatedButton(
onPressed: isOtpFilled && !isVerifying
? () async {
// Reuse common verification flow (same as auto-verify)
await _autoVerify();
}
: null,
style: ElevatedButton.styleFrom(
backgroundColor: isOtpFilled
? ColorConstants.primaryColor
: Colors.grey.shade400,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10 * scale),
),
),
child: isVerifying
? SizedBox(
height: 30 * scale,
width: 30 * scale,
child: const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
)
: Text(
"Verify",
style: TextStyle(
fontSize: 21 * scale,
fontWeight: FontWeight.bold,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,208 @@
import 'package:flutter/material.dart';
import 'package:slider_button_lite/feature/presentation/slider_button/slider.dart';
import 'package:slider_button_lite/feature/presentation/slider_button/slider_button_prop.dart';
import 'package:get/get.dart';
import 'package:nearle/controllers/riderlog.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:nearle/widget/Bottom_page.dart';
class SigninBanner extends StatefulWidget {
const SigninBanner({super.key});
@override
State<SigninBanner> createState() => _SigninBannerState();
}
class _SigninBannerState extends State<SigninBanner> {
String _shiftText = 'Your shift: -';
@override
void initState() {
super.initState();
// ✅ CRITICAL: Load shift info in background, don't block UI
_loadShiftInfo();
}
Future<void> _loadShiftInfo() async {
try {
final prefs = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
final s = (prefs.getString('starttime') ?? '').trim();
final e = (prefs.getString('endtime') ?? '').trim();
_shiftText = (s.isEmpty || e.isEmpty)
? 'Your shift: -'
: 'Your shift: $s $e';
});
}
} catch (e) {
// Keep default text on error
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final width = size.width;
final height = size.height;
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
scrolledUnderElevation: 0,
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
),
backgroundColor: Colors.white,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: width * 0.01),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: height * 0.02),
Text(
"Welcome Back!",
style: TextStyle(
fontSize: width * 0.07,
fontWeight: FontWeight.bold,
color: const Color(0xFF6A1B9A),
),
textAlign: TextAlign.center,
),
SizedBox(height: height * 0.01),
Text(
"Start your ride and make today amazing!",
style: TextStyle(
fontSize: width * 0.04,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
SizedBox(height: height * 0.04),
CircleAvatar(
radius: width * 0.14,
backgroundColor: const Color(0xFFEDE7F6),
child: Icon(
Icons.person,
color: const Color(0xFF6A1B9A),
size: width * 0.12,
),
),
SizedBox(height: height * 0.015),
// ✅ CRITICAL: Show shift text immediately (no FutureBuilder blocking)
Text(
_shiftText,
style: TextStyle(
color: Colors.grey[700],
fontSize: width * 0.04,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: height * 0.04),
Image.asset('assets/images/signin_banner.png'),
SizedBox(height: height * 0.05),
LayoutBuilder(
builder: (context, constraints) {
final sliderWidth = constraints.maxWidth;
return Padding(
padding: const EdgeInsets.all(8.0),
child: SliderButton(
properties: SliderButtonProperties(
height: height * 0.07,
width: sliderWidth,
buttonSize: height * 0.065,
disable: false,
isLoading: false,
backgroundColor: const Color(0xFF6A1B9A),
disableButtonColor: const Color(0xFFCCCCDD),
dismissThresholds: 0.9,
action: () async {
try {
final rlc = Get.find<RiderLogController>();
// Ensure any previous break is ended when coming online
debugPrint(
'[SIGNIN_BANNER] Ending break before going online',
);
final breakEnded = await rlc
.endBreakAuto()
.timeout(
const Duration(seconds: 12),
onTimeout: () => false,
);
debugPrint(
'[SIGNIN_BANNER] endBreakAuto -> $breakEnded',
);
// Set rider ON duty (onduty = 1)
final ok = await rlc.setOnDuty(true);
if (ok && context.mounted) {
Get.offAll(() => const BottomPage());
}
// Show snackbar if still on this screen (unlikely after navigation)
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
ok
? "You're now on duty"
: "Failed to update status",
),
backgroundColor: ok
? Colors.deepPurple
: Colors.red,
),
);
}
} catch (e) {
debugPrint(
'[SIGNIN_BANNER] Error updating status: $e',
);
}
return false;
},
label: Text(
'Slide to Start',
style: TextStyle(
fontSize: width * 0.05,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
alignLabel: Alignment.center,
icon: ClipOval(
child: Material(
color: Colors.white,
child: SizedBox(
width: height * 0.065,
height: height * 0.065,
child: const Icon(
Icons.arrow_forward_ios_outlined,
color: Color(0xFF6A1B9A),
),
),
),
),
),
),
);
},
),
SizedBox(height: height * 0.04),
// Add extra bottom padding for devices with navigation bars
SizedBox(height: MediaQuery.of(context).padding.bottom),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'dart:async';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:nearle/views/introscreens/introscreen.dart';
class Splashscreen extends StatefulWidget {
const Splashscreen({super.key});
@override
State<Splashscreen> createState() => _SplashscreenState();
}
class _SplashscreenState extends State<Splashscreen> {
@override
void initState() {
super.initState();
Timer(const Duration(seconds: 3), () {
Get.to(() => Introscreen());
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: ColorConstants.secondaryColor,
elevation: 0,
),
backgroundColor: ColorConstants.secondaryColor,
body: SafeArea(
child: Column(
children: [
SizedBox(height: 230,),
Center(child: Image.asset("assets/images/splashimg.png",fit: BoxFit.contain,height:180,width: 180,)),
],
),
),
);
}
}

View File

@@ -0,0 +1,264 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:nearle/views/helpers/constants/Font_constant.dart';
import 'dart:io';
import 'package:url_launcher/url_launcher.dart';
import 'package:nearle/views/helpers/constants/Colorconstants.dart';
import 'package:new_version_plus/new_version_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:nearle/views/introscreens/introscreen.dart';
import 'package:nearle/widget/Bottom_page.dart';
import 'package:nearle/views/onboardscreens/signin_banner.dart';
class UpdateScreen extends StatefulWidget {
final bool mIsForceUpdate;
final String mCurrentVersion;
final String mUpdateVersion;
const UpdateScreen({
super.key,
this.mIsForceUpdate = true,
required this.mCurrentVersion,
required this.mUpdateVersion,
});
@override
State<UpdateScreen> createState() => _UpdateScreenState();
}
class _UpdateScreenState extends State<UpdateScreen>
with WidgetsBindingObserver {
bool _isCheckingVersion = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && !_isCheckingVersion) {
checkVersionAndNavigate();
}
}
Future<void> checkVersionAndNavigate() async {
if (_isCheckingVersion) return;
_isCheckingVersion = true;
try {
await Future.delayed(const Duration(seconds: 1));
final newVersion = NewVersionPlus(
iOSId: '284882215',
androidId: "com.nearle.partner",
);
final status = await newVersion.getVersionStatus();
if (status != null) {
if (!status.canUpdate) {
if (mounted) {
_navigateToNextScreen();
}
}
}
} catch (e) {
print("Error checking version: $e");
} finally {
_isCheckingVersion = false;
}
}
Future<void> _navigateToNextScreen() async {
final prefs = await SharedPreferences.getInstance();
final isLoggedOut = prefs.getBool('logged_out') == true;
final savedUserId = prefs.getInt('userid');
if (!mounted) return;
if (!isLoggedOut && savedUserId != null && savedUserId > 0) {
final onduty = prefs.getInt('onduty') ?? 0;
if (onduty == 0) {
Get.offAll(() => const SigninBanner());
} else {
Get.offAll(() => const BottomPage());
}
} else {
Get.offAll(() => Introscreen());
}
}
@override
Widget build(BuildContext context) {
double h = Get.height;
double w = Get.width;
return WillPopScope(
onWillPop: () async {
if (widget.mIsForceUpdate) {
SystemNavigator.pop();
return false;
}
return false;
},
child: Scaffold(
backgroundColor: Colors.white,
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
surfaceTintColor: Colors.transparent,
systemOverlayStyle: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
),
),
body: SafeArea(
top: true,
bottom: false, // Bottom safe area handled in bottomNavigationBar
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: IntrinsicHeight(
child: Column(
children: [
/// top spacing (responsive)
SizedBox(height: h * 0.06),
/// 🚀 Image (auto scales)
Padding(
padding: EdgeInsets.symmetric(horizontal: w * 0.08),
child: Image.asset(
"assets/images/update.png",
height: h * 0.28,
fit: BoxFit.contain,
),
),
SizedBox(height: h * 0.03),
/// 🔥 Title
Text(
"A New Update is Available!",
style: TextStyle(
fontSize: h * 0.025,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
SizedBox(height: h * 0.015),
/// 📄 Subtitle
Padding(
padding: EdgeInsets.symmetric(horizontal: w * 0.08),
child: Text(
"New features are here to make your app experience even smoother and more user-friendly!",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: h * 0.018,
color: Colors.grey,
height: 1.4,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(height: h * 0.03),
/// Version Text
Text(
"Available version: ${widget.mUpdateVersion}",
style: TextStyle(
fontSize: h * 0.017,
color: Colors.grey,
fontFamily: FontConstants.fontFamily,
),
),
Spacer(), // pushes content for large screens
],
),
),
),
);
},
),
),
/// ⭐ Bottom Button (fixed, responsive) with bottom safe area
bottomNavigationBar: SafeArea(
top: false,
bottom: true,
child: Padding(
padding: EdgeInsets.fromLTRB(
w * 0.06,
0,
w * 0.06,
h * 0.03,
),
child: SizedBox(
width: double.infinity,
height: h * 0.065,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: ColorConstants.primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
onPressed: () => downloadActions(),
child: Text(
"Update Now",
style: TextStyle(
fontSize: h * 0.022,
fontFamily: FontConstants.fontFamily,
color: Colors.white,
),
),
),
),
),
),
),
);
}
void downloadActions() async {
String url;
var s = Platform.isAndroid ? "Android" : "Ios";
if (s == "Android") {
url = 'https://play.google.com/store/apps/details?id=com.nearle.partner';
} else {
url = 'https://apps.apple.com/us/app/nearle/id1596895375ls=1';
}
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
throw 'Could not launch App';
}
}
}