Initial commit of Doormile CRM Mobile with UI modernizations
This commit is contained in:
291
lib/app.dart
Normal file
291
lib/app.dart
Normal file
@@ -0,0 +1,291 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'models/client.dart';
|
||||
import 'models/activity.dart';
|
||||
import 'models/app_stats.dart';
|
||||
import 'screens/dashboard_screen.dart';
|
||||
import 'screens/clients_screen.dart';
|
||||
import 'screens/survey_screen.dart';
|
||||
import 'screens/surveys_list_screen.dart';
|
||||
import 'screens/pricing_screen.dart';
|
||||
import 'services/crm_api_service.dart';
|
||||
import 'services/auth_service.dart';
|
||||
|
||||
class AppShell extends StatefulWidget {
|
||||
final AuthUser currentUser;
|
||||
final VoidCallback onLogout;
|
||||
|
||||
const AppShell({
|
||||
super.key,
|
||||
required this.currentUser,
|
||||
required this.onLogout,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends State<AppShell> {
|
||||
int _currentIndex = 0;
|
||||
bool _backendReady = false;
|
||||
|
||||
List<Client> _clients = [];
|
||||
List<Activity> _activities = [];
|
||||
List<dynamic> _surveys = [];
|
||||
List<dynamic> _pricing = [];
|
||||
AppStats _stats = const AppStats(
|
||||
totalClients: 0,
|
||||
avgVolume: 0,
|
||||
revenueOpportunity: 0,
|
||||
todayEntries: 0,
|
||||
pendingFollowups: 0,
|
||||
);
|
||||
|
||||
final _api = CrmApiService();
|
||||
|
||||
Timer? _pollingTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_api.setToken(widget.currentUser.token);
|
||||
_loadFromBackend();
|
||||
_startPolling();
|
||||
}
|
||||
|
||||
void _startPolling() {
|
||||
_pollingTimer = Timer.periodic(const Duration(seconds: 10), (_) {
|
||||
_loadFromBackend(silent: true);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollingTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadFromBackend({bool silent = false}) async {
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
_api.loadClients(),
|
||||
_api.fetchSurveys(),
|
||||
_api.fetchPricing(),
|
||||
]);
|
||||
final clients = results[0] as List<Client>;
|
||||
final surveys = results[1];
|
||||
final pricing = results[2];
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_clients = clients;
|
||||
_surveys = surveys;
|
||||
_pricing = pricing;
|
||||
_stats = _computeStats(clients);
|
||||
_backendReady = true;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted && !silent) setState(() => _backendReady = false);
|
||||
}
|
||||
}
|
||||
|
||||
AppStats _computeStats(List<Client> clients) {
|
||||
if (clients.isEmpty) {
|
||||
return const AppStats(
|
||||
totalClients: 0,
|
||||
avgVolume: 0,
|
||||
revenueOpportunity: 0,
|
||||
todayEntries: 0,
|
||||
pendingFollowups: 0,
|
||||
);
|
||||
}
|
||||
final totalVolume = clients.fold(0.0, (acc, c) => acc + c.parcelVolume);
|
||||
final avg = (totalVolume / clients.length).round();
|
||||
return AppStats(
|
||||
totalClients: clients.length,
|
||||
avgVolume: avg,
|
||||
revenueOpportunity: double.parse((totalVolume * 0.01).toStringAsFixed(2)),
|
||||
todayEntries: 0,
|
||||
pendingFollowups: clients.where((c) => c.dataConsent == DataConsent.basicOnly).length,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleAddClient(Client incoming) async {
|
||||
final newClient = incoming.copyWith(
|
||||
recordedByName: widget.currentUser.name,
|
||||
recordedByEmail: widget.currentUser.email,
|
||||
recordedByRole: widget.currentUser.role,
|
||||
);
|
||||
|
||||
// Optimistic UI update with a temp ID
|
||||
setState(() {
|
||||
_clients = [newClient, ..._clients];
|
||||
_activities = [
|
||||
Activity(
|
||||
id: 'act_${DateTime.now().millisecondsSinceEpoch}',
|
||||
clientName: newClient.name,
|
||||
action: 'Survey submitted just now',
|
||||
icon: ActivityIcon.shipping,
|
||||
status: ActivityStatus.newStatus,
|
||||
),
|
||||
..._activities,
|
||||
];
|
||||
_stats = _computeStats(_clients);
|
||||
});
|
||||
|
||||
// Persist to backend and swap temp ID with server-assigned ID
|
||||
final saved = await _api.createClient(newClient);
|
||||
if (saved != null && mounted) {
|
||||
setState(() {
|
||||
_clients = _clients
|
||||
.map((c) => c.id == newClient.id ? saved : c)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleUpdateNotes(String clientId, String newNotes) async {
|
||||
setState(() {
|
||||
_clients = _clients.map((c) {
|
||||
if (c.id == clientId) {
|
||||
return c.copyWith(notes: newNotes, lastUpdated: 'Updated just now');
|
||||
}
|
||||
return c;
|
||||
}).toList();
|
||||
|
||||
final updated = _clients.firstWhere((c) => c.id == clientId);
|
||||
_activities = [
|
||||
Activity(
|
||||
id: 'act_${DateTime.now().millisecondsSinceEpoch}',
|
||||
clientName: updated.name,
|
||||
action: 'Profile updated by Rep',
|
||||
icon: ActivityIcon.edit,
|
||||
),
|
||||
..._activities,
|
||||
];
|
||||
});
|
||||
|
||||
final updated = _clients.firstWhere((c) => c.id == clientId);
|
||||
await _api.updateClient(updated);
|
||||
}
|
||||
|
||||
void _handleCompleteClientProfile(
|
||||
String clientId,
|
||||
double parcelVolume,
|
||||
int activeContracts,
|
||||
String provider,
|
||||
String notes,
|
||||
) async {
|
||||
String efficiency = 'High Efficiency';
|
||||
if (provider == 'Blue Dart Express') efficiency = 'Premium Service';
|
||||
if (provider == 'Gati-KWE') efficiency = 'Cost Leader';
|
||||
if (provider == 'Delhivery') efficiency = 'Local Last Mile';
|
||||
|
||||
setState(() {
|
||||
_clients = _clients.map((c) {
|
||||
if (c.id != clientId) return c;
|
||||
return c.copyWith(
|
||||
parcelVolume: parcelVolume,
|
||||
activeContracts: activeContracts,
|
||||
provider: provider,
|
||||
efficiency: efficiency,
|
||||
notes: notes,
|
||||
dataConsent: DataConsent.full,
|
||||
lastUpdated: 'Just now',
|
||||
surveySubmitted: true,
|
||||
recordedByName: widget.currentUser.name,
|
||||
recordedByEmail: widget.currentUser.email,
|
||||
recordedByRole: widget.currentUser.role,
|
||||
);
|
||||
}).toList();
|
||||
_stats = _computeStats(_clients);
|
||||
});
|
||||
|
||||
final updated = _clients.firstWhere((c) => c.id == clientId);
|
||||
await _api.updateClient(updated);
|
||||
}
|
||||
|
||||
void _navigateTo(int index) {
|
||||
if (index == 4) {
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (ctx) => Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
iconTheme: const IconThemeData(color: Colors.black87),
|
||||
title: const Text('Add Client', style: TextStyle(color: Colors.black87, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
body: SurveyScreen(
|
||||
clients: _clients,
|
||||
onAddClient: _handleAddClient,
|
||||
onNavigate: (i) {
|
||||
Navigator.pop(ctx);
|
||||
_navigateTo(i);
|
||||
},
|
||||
backendReady: _backendReady,
|
||||
isActive: true,
|
||||
),
|
||||
),
|
||||
));
|
||||
return;
|
||||
}
|
||||
setState(() => _currentIndex = index);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screens = [
|
||||
DashboardScreen(
|
||||
stats: _stats,
|
||||
activities: _activities,
|
||||
clients: _clients,
|
||||
surveys: _surveys,
|
||||
pricing: _pricing,
|
||||
onNavigate: _navigateTo,
|
||||
onDataChanged: _loadFromBackend,
|
||||
backendReady: _backendReady,
|
||||
onLogout: widget.onLogout,
|
||||
isActive: _currentIndex == 0,
|
||||
),
|
||||
ClientsScreen(
|
||||
clients: _clients,
|
||||
onUpdateClientNotes: _handleUpdateNotes,
|
||||
onCompleteClientProfile: _handleCompleteClientProfile,
|
||||
onNavigate: _navigateTo,
|
||||
backendReady: _backendReady,
|
||||
isActive: _currentIndex == 1,
|
||||
),
|
||||
SurveysListScreen(
|
||||
surveys: _surveys,
|
||||
onDataChanged: _loadFromBackend,
|
||||
isActive: _currentIndex == 2,
|
||||
),
|
||||
PricingScreen(
|
||||
pricing: _pricing,
|
||||
backendReady: _backendReady,
|
||||
onDataChanged: _loadFromBackend,
|
||||
isActive: _currentIndex == 3,
|
||||
),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: IndexedStack(
|
||||
index: _currentIndex,
|
||||
children: screens,
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: _navigateTo,
|
||||
height: 68,
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.dashboard_outlined), selectedIcon: Icon(Icons.dashboard), label: 'Home'),
|
||||
NavigationDestination(icon: Icon(Icons.people_outline), selectedIcon: Icon(Icons.people), label: 'Clients'),
|
||||
NavigationDestination(icon: Icon(Icons.storefront_outlined), selectedIcon: Icon(Icons.storefront), label: 'Surveys'),
|
||||
NavigationDestination(icon: Icon(Icons.local_shipping_outlined), selectedIcon: Icon(Icons.local_shipping), label: 'Pricing'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
166
lib/data/india_cities.dart
Normal file
166
lib/data/india_cities.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
/// A selectable Indian city shown flight-booking style: "Chennai (MAA)".
|
||||
class IndiaCity {
|
||||
final String name;
|
||||
final String code; // airport-style 3-letter code
|
||||
final String state;
|
||||
|
||||
const IndiaCity(this.name, this.code, this.state);
|
||||
|
||||
String get label => '$name ($code)';
|
||||
|
||||
/// True if [query] matches the city name, code, or state.
|
||||
bool matches(String query) {
|
||||
final q = query.trim().toLowerCase();
|
||||
if (q.isEmpty) return true;
|
||||
return name.toLowerCase().contains(q) ||
|
||||
code.toLowerCase().contains(q) ||
|
||||
state.toLowerCase().contains(q);
|
||||
}
|
||||
}
|
||||
|
||||
/// Major Indian cities with flight-style codes. Tamil Nadu listed first
|
||||
/// (primary operating region), then the rest of India.
|
||||
const List<IndiaCity> indiaCities = [
|
||||
// ── Tamil Nadu ──────────────────────────────────────────────────────────
|
||||
IndiaCity('Chennai', 'MAA', 'Tamil Nadu'),
|
||||
IndiaCity('Coimbatore', 'CJB', 'Tamil Nadu'),
|
||||
IndiaCity('Madurai', 'IXM', 'Tamil Nadu'),
|
||||
IndiaCity('Tiruchirappalli', 'TRZ', 'Tamil Nadu'),
|
||||
IndiaCity('Salem', 'SXV', 'Tamil Nadu'),
|
||||
IndiaCity('Tuticorin', 'TCR', 'Tamil Nadu'),
|
||||
IndiaCity('Tirupur', 'TUP', 'Tamil Nadu'),
|
||||
IndiaCity('Erode', 'ERD', 'Tamil Nadu'),
|
||||
IndiaCity('Vellore', 'VLR', 'Tamil Nadu'),
|
||||
IndiaCity('Tirunelveli', 'TEN', 'Tamil Nadu'),
|
||||
IndiaCity('Thanjavur', 'TNJ', 'Tamil Nadu'),
|
||||
IndiaCity('Dindigul', 'DGL', 'Tamil Nadu'),
|
||||
IndiaCity('Hosur', 'HSR', 'Tamil Nadu'),
|
||||
IndiaCity('Nagercoil', 'NGL', 'Tamil Nadu'),
|
||||
IndiaCity('Karur', 'KRR', 'Tamil Nadu'),
|
||||
IndiaCity('Namakkal', 'NMK', 'Tamil Nadu'),
|
||||
IndiaCity('Kanchipuram', 'KCP', 'Tamil Nadu'),
|
||||
IndiaCity('Cuddalore', 'CDL', 'Tamil Nadu'),
|
||||
IndiaCity('Thoothukudi', 'TOO', 'Tamil Nadu'),
|
||||
|
||||
// ── Karnataka ───────────────────────────────────────────────────────────
|
||||
IndiaCity('Bengaluru', 'BLR', 'Karnataka'),
|
||||
IndiaCity('Mysuru', 'MYQ', 'Karnataka'),
|
||||
IndiaCity('Mangaluru', 'IXE', 'Karnataka'),
|
||||
IndiaCity('Hubli', 'HBX', 'Karnataka'),
|
||||
IndiaCity('Belagavi', 'IXG', 'Karnataka'),
|
||||
IndiaCity('Kalaburagi', 'GBI', 'Karnataka'),
|
||||
IndiaCity('Davangere', 'DVG', 'Karnataka'),
|
||||
IndiaCity('Ballari', 'BEP', 'Karnataka'),
|
||||
|
||||
// ── Kerala ──────────────────────────────────────────────────────────────
|
||||
IndiaCity('Kochi', 'COK', 'Kerala'),
|
||||
IndiaCity('Thiruvananthapuram', 'TRV', 'Kerala'),
|
||||
IndiaCity('Kozhikode', 'CCJ', 'Kerala'),
|
||||
IndiaCity('Kannur', 'CNN', 'Kerala'),
|
||||
IndiaCity('Thrissur', 'TCR2', 'Kerala'),
|
||||
IndiaCity('Kollam', 'QLN', 'Kerala'),
|
||||
|
||||
// ── Telangana & Andhra Pradesh ──────────────────────────────────────────
|
||||
IndiaCity('Hyderabad', 'HYD', 'Telangana'),
|
||||
IndiaCity('Warangal', 'WGC', 'Telangana'),
|
||||
IndiaCity('Visakhapatnam', 'VTZ', 'Andhra Pradesh'),
|
||||
IndiaCity('Vijayawada', 'VGA', 'Andhra Pradesh'),
|
||||
IndiaCity('Tirupati', 'TIR', 'Andhra Pradesh'),
|
||||
IndiaCity('Guntur', 'GNT', 'Andhra Pradesh'),
|
||||
IndiaCity('Rajahmundry', 'RJA', 'Andhra Pradesh'),
|
||||
IndiaCity('Nellore', 'NLR', 'Andhra Pradesh'),
|
||||
|
||||
// ── Maharashtra ─────────────────────────────────────────────────────────
|
||||
IndiaCity('Mumbai', 'BOM', 'Maharashtra'),
|
||||
IndiaCity('Pune', 'PNQ', 'Maharashtra'),
|
||||
IndiaCity('Nagpur', 'NAG', 'Maharashtra'),
|
||||
IndiaCity('Nashik', 'ISK', 'Maharashtra'),
|
||||
IndiaCity('Aurangabad', 'IXU', 'Maharashtra'),
|
||||
IndiaCity('Kolhapur', 'KLH', 'Maharashtra'),
|
||||
IndiaCity('Solapur', 'SSE', 'Maharashtra'),
|
||||
|
||||
// ── Delhi NCR & North ───────────────────────────────────────────────────
|
||||
IndiaCity('New Delhi', 'DEL', 'Delhi'),
|
||||
IndiaCity('Gurugram', 'GGN', 'Haryana'),
|
||||
IndiaCity('Noida', 'NOI', 'Uttar Pradesh'),
|
||||
IndiaCity('Faridabad', 'FBD', 'Haryana'),
|
||||
IndiaCity('Chandigarh', 'IXC', 'Chandigarh'),
|
||||
IndiaCity('Amritsar', 'ATQ', 'Punjab'),
|
||||
IndiaCity('Ludhiana', 'LUH', 'Punjab'),
|
||||
IndiaCity('Jalandhar', 'JLR2', 'Punjab'),
|
||||
|
||||
// ── Gujarat ─────────────────────────────────────────────────────────────
|
||||
IndiaCity('Ahmedabad', 'AMD', 'Gujarat'),
|
||||
IndiaCity('Surat', 'STV', 'Gujarat'),
|
||||
IndiaCity('Vadodara', 'BDQ', 'Gujarat'),
|
||||
IndiaCity('Rajkot', 'RAJ', 'Gujarat'),
|
||||
IndiaCity('Bhavnagar', 'BHU', 'Gujarat'),
|
||||
IndiaCity('Jamnagar', 'JGA', 'Gujarat'),
|
||||
|
||||
// ── Rajasthan ───────────────────────────────────────────────────────────
|
||||
IndiaCity('Jaipur', 'JAI', 'Rajasthan'),
|
||||
IndiaCity('Jodhpur', 'JDH', 'Rajasthan'),
|
||||
IndiaCity('Udaipur', 'UDR', 'Rajasthan'),
|
||||
IndiaCity('Kota', 'KTU', 'Rajasthan'),
|
||||
IndiaCity('Bikaner', 'BKB', 'Rajasthan'),
|
||||
|
||||
// ── Uttar Pradesh & Central ─────────────────────────────────────────────
|
||||
IndiaCity('Lucknow', 'LKO', 'Uttar Pradesh'),
|
||||
IndiaCity('Kanpur', 'KNU', 'Uttar Pradesh'),
|
||||
IndiaCity('Varanasi', 'VNS', 'Uttar Pradesh'),
|
||||
IndiaCity('Agra', 'AGR', 'Uttar Pradesh'),
|
||||
IndiaCity('Prayagraj', 'IXD', 'Uttar Pradesh'),
|
||||
IndiaCity('Gorakhpur', 'GOP', 'Uttar Pradesh'),
|
||||
IndiaCity('Bhopal', 'BHO', 'Madhya Pradesh'),
|
||||
IndiaCity('Indore', 'IDR', 'Madhya Pradesh'),
|
||||
IndiaCity('Gwalior', 'GWL', 'Madhya Pradesh'),
|
||||
IndiaCity('Jabalpur', 'JLR', 'Madhya Pradesh'),
|
||||
IndiaCity('Raipur', 'RPR', 'Chhattisgarh'),
|
||||
|
||||
// ── East & North-East ───────────────────────────────────────────────────
|
||||
IndiaCity('Kolkata', 'CCU', 'West Bengal'),
|
||||
IndiaCity('Siliguri', 'IXB', 'West Bengal'),
|
||||
IndiaCity('Durgapur', 'RDP', 'West Bengal'),
|
||||
IndiaCity('Patna', 'PAT', 'Bihar'),
|
||||
IndiaCity('Gaya', 'GAY', 'Bihar'),
|
||||
IndiaCity('Ranchi', 'IXR', 'Jharkhand'),
|
||||
IndiaCity('Jamshedpur', 'JSD', 'Jharkhand'),
|
||||
IndiaCity('Bhubaneswar', 'BBI', 'Odisha'),
|
||||
IndiaCity('Cuttack', 'CTC', 'Odisha'),
|
||||
IndiaCity('Guwahati', 'GAU', 'Assam'),
|
||||
IndiaCity('Dibrugarh', 'DIB', 'Assam'),
|
||||
IndiaCity('Agartala', 'IXA', 'Tripura'),
|
||||
IndiaCity('Imphal', 'IMF', 'Manipur'),
|
||||
IndiaCity('Shillong', 'SHL', 'Meghalaya'),
|
||||
IndiaCity('Aizawl', 'AJL', 'Mizoram'),
|
||||
IndiaCity('Dimapur', 'DMU', 'Nagaland'),
|
||||
|
||||
// ── Hills, UTs & others ─────────────────────────────────────────────────
|
||||
IndiaCity('Dehradun', 'DED', 'Uttarakhand'),
|
||||
IndiaCity('Haridwar', 'HDW', 'Uttarakhand'),
|
||||
IndiaCity('Shimla', 'SLV', 'Himachal Pradesh'),
|
||||
IndiaCity('Srinagar', 'SXR', 'Jammu & Kashmir'),
|
||||
IndiaCity('Jammu', 'IXJ', 'Jammu & Kashmir'),
|
||||
IndiaCity('Leh', 'IXL', 'Ladakh'),
|
||||
IndiaCity('Panaji', 'GOI', 'Goa'),
|
||||
IndiaCity('Puducherry', 'PNY', 'Puducherry'),
|
||||
IndiaCity('Port Blair', 'IXZ', 'Andaman & Nicobar Islands'),
|
||||
];
|
||||
|
||||
/// Finds a city by (loose) name match — used to map a GPS reverse-geocode
|
||||
/// result to a known city. Returns null if nothing reasonable matches.
|
||||
IndiaCity? findCityByName(String? name) {
|
||||
if (name == null || name.trim().isEmpty) return null;
|
||||
final n = name.trim().toLowerCase();
|
||||
for (final c in indiaCities) {
|
||||
if (c.name.toLowerCase() == n) return c;
|
||||
}
|
||||
// Loose contains match (e.g. "Coimbatore District" → "Coimbatore")
|
||||
for (final c in indiaCities) {
|
||||
if (n.contains(c.name.toLowerCase()) ||
|
||||
c.name.toLowerCase().contains(n)) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
221
lib/data/india_locations.dart
Normal file
221
lib/data/india_locations.dart
Normal file
@@ -0,0 +1,221 @@
|
||||
/// All Indian states + Union Territories, alphabetically sorted.
|
||||
const List<String> indianStates = [
|
||||
'Andaman & Nicobar Islands',
|
||||
'Andhra Pradesh',
|
||||
'Arunachal Pradesh',
|
||||
'Assam',
|
||||
'Bihar',
|
||||
'Chandigarh',
|
||||
'Chhattisgarh',
|
||||
'Dadra & Nagar Haveli',
|
||||
'Daman & Diu',
|
||||
'Delhi',
|
||||
'Goa',
|
||||
'Gujarat',
|
||||
'Haryana',
|
||||
'Himachal Pradesh',
|
||||
'Jammu & Kashmir',
|
||||
'Jharkhand',
|
||||
'Karnataka',
|
||||
'Kerala',
|
||||
'Ladakh',
|
||||
'Lakshadweep',
|
||||
'Madhya Pradesh',
|
||||
'Maharashtra',
|
||||
'Manipur',
|
||||
'Meghalaya',
|
||||
'Mizoram',
|
||||
'Nagaland',
|
||||
'Odisha',
|
||||
'Puducherry',
|
||||
'Punjab',
|
||||
'Rajasthan',
|
||||
'Sikkim',
|
||||
'Tamil Nadu',
|
||||
'Telangana',
|
||||
'Tripura',
|
||||
'Uttar Pradesh',
|
||||
'Uttarakhand',
|
||||
'West Bengal',
|
||||
];
|
||||
|
||||
/// Major cities per state. Falls back to an empty list for states not listed.
|
||||
const Map<String, List<String>> stateCities = {
|
||||
'Tamil Nadu': [
|
||||
'Chennai', 'Coimbatore', 'Madurai', 'Salem', 'Tirupur', 'Erode',
|
||||
'Tiruchirappalli', 'Tirunelveli', 'Vellore', 'Dindigul', 'Thanjavur',
|
||||
'Kanchipuram', 'Namakkal', 'Karur', 'Dharmapuri', 'Krishnagiri',
|
||||
'Cuddalore', 'Villupuram', 'Nagapattinam', 'Thoothukudi', 'Virudhunagar',
|
||||
'Sivaganga', 'Ramanathapuram', 'Theni', 'Perambalur', 'Puducherry',
|
||||
],
|
||||
'Karnataka': [
|
||||
'Bengaluru', 'Mysuru', 'Mangaluru', 'Hubli', 'Belagavi',
|
||||
'Davangere', 'Shivamogga', 'Tumakuru', 'Kalaburagi', 'Udupi',
|
||||
'Ballari', 'Bidar', 'Vijayapura', 'Raichur', 'Dharwad', 'Hassan',
|
||||
],
|
||||
'Kerala': [
|
||||
'Kochi', 'Thiruvananthapuram', 'Kozhikode', 'Thrissur', 'Kannur',
|
||||
'Kollam', 'Palakkad', 'Malappuram', 'Alappuzha', 'Kottayam',
|
||||
'Idukki', 'Wayanad', 'Kasaragod', 'Pathanamthitta',
|
||||
],
|
||||
'Andhra Pradesh': [
|
||||
'Vijayawada', 'Visakhapatnam', 'Tirupati', 'Guntur', 'Nellore',
|
||||
'Kakinada', 'Rajahmundry', 'Kadapa', 'Kurnool', 'Ongole',
|
||||
'Anantapur', 'Vizianagaram', 'Eluru',
|
||||
],
|
||||
'Telangana': [
|
||||
'Hyderabad', 'Warangal', 'Nizamabad', 'Karimnagar', 'Khammam',
|
||||
'Mahbubnagar', 'Nalgonda', 'Adilabad', 'Secunderabad', 'Siddipet',
|
||||
],
|
||||
'Maharashtra': [
|
||||
'Mumbai', 'Pune', 'Nagpur', 'Nashik', 'Aurangabad', 'Thane',
|
||||
'Solapur', 'Amravati', 'Kolhapur', 'Nanded', 'Sangli', 'Jalgaon',
|
||||
'Akola', 'Latur', 'Chandrapur',
|
||||
],
|
||||
'Delhi': [
|
||||
'New Delhi', 'North Delhi', 'South Delhi', 'East Delhi', 'West Delhi',
|
||||
'Noida', 'Gurgaon', 'Faridabad', 'Ghaziabad', 'Dwarka',
|
||||
],
|
||||
'Gujarat': [
|
||||
'Ahmedabad', 'Surat', 'Vadodara', 'Rajkot', 'Gandhinagar',
|
||||
'Bhavnagar', 'Jamnagar', 'Junagadh', 'Anand', 'Bharuch',
|
||||
'Morbi', 'Nadiad', 'Surendranagar',
|
||||
],
|
||||
'Rajasthan': [
|
||||
'Jaipur', 'Jodhpur', 'Udaipur', 'Kota', 'Ajmer',
|
||||
'Bikaner', 'Alwar', 'Bharatpur', 'Sikar', 'Sri Ganganagar',
|
||||
'Pali', 'Tonk', 'Barmer',
|
||||
],
|
||||
'West Bengal': [
|
||||
'Kolkata', 'Howrah', 'Durgapur', 'Asansol', 'Siliguri',
|
||||
'Bardhaman', 'Malda', 'Kharagpur', 'Haldia', 'Jalpaiguri',
|
||||
],
|
||||
'Uttar Pradesh': [
|
||||
'Lucknow', 'Kanpur', 'Varanasi', 'Agra', 'Meerut',
|
||||
'Prayagraj', 'Bareilly', 'Moradabad', 'Gorakhpur', 'Aligarh',
|
||||
'Noida', 'Ghaziabad', 'Firozabad', 'Saharanpur', 'Mathura',
|
||||
],
|
||||
'Madhya Pradesh': [
|
||||
'Bhopal', 'Indore', 'Gwalior', 'Jabalpur', 'Ujjain',
|
||||
'Sagar', 'Dewas', 'Satna', 'Ratlam', 'Rewa', 'Singrauli',
|
||||
],
|
||||
'Punjab': [
|
||||
'Ludhiana', 'Amritsar', 'Jalandhar', 'Patiala', 'Bathinda',
|
||||
'Mohali', 'Hoshiarpur', 'Gurdaspur', 'Pathankot', 'Firozpur',
|
||||
],
|
||||
'Haryana': [
|
||||
'Gurugram', 'Faridabad', 'Hisar', 'Panipat', 'Ambala',
|
||||
'Karnal', 'Rohtak', 'Bhiwani', 'Sirsa', 'Sonipat',
|
||||
],
|
||||
'Bihar': [
|
||||
'Patna', 'Gaya', 'Bhagalpur', 'Muzaffarpur', 'Purnia',
|
||||
'Darbhanga', 'Arrah', 'Begusarai', 'Munger', 'Chhapra',
|
||||
],
|
||||
'Odisha': [
|
||||
'Bhubaneswar', 'Cuttack', 'Rourkela', 'Brahmapur', 'Puri',
|
||||
'Sambalpur', 'Balasore', 'Baripada', 'Jharsuguda',
|
||||
],
|
||||
'Assam': [
|
||||
'Guwahati', 'Silchar', 'Dibrugarh', 'Jorhat', 'Nagaon',
|
||||
'Tinsukia', 'Tezpur', 'Bongaigaon', 'Karimganj',
|
||||
],
|
||||
'Jharkhand': [
|
||||
'Ranchi', 'Jamshedpur', 'Dhanbad', 'Bokaro', 'Deoghar',
|
||||
'Hazaribagh', 'Giridih', 'Dumka', 'Phusro',
|
||||
],
|
||||
'Chhattisgarh': [
|
||||
'Raipur', 'Bhilai', 'Bilaspur', 'Korba', 'Rajnandgaon',
|
||||
'Durg', 'Jagdalpur', 'Ambikapur', 'Raigarh',
|
||||
],
|
||||
'Uttarakhand': [
|
||||
'Dehradun', 'Haridwar', 'Rishikesh', 'Roorkee', 'Haldwani',
|
||||
'Nainital', 'Mussoorie', 'Kashipur', 'Rudrapur',
|
||||
],
|
||||
'Himachal Pradesh': [
|
||||
'Shimla', 'Dharamshala', 'Solan', 'Mandi', 'Kullu',
|
||||
'Manali', 'Baddi', 'Bilaspur', 'Kangra',
|
||||
],
|
||||
'Goa': [
|
||||
'Panaji', 'Margao', 'Vasco da Gama', 'Mapusa', 'Ponda', 'Calangute',
|
||||
],
|
||||
'Puducherry': ['Puducherry', 'Karaikal', 'Mahe', 'Yanam'],
|
||||
'Jammu & Kashmir': [
|
||||
'Srinagar', 'Jammu', 'Anantnag', 'Baramulla', 'Sopore', 'Udhampur',
|
||||
],
|
||||
'Chandigarh': ['Chandigarh'],
|
||||
'Ladakh': ['Leh', 'Kargil'],
|
||||
'Andaman & Nicobar Islands': ['Port Blair'],
|
||||
'Manipur': ['Imphal', 'Thoubal', 'Bishnupur', 'Churachandpur'],
|
||||
'Meghalaya': ['Shillong', 'Tura', 'Jowai'],
|
||||
'Mizoram': ['Aizawl', 'Lunglei', 'Champhai'],
|
||||
'Nagaland': ['Kohima', 'Dimapur', 'Mokokchung'],
|
||||
'Arunachal Pradesh': ['Itanagar', 'Naharlagun', 'Pasighat', 'Tawang'],
|
||||
'Sikkim': ['Gangtok', 'Namchi', 'Gyalshing'],
|
||||
'Tripura': ['Agartala', 'Dharmanagar', 'Udaipur', 'Kailasahar'],
|
||||
};
|
||||
|
||||
const Map<String, List<String>> categorisedProviders = {
|
||||
'National & Pan-India Courier Services (Parcels & Express)': [
|
||||
'Blue Dart',
|
||||
'Delhivery',
|
||||
'DTDC',
|
||||
'India Post / Speed Post',
|
||||
'The Professional Couriers',
|
||||
'XpressBees',
|
||||
'Ecom Express',
|
||||
'Shadowfax',
|
||||
],
|
||||
'National Heavyweight Cargo & B2B Surface Freight': [
|
||||
'Safexpress',
|
||||
'VRL Logistics',
|
||||
'TCI (Transport Corporation of India)',
|
||||
'Om Logistics',
|
||||
'Best Roadways',
|
||||
],
|
||||
'Southern India Dual-Purpose & Regional Networks (Tamil Nadu, Telangana, AP, Karnataka)': [
|
||||
'MSS (Mettur Super Services / Mettur Transports)',
|
||||
'ABT Travels & Logistics',
|
||||
'Navata Road Transport',
|
||||
'KRS (Kerala Roadways)',
|
||||
'Parveen Travels / Parveen Express',
|
||||
'SRM Transports',
|
||||
'KPN Travels & KPN Speed Parcel',
|
||||
'SRS Travels',
|
||||
],
|
||||
'Hyper-Local & District Operators in Tamil Nadu': [
|
||||
'Hindusthan Travels',
|
||||
'City Travels',
|
||||
'Essaar Travels',
|
||||
'No. 1 Air Travels',
|
||||
'A1 Travels',
|
||||
'Krish Travels',
|
||||
'Hebron Transports',
|
||||
'Horma Travels',
|
||||
'Vivegam Travels',
|
||||
'PSS Transport',
|
||||
'SRT (Renugambal Travels)',
|
||||
'Thamarai Bus Transports',
|
||||
'Rathimeena Travels',
|
||||
'Ganesh Travels',
|
||||
'John Kennedy Bus Service',
|
||||
'Arun Travel',
|
||||
'Saaji Meera Roadways',
|
||||
'ARC Parcel Service',
|
||||
'Chakra Travels & Parcel Service',
|
||||
'MVA Parcel And Bus Service',
|
||||
],
|
||||
'Northern & Central India Regional Operators (Delhi NCR, Rajasthan, UP, MP, Punjab, Haryana)': [
|
||||
'Shrinath Travels & Cargo',
|
||||
'Hans Travels',
|
||||
'Zingbus',
|
||||
'Kalpana Travels / City Land Travels',
|
||||
'Trackon Couriers',
|
||||
'North India Transways',
|
||||
'RSRTC Cargo',
|
||||
'UPSRTC Cargo',
|
||||
],
|
||||
};
|
||||
|
||||
final List<String> allProvidersFlat =
|
||||
categorisedProviders.values.expand((list) => list).toList();
|
||||
|
||||
46
lib/data/initial_data.dart
Normal file
46
lib/data/initial_data.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import '../models/client.dart';
|
||||
import '../models/activity.dart';
|
||||
import '../models/app_stats.dart';
|
||||
|
||||
const AppStats initialStats = AppStats(
|
||||
totalClients: 1,
|
||||
avgVolume: 200,
|
||||
revenueOpportunity: 0.1,
|
||||
todayEntries: 1,
|
||||
pendingFollowups: 0,
|
||||
);
|
||||
|
||||
const List<Client> initialClients = [
|
||||
Client(
|
||||
id: 'suriya_001',
|
||||
name: 'Suriya',
|
||||
city: 'Coimbatore',
|
||||
businessType: BusinessType.ecommerce,
|
||||
parcelVolume: 200,
|
||||
activeContracts: 5,
|
||||
provider: 'DTDC Express',
|
||||
efficiency: 'High Efficiency',
|
||||
status: ClientStatus.newClient,
|
||||
lastUpdated: 'Just now',
|
||||
surveySubmitted: true,
|
||||
notes: '',
|
||||
dataConsent: DataConsent.full,
|
||||
phone: '',
|
||||
frequency: 'Weekly',
|
||||
businessState: 'Tamil Nadu',
|
||||
transitType: 'within_state',
|
||||
transitFrom: 'Coimbatore',
|
||||
transitTo: 'Chennai',
|
||||
neighbourhood: 'RS Puram',
|
||||
),
|
||||
];
|
||||
|
||||
const List<Activity> initialActivities = [
|
||||
Activity(
|
||||
id: 'a1',
|
||||
clientName: 'Suriya',
|
||||
action: 'Profile created — staging entry',
|
||||
icon: ActivityIcon.shipping,
|
||||
status: ActivityStatus.newStatus,
|
||||
),
|
||||
];
|
||||
130
lib/main.dart
Normal file
130
lib/main.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'app.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/update_screen.dart';
|
||||
import 'services/auth_service.dart';
|
||||
import 'package:in_app_update/in_app_update.dart';
|
||||
import 'dart:io';
|
||||
import 'theme/app_theme.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
),
|
||||
);
|
||||
|
||||
AuthUser? initialUser;
|
||||
try {
|
||||
initialUser = await AuthService().getSavedUser();
|
||||
} catch (_) {}
|
||||
|
||||
runApp(DoorMileApp(initialUser: initialUser));
|
||||
}
|
||||
|
||||
class DoorMileApp extends StatelessWidget {
|
||||
final AuthUser? initialUser;
|
||||
|
||||
const DoorMileApp({super.key, this.initialUser});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Doormile CRM',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildAppTheme(),
|
||||
home: AuthGate(initialUser: initialUser),
|
||||
builder: (context, child) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
return Container(
|
||||
color: AppColors.scaffold,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: MediaQuery(
|
||||
data: mediaQuery.copyWith(
|
||||
textScaler: mediaQuery.textScaler.clamp(
|
||||
minScaleFactor: 0.85,
|
||||
maxScaleFactor: 1.15,
|
||||
),
|
||||
),
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthGate extends StatefulWidget {
|
||||
final AuthUser? initialUser;
|
||||
|
||||
const AuthGate({super.key, this.initialUser});
|
||||
|
||||
@override
|
||||
State<AuthGate> createState() => _AuthGateState();
|
||||
}
|
||||
|
||||
class _AuthGateState extends State<AuthGate> {
|
||||
AuthUser? _user;
|
||||
bool _updateRequired = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_user = widget.initialUser;
|
||||
_checkForUpdates();
|
||||
}
|
||||
|
||||
Future<void> _checkForUpdates() async {
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
final info = await InAppUpdate.checkForUpdate();
|
||||
if (info.updateAvailability == UpdateAvailability.updateAvailable) {
|
||||
setState(() => _updateRequired = true);
|
||||
await InAppUpdate.performImmediateUpdate();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignored: Not a supported Android device or Play Store not accessible
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _login(AuthUser user) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('auth_user_data', jsonEncode(user.toJson()));
|
||||
await prefs.setString('user_name', user.name);
|
||||
} catch (_) {}
|
||||
setState(() => _user = user);
|
||||
}
|
||||
|
||||
Future<void> _logout() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('auth_user_data');
|
||||
await prefs.remove('user_name');
|
||||
} catch (_) {}
|
||||
setState(() => _user = null);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_updateRequired) {
|
||||
return const UpdateRequiredScreen();
|
||||
}
|
||||
if (_user == null) {
|
||||
return LoginScreen(
|
||||
onLoggedIn: _login,
|
||||
);
|
||||
}
|
||||
return AppShell(currentUser: _user!, onLogout: _logout);
|
||||
}
|
||||
}
|
||||
19
lib/models/activity.dart
Normal file
19
lib/models/activity.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
enum ActivityIcon { shipping, call, edit }
|
||||
|
||||
enum ActivityStatus { newStatus, pending, completed }
|
||||
|
||||
class Activity {
|
||||
final String id;
|
||||
final String clientName;
|
||||
final String action;
|
||||
final ActivityIcon icon;
|
||||
final ActivityStatus? status;
|
||||
|
||||
const Activity({
|
||||
required this.id,
|
||||
required this.clientName,
|
||||
required this.action,
|
||||
required this.icon,
|
||||
this.status,
|
||||
});
|
||||
}
|
||||
31
lib/models/app_stats.dart
Normal file
31
lib/models/app_stats.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
class AppStats {
|
||||
final int totalClients;
|
||||
final int avgVolume;
|
||||
final double revenueOpportunity;
|
||||
final int todayEntries;
|
||||
final int pendingFollowups;
|
||||
|
||||
const AppStats({
|
||||
required this.totalClients,
|
||||
required this.avgVolume,
|
||||
required this.revenueOpportunity,
|
||||
required this.todayEntries,
|
||||
required this.pendingFollowups,
|
||||
});
|
||||
|
||||
AppStats copyWith({
|
||||
int? totalClients,
|
||||
int? avgVolume,
|
||||
double? revenueOpportunity,
|
||||
int? todayEntries,
|
||||
int? pendingFollowups,
|
||||
}) {
|
||||
return AppStats(
|
||||
totalClients: totalClients ?? this.totalClients,
|
||||
avgVolume: avgVolume ?? this.avgVolume,
|
||||
revenueOpportunity: revenueOpportunity ?? this.revenueOpportunity,
|
||||
todayEntries: todayEntries ?? this.todayEntries,
|
||||
pendingFollowups: pendingFollowups ?? this.pendingFollowups,
|
||||
);
|
||||
}
|
||||
}
|
||||
173
lib/models/client.dart
Normal file
173
lib/models/client.dart
Normal file
@@ -0,0 +1,173 @@
|
||||
enum DataConsent { basicOnly, full }
|
||||
|
||||
enum BusinessType { ecommerce, manufacturing, wholesale, retail }
|
||||
|
||||
enum ClientStatus { newClient, pending, completed, updated }
|
||||
|
||||
extension BusinessTypeLabel on BusinessType {
|
||||
String get label {
|
||||
switch (this) {
|
||||
case BusinessType.ecommerce:
|
||||
return 'E-commerce';
|
||||
case BusinessType.manufacturing:
|
||||
return 'Manufacturing';
|
||||
case BusinessType.wholesale:
|
||||
return 'Wholesale';
|
||||
case BusinessType.retail:
|
||||
return 'Retail';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ClientStatusLabel on ClientStatus {
|
||||
String get label {
|
||||
switch (this) {
|
||||
case ClientStatus.newClient:
|
||||
return 'NEW';
|
||||
case ClientStatus.pending:
|
||||
return 'PENDING';
|
||||
case ClientStatus.completed:
|
||||
return 'COMPLETED';
|
||||
case ClientStatus.updated:
|
||||
return 'UPDATED';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Client {
|
||||
final String id;
|
||||
final String name;
|
||||
final String city; // free-text, e.g. "New York", "Austin"
|
||||
final BusinessType businessType;
|
||||
final double parcelVolume;
|
||||
final int activeContracts;
|
||||
final String provider;
|
||||
final String efficiency;
|
||||
final ClientStatus status;
|
||||
final String lastUpdated;
|
||||
final bool surveySubmitted;
|
||||
final String notes;
|
||||
final DataConsent dataConsent;
|
||||
|
||||
// Extended basic-info fields
|
||||
final String phone; // contact number
|
||||
final String frequency; // shipment frequency: Daily / Weekly / etc.
|
||||
final String businessState; // Indian state of the business
|
||||
final String transitType; // 'within_state' | 'state_to_state' | ''
|
||||
final String transitFrom; // city (within_state) or state name (state_to_state)
|
||||
final String transitTo; // city (within_state) or state name (state_to_state)
|
||||
final String neighbourhood; // local area / neighbourhood
|
||||
final String logisticsSegment; // 'First Mile' | 'Middle Mile' | 'Last Mile' | ''
|
||||
final String pincode; // client postal/PIN code — optional manual entry
|
||||
|
||||
// Device GPS at the moment of survey submission — stored for field audit, not displayed in app UI.
|
||||
final double surveyLat;
|
||||
final double surveyLng;
|
||||
final String surveyAddress; // reverse-geocoded full address
|
||||
final String surveyZone; // suburb / neighbourhood from geocoder
|
||||
final String surveyPincode; // postal code from GPS reverse-geocode
|
||||
|
||||
// Field-agent attribution — who collected this record (from login session).
|
||||
final String recordedByName; // e.g. "Kamesh"
|
||||
final String recordedByEmail; // login email of the agent
|
||||
final String recordedByRole; // 'admin' | 'rep' | 'support'
|
||||
|
||||
const Client({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.city,
|
||||
required this.businessType,
|
||||
required this.parcelVolume,
|
||||
required this.activeContracts,
|
||||
required this.provider,
|
||||
required this.efficiency,
|
||||
required this.status,
|
||||
required this.lastUpdated,
|
||||
required this.surveySubmitted,
|
||||
this.notes = '',
|
||||
this.dataConsent = DataConsent.full,
|
||||
this.phone = '',
|
||||
this.frequency = '',
|
||||
this.businessState = '',
|
||||
this.transitType = '',
|
||||
this.transitFrom = '',
|
||||
this.transitTo = '',
|
||||
this.neighbourhood = '',
|
||||
this.logisticsSegment = '',
|
||||
this.pincode = '',
|
||||
this.surveyLat = 0.0,
|
||||
this.surveyLng = 0.0,
|
||||
this.surveyAddress = '',
|
||||
this.surveyZone = '',
|
||||
this.surveyPincode = '',
|
||||
this.recordedByName = '',
|
||||
this.recordedByEmail = '',
|
||||
this.recordedByRole = '',
|
||||
});
|
||||
|
||||
Client copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? city,
|
||||
BusinessType? businessType,
|
||||
double? parcelVolume,
|
||||
int? activeContracts,
|
||||
String? provider,
|
||||
String? efficiency,
|
||||
ClientStatus? status,
|
||||
String? lastUpdated,
|
||||
bool? surveySubmitted,
|
||||
String? notes,
|
||||
DataConsent? dataConsent,
|
||||
String? phone,
|
||||
String? frequency,
|
||||
String? businessState,
|
||||
String? transitType,
|
||||
String? transitFrom,
|
||||
String? transitTo,
|
||||
String? neighbourhood,
|
||||
String? logisticsSegment,
|
||||
String? pincode,
|
||||
double? surveyLat,
|
||||
double? surveyLng,
|
||||
String? surveyAddress,
|
||||
String? surveyZone,
|
||||
String? surveyPincode,
|
||||
String? recordedByName,
|
||||
String? recordedByEmail,
|
||||
String? recordedByRole,
|
||||
}) {
|
||||
return Client(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
city: city ?? this.city,
|
||||
businessType: businessType ?? this.businessType,
|
||||
parcelVolume: parcelVolume ?? this.parcelVolume,
|
||||
activeContracts: activeContracts ?? this.activeContracts,
|
||||
provider: provider ?? this.provider,
|
||||
efficiency: efficiency ?? this.efficiency,
|
||||
status: status ?? this.status,
|
||||
lastUpdated: lastUpdated ?? this.lastUpdated,
|
||||
surveySubmitted: surveySubmitted ?? this.surveySubmitted,
|
||||
notes: notes ?? this.notes,
|
||||
dataConsent: dataConsent ?? this.dataConsent,
|
||||
phone: phone ?? this.phone,
|
||||
frequency: frequency ?? this.frequency,
|
||||
businessState: businessState ?? this.businessState,
|
||||
transitType: transitType ?? this.transitType,
|
||||
transitFrom: transitFrom ?? this.transitFrom,
|
||||
transitTo: transitTo ?? this.transitTo,
|
||||
neighbourhood: neighbourhood ?? this.neighbourhood,
|
||||
logisticsSegment: logisticsSegment ?? this.logisticsSegment,
|
||||
pincode: pincode ?? this.pincode,
|
||||
surveyLat: surveyLat ?? this.surveyLat,
|
||||
surveyLng: surveyLng ?? this.surveyLng,
|
||||
surveyAddress: surveyAddress ?? this.surveyAddress,
|
||||
surveyZone: surveyZone ?? this.surveyZone,
|
||||
surveyPincode: surveyPincode ?? this.surveyPincode,
|
||||
recordedByName: recordedByName ?? this.recordedByName,
|
||||
recordedByEmail: recordedByEmail ?? this.recordedByEmail,
|
||||
recordedByRole: recordedByRole ?? this.recordedByRole,
|
||||
);
|
||||
}
|
||||
}
|
||||
1483
lib/screens/clients_screen.dart
Normal file
1483
lib/screens/clients_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
709
lib/screens/dashboard_screen.dart
Normal file
709
lib/screens/dashboard_screen.dart
Normal file
@@ -0,0 +1,709 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/client.dart';
|
||||
import '../models/activity.dart';
|
||||
import '../models/app_stats.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'surveys_list_screen.dart';
|
||||
|
||||
class DashboardScreen extends StatefulWidget {
|
||||
final AppStats stats;
|
||||
final List<Activity> activities;
|
||||
final List<Client> clients;
|
||||
final List<dynamic> surveys;
|
||||
final List<dynamic> pricing;
|
||||
final void Function(int) onNavigate;
|
||||
final VoidCallback onDataChanged;
|
||||
final bool backendReady;
|
||||
final VoidCallback onLogout;
|
||||
final bool isActive;
|
||||
|
||||
const DashboardScreen({
|
||||
super.key,
|
||||
required this.stats,
|
||||
required this.activities,
|
||||
required this.clients,
|
||||
this.surveys = const [],
|
||||
this.pricing = const [],
|
||||
required this.onNavigate,
|
||||
required this.onDataChanged,
|
||||
required this.backendReady,
|
||||
required this.onLogout,
|
||||
this.isActive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends State<DashboardScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant DashboardScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.isActive && !oldWidget.isActive) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 100),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
WideTopBanner(
|
||||
backendReady: widget.backendReady,
|
||||
subtitle: 'DASHBOARD',
|
||||
onLogout: widget.onLogout,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_WelcomeHeader(),
|
||||
const SizedBox(height: 20),
|
||||
_BentoGrid(stats: widget.stats),
|
||||
const SizedBox(height: 20),
|
||||
_QuickActions(onNavigate: widget.onNavigate),
|
||||
const SizedBox(height: 20),
|
||||
_MarketInsights(surveys: widget.surveys, pricing: widget.pricing, onNavigate: widget.onNavigate, onDataChanged: widget.onDataChanged),
|
||||
const SizedBox(height: 20),
|
||||
_RecentActivity(
|
||||
activities: widget.activities,
|
||||
clients: widget.clients,
|
||||
onNavigate: widget.onNavigate,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_InsightCard(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WelcomeHeader extends StatelessWidget {
|
||||
static String get _greeting {
|
||||
final h = DateTime.now().hour;
|
||||
if (h < 12) return 'Good Morning';
|
||||
if (h < 17) return 'Good Afternoon';
|
||||
if (h < 21) return 'Good Evening';
|
||||
return 'Good Night';
|
||||
}
|
||||
|
||||
static String get _subtext {
|
||||
final h = DateTime.now().hour;
|
||||
if (h < 12) return 'Start your day with a fresh logistics survey.';
|
||||
if (h < 17) return 'Keep the momentum log your field visits.';
|
||||
if (h < 21) return 'Wrap up the day any pending surveys to log?';
|
||||
return 'Late session active your data is synced.';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_greeting,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_subtext,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BentoGrid extends StatelessWidget {
|
||||
final AppStats stats;
|
||||
const _BentoGrid({required this.stats});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// Large card spanning full width
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFD3E4FE),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFA6C8FF).withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'TOTAL CLIENTS',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
stats.totalClients.toString().replaceAllMapped(
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(m) => '${m[1]},',
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.arrow_upward,
|
||||
size: 14, color: AppColors.greenDark),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'+12% from last month',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.greenDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: -20,
|
||||
bottom: -20,
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.primary.withValues(alpha: 0.06),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SmallStatCard(
|
||||
icon: Icons.calendar_today,
|
||||
iconColor: AppColors.primary,
|
||||
value: stats.todayEntries.toString(),
|
||||
label: "Today's Entries",
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SmallStatCard(
|
||||
icon: Icons.access_time,
|
||||
iconColor: const Color(0xFF004D47),
|
||||
value: stats.pendingFollowups.toString(),
|
||||
label: 'Pending Follow-ups',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SmallStatCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final String value;
|
||||
final String label;
|
||||
|
||||
const _SmallStatCard({
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.value,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 24, color: iconColor),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickActions extends StatelessWidget {
|
||||
final void Function(int) onNavigate;
|
||||
const _QuickActions({required this.onNavigate});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'QUICK ACTIONS',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.2,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => onNavigate(4),
|
||||
icon: const Icon(Icons.add, size: 18, color: Colors.white),
|
||||
label: const Text(
|
||||
'Quick Add Client',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
elevation: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => onNavigate(1),
|
||||
icon: Icon(Icons.search, size: 18, color: AppColors.darkBlue),
|
||||
label: Text(
|
||||
'Search Client Database',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.darkBlue,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFD3E4FE),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: const BorderSide(color: Color(0xFFA6C8FF)),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MarketInsights extends StatelessWidget {
|
||||
final List<dynamic> surveys;
|
||||
final List<dynamic> pricing;
|
||||
final void Function(int) onNavigate;
|
||||
final VoidCallback onDataChanged;
|
||||
|
||||
const _MarketInsights({required this.surveys, required this.pricing, required this.onNavigate, required this.onDataChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'MARKET DATA',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.2,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (ctx) => SurveysListScreen(
|
||||
surveys: surveys,
|
||||
onDataChanged: onDataChanged,
|
||||
),
|
||||
));
|
||||
},
|
||||
child: _SmallStatCard(
|
||||
icon: Icons.storefront_outlined,
|
||||
iconColor: const Color(0xFFE87C00),
|
||||
value: surveys.length.toString(),
|
||||
label: 'Competitor Surveys',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onNavigate(3),
|
||||
child: _SmallStatCard(
|
||||
icon: Icons.price_change_outlined,
|
||||
iconColor: const Color(0xFF15803D),
|
||||
value: pricing.length.toString(),
|
||||
label: 'Carrier Pricing',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentActivity extends StatelessWidget {
|
||||
final List<Activity> activities;
|
||||
final List<Client> clients;
|
||||
final void Function(int) onNavigate;
|
||||
|
||||
const _RecentActivity({
|
||||
required this.activities,
|
||||
required this.clients,
|
||||
required this.onNavigate,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'RECENT ACTIVITY',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.2,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => onNavigate(1),
|
||||
child: Text(
|
||||
'View all',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Column(
|
||||
children: activities
|
||||
.take(5)
|
||||
.map((activity) => _ActivityTile(
|
||||
activity: activity,
|
||||
onTap: () => onNavigate(1),
|
||||
isLast: activity == activities.last,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActivityTile extends StatelessWidget {
|
||||
final Activity activity;
|
||||
final VoidCallback onTap;
|
||||
final bool isLast;
|
||||
|
||||
const _ActivityTile({
|
||||
required this.activity,
|
||||
required this.onTap,
|
||||
required this.isLast,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color iconBg;
|
||||
Color iconColor;
|
||||
IconData iconData;
|
||||
|
||||
switch (activity.icon) {
|
||||
case ActivityIcon.shipping:
|
||||
iconBg = const Color(0xFFD5E0F8);
|
||||
iconColor = const Color(0xFF1E3A8A);
|
||||
iconData = Icons.local_shipping;
|
||||
break;
|
||||
case ActivityIcon.call:
|
||||
iconBg = const Color(0xFFFFE4E6);
|
||||
iconColor = AppColors.primary;
|
||||
iconData = Icons.phone;
|
||||
break;
|
||||
case ActivityIcon.edit:
|
||||
iconBg = const Color(0xFFDCFCE7);
|
||||
iconColor = const Color(0xFF15803D);
|
||||
iconData = Icons.edit;
|
||||
break;
|
||||
}
|
||||
|
||||
Widget? badge;
|
||||
if (activity.status == ActivityStatus.newStatus) {
|
||||
badge = _Badge(label: 'NEW', bg: const Color(0xFFFFDAD7), fg: const Color(0xFF930013));
|
||||
} else if (activity.status == ActivityStatus.pending) {
|
||||
badge = _Badge(label: 'PENDING', bg: const Color(0xFFD8E3FB), fg: const Color(0xFF3C475A));
|
||||
} else if (activity.status == ActivityStatus.completed) {
|
||||
badge = _Badge(label: 'DONE', bg: const Color(0xFFD8F9EE), fg: const Color(0xFF115E59));
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: isLast
|
||||
? const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(16),
|
||||
bottomRight: Radius.circular(16),
|
||||
)
|
||||
: null,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: isLast
|
||||
? null
|
||||
: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.border.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBg,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(iconData, size: 18, color: iconColor),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
activity.clientName,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
activity.action,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
?badge,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Badge extends StatelessWidget {
|
||||
final String label;
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
|
||||
const _Badge({required this.label, required this.bg, required this.fg});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: fg,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InsightCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 160,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF0B1C30), Color(0xFF8D0012)],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: CustomPaint(painter: _GridPainter()),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.auto_awesome,
|
||||
size: 12, color: const Color(0xFFFFDAD7)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'INSIGHT OF THE WEEK',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.2,
|
||||
color: const Color(0xFFFFDAD7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Weekly Performance Report',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"Analyze your team's survey conversion rates.",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GridPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.04)
|
||||
..strokeWidth = 1;
|
||||
const spacing = 30.0;
|
||||
for (double x = 0; x < size.width; x += spacing) {
|
||||
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
|
||||
}
|
||||
for (double y = 0; y < size.height; y += spacing) {
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_GridPainter oldDelegate) => false;
|
||||
}
|
||||
266
lib/screens/login_screen.dart
Normal file
266
lib/screens/login_screen.dart
Normal file
@@ -0,0 +1,266 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../utils/snackbar_utils.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
final void Function(AuthUser user) onLoggedIn;
|
||||
|
||||
const LoginScreen({super.key, required this.onLoggedIn});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _auth = AuthService();
|
||||
|
||||
final _emailCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _login() async {
|
||||
final email = _emailCtrl.text.trim();
|
||||
final password = _passwordCtrl.text;
|
||||
|
||||
if (email.isEmpty || password.isEmpty) {
|
||||
SnackbarUtils.showError(context, 'Enter your email and password');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_busy = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final user = await _auth.login(email, password);
|
||||
if (!mounted) return;
|
||||
if (user == null) {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
});
|
||||
SnackbarUtils.showError(context, 'Invalid email or password');
|
||||
return;
|
||||
}
|
||||
|
||||
widget.onLoggedIn(user);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
});
|
||||
SnackbarUtils.showError(context, 'Could not reach server. Check your connection.');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.primary,
|
||||
body: SafeArea(
|
||||
child: Align(
|
||||
alignment: const Alignment(0, -0.4),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_logo(),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Doormile CRM',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'FIELD SALES PORTAL',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 2,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
_card(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _logo() {
|
||||
return Container(
|
||||
width: 92,
|
||||
height: 92,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.18),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Image.asset('assets/doormilelogoround.png', fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(22),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.12),
|
||||
blurRadius: 30,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _loginForm(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _loginForm() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Sign In',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Welcome back! Please enter your details.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _emailCtrl,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Email',
|
||||
prefixIcon: const Icon(Icons.email_outlined, color: AppColors.muted),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade50,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordCtrl,
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.go,
|
||||
onSubmitted: (_) => _login(),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Password',
|
||||
prefixIcon: const Icon(Icons.lock_outline, color: AppColors.muted),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade50,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_primaryButton(
|
||||
label: 'Continue',
|
||||
onPressed: _busy ? null : _login,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
Widget _primaryButton({
|
||||
required String label,
|
||||
required VoidCallback? onPressed,
|
||||
}) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: AppColors.primary.withValues(alpha: 0.5),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _busy
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
538
lib/screens/pricing_screen.dart
Normal file
538
lib/screens/pricing_screen.dart
Normal file
@@ -0,0 +1,538 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/crm_api_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../utils/snackbar_utils.dart';
|
||||
|
||||
class PricingScreen extends StatefulWidget {
|
||||
final List<dynamic> pricing;
|
||||
final bool backendReady;
|
||||
final bool isActive;
|
||||
final VoidCallback onDataChanged;
|
||||
|
||||
const PricingScreen({
|
||||
super.key,
|
||||
required this.pricing,
|
||||
required this.backendReady,
|
||||
required this.onDataChanged,
|
||||
this.isActive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PricingScreen> createState() => _PricingScreenState();
|
||||
}
|
||||
|
||||
class _PricingScreenState extends State<PricingScreen> {
|
||||
final CrmApiService _api = CrmApiService();
|
||||
|
||||
bool _isSaving = false;
|
||||
|
||||
// Group pricing by company
|
||||
Map<String, List<dynamic>> get groupedPricing {
|
||||
final Map<String, List<dynamic>> map = {};
|
||||
for (var p in widget.pricing) {
|
||||
final company = p['company']?.toString() ?? 'Unknown';
|
||||
if (!map.containsKey(company)) {
|
||||
map[company] = [];
|
||||
}
|
||||
map[company]!.add(p);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
void _showAddPricingDialog([String? defaultProvider]) {
|
||||
_showPricingFormDialog(defaultProvider: defaultProvider);
|
||||
}
|
||||
|
||||
void _showEditPricingDialog(Map<String, dynamic> rateData) {
|
||||
_showPricingFormDialog(rateData: rateData);
|
||||
}
|
||||
|
||||
void _showPricingFormDialog({Map<String, dynamic>? rateData, String? defaultProvider}) {
|
||||
final isEditing = rateData != null;
|
||||
final companyCtrl = TextEditingController(text: rateData?['company']?.toString() ?? defaultProvider ?? '');
|
||||
final slabCtrl = TextEditingController(text: rateData?['weight_slab']?.toString() ?? '');
|
||||
final zoneCtrl = TextEditingController(text: rateData?['zone']?.toString().isNotEmpty == true ? rateData!['zone'] : rateData?['service_type'] ?? '');
|
||||
final rateCtrl = TextEditingController(text: rateData?['rate']?.toString() ?? '');
|
||||
final String id = rateData?['id']?.toString() ?? '';
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 20),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.blueLight,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
|
||||
child: Icon(isEditing ? Icons.edit_outlined : Icons.add_circle_outline, color: AppColors.primary, size: 24),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(isEditing ? 'Edit Rate' : 'Add New Rate', style: const TextStyle(fontWeight: FontWeight.w800, color: AppColors.darkBlue, fontSize: 18)),
|
||||
const SizedBox(height: 2),
|
||||
Text(isEditing ? 'Update pricing information' : 'Add to provider pricing slab', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Form Fields
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildModernTextField(
|
||||
controller: companyCtrl,
|
||||
label: 'Provider Name',
|
||||
hint: 'e.g. DTDC, Delhivery',
|
||||
icon: Icons.storefront_outlined,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildModernTextField(
|
||||
controller: slabCtrl,
|
||||
label: 'Weight Slab',
|
||||
hint: 'e.g. 500g, 1kg-2kg',
|
||||
icon: Icons.scale_outlined,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildModernTextField(
|
||||
controller: zoneCtrl,
|
||||
label: 'Zone / Service',
|
||||
hint: 'e.g. LOCAL, SOUTH',
|
||||
icon: Icons.place_outlined,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildModernTextField(
|
||||
controller: rateCtrl,
|
||||
label: 'Rate (₹)',
|
||||
hint: '0.00',
|
||||
icon: Icons.currency_rupee,
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Actions
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('Cancel', style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (companyCtrl.text.isEmpty || slabCtrl.text.isEmpty || rateCtrl.text.isEmpty) {
|
||||
SnackbarUtils.showError(ctx, 'Please fill in required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
Navigator.pop(ctx);
|
||||
|
||||
final payload = {
|
||||
'company': companyCtrl.text,
|
||||
'weight_slab': slabCtrl.text,
|
||||
'zone': zoneCtrl.text,
|
||||
'service_type': '',
|
||||
'rate': rateCtrl.text,
|
||||
};
|
||||
|
||||
final success = isEditing
|
||||
? await _api.updatePricing(id, payload)
|
||||
: await _api.createPricing(payload);
|
||||
|
||||
setState(() => _isSaving = false);
|
||||
|
||||
if (success) {
|
||||
widget.onDataChanged();
|
||||
if (mounted) {
|
||||
SnackbarUtils.showSuccess(context, isEditing ? 'Rate updated!' : 'Rate added!');
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
SnackbarUtils.showError(context, isEditing ? 'Update failed.' : 'Failed to add rate.');
|
||||
}
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: Text(isEditing ? 'Save Changes' : 'Add Rate', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModernTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required String hint,
|
||||
required IconData icon,
|
||||
TextInputType keyboardType = TextInputType.text,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: AppColors.darkBlue, letterSpacing: 0.5),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, color: AppColors.darkBlue, fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(color: AppColors.textSecondary.withValues(alpha: 0.5)),
|
||||
prefixIcon: Icon(icon, size: 18, color: AppColors.textSecondary),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF8F9FA),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _deletePricing(String id) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: AppColors.primary.withValues(alpha: 0.1), shape: BoxShape.circle),
|
||||
child: const Icon(Icons.warning_amber_rounded, color: AppColors.primary, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Delete Pricing Rate?', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: AppColors.darkBlue)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('This action cannot be undone. Are you sure you want to delete this rate?', textAlign: TextAlign.center, style: TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
|
||||
child: const Text('Cancel', style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('Delete', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm != true) return;
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
final success = await _api.deletePricing(id);
|
||||
setState(() => _isSaving = false);
|
||||
|
||||
if (success) {
|
||||
widget.onDataChanged();
|
||||
if (mounted) {
|
||||
SnackbarUtils.showSuccess(context, 'Pricing rate deleted.');
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
SnackbarUtils.showError(context, 'Failed to delete pricing rate.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groups = groupedPricing;
|
||||
final companies = groups.keys.toList()..sort();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.scaffold,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
|
||||
child: WideTopBanner(
|
||||
backendReady: widget.backendReady,
|
||||
subtitle: 'CARRIER PRICING',
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'All Providers',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isSaving ? null : () => _showAddPricingDialog(),
|
||||
icon: const Icon(Icons.add, size: 18, color: Colors.white),
|
||||
label: const Text('Add Provider / Rate', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isSaving)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(20.0),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
if (companies.isEmpty && !_isSaving)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(40.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No pricing data available.\nAdd a provider to get started.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppColors.textSecondary, fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final company = companies[index];
|
||||
final rates = groups[company] ?? [];
|
||||
|
||||
final zones = rates.map((r) => r['zone']?.toString().toUpperCase() ?? 'LOCAL').toSet().toList()..sort();
|
||||
final slabs = rates.map((r) => r['weight_slab']?.toString() ?? '').toSet().toList()..sort();
|
||||
final initial = company.isNotEmpty ? company.substring(0, 1).toUpperCase() : 'P';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: Colors.grey.shade200, width: 1),
|
||||
),
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
child: ExpansionTile(
|
||||
shape: const Border(),
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFDECEE),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initial,
|
||||
style: const TextStyle(color: AppColors.primary, fontSize: 20, fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
company,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800, color: AppColors.darkBlue, fontSize: 16),
|
||||
),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
const Icon(Icons.place_outlined, size: 14, color: AppColors.primary),
|
||||
const SizedBox(width: 4),
|
||||
Text('${rates.length} Registered Rates', style: const TextStyle(color: AppColors.textSecondary, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade200)),
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
headingRowColor: WidgetStateProperty.all(Colors.grey.shade50),
|
||||
dataRowMinHeight: 56,
|
||||
dataRowMaxHeight: 56,
|
||||
columnSpacing: 32,
|
||||
horizontalMargin: 24,
|
||||
columns: [
|
||||
const DataColumn(label: Text('WEIGHT SLAB', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: AppColors.textSecondary))),
|
||||
...zones.map((z) => DataColumn(label: Text(z, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: AppColors.textSecondary)))),
|
||||
],
|
||||
rows: slabs.map((slab) {
|
||||
return DataRow(
|
||||
cells: [
|
||||
DataCell(Text(slab, style: const TextStyle(fontWeight: FontWeight.w700, color: AppColors.darkBlue, fontSize: 13))),
|
||||
...zones.map((zone) {
|
||||
final matchingRate = rates.firstWhere(
|
||||
(r) => (r['weight_slab'] == slab) && ((r['zone']?.toString().toUpperCase() ?? 'LOCAL') == zone),
|
||||
orElse: () => null,
|
||||
);
|
||||
if (matchingRate == null) {
|
||||
return const DataCell(Center(child: Text('—', style: TextStyle(color: Colors.grey))));
|
||||
}
|
||||
return DataCell(
|
||||
InkWell(
|
||||
onTap: () {
|
||||
// Show options to edit or delete
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit_outlined, color: AppColors.textSecondary),
|
||||
title: const Text('Edit Rate'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_showEditPricingDialog(matchingRate as Map<String, dynamic>);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline, color: AppColors.primary),
|
||||
title: const Text('Delete Rate', style: TextStyle(color: AppColors.primary)),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_deletePricing(matchingRate['id'].toString());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.green.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'₹${matchingRate['rate']}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w800, color: AppColors.greenDark, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _showAddPricingDialog(company),
|
||||
icon: const Icon(Icons.add, size: 16, color: AppColors.primary),
|
||||
label: const Text('Add Rate', style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: companies.length,
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
2843
lib/screens/survey_screen.dart
Normal file
2843
lib/screens/survey_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
843
lib/screens/surveys_list_screen.dart
Normal file
843
lib/screens/surveys_list_screen.dart
Normal file
@@ -0,0 +1,843 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/crm_api_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../utils/snackbar_utils.dart';
|
||||
|
||||
class SurveysListScreen extends StatefulWidget {
|
||||
final List<dynamic> surveys;
|
||||
final VoidCallback onDataChanged;
|
||||
final bool isActive;
|
||||
|
||||
const SurveysListScreen({
|
||||
super.key,
|
||||
required this.surveys,
|
||||
required this.onDataChanged,
|
||||
this.isActive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SurveysListScreen> createState() => _SurveysListScreenState();
|
||||
}
|
||||
|
||||
class _SurveysListScreenState extends State<SurveysListScreen> {
|
||||
final CrmApiService _api = CrmApiService();
|
||||
bool _isSaving = false;
|
||||
|
||||
void _deleteSurvey(String id) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: AppColors.primary.withValues(alpha: 0.1), shape: BoxShape.circle),
|
||||
child: const Icon(Icons.warning_amber_rounded, color: AppColors.primary, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Delete Survey?', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: AppColors.darkBlue)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('This action cannot be undone. Are you sure you want to delete this record?', textAlign: TextAlign.center, style: TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
|
||||
child: const Text('Cancel', style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('Delete', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm != true) return;
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
final success = await _api.deleteSurvey(id);
|
||||
setState(() => _isSaving = false);
|
||||
|
||||
if (success) {
|
||||
widget.onDataChanged();
|
||||
if (mounted) {
|
||||
SnackbarUtils.showSuccess(context, 'Survey deleted successfully.');
|
||||
Navigator.pop(context); // Optional: close screen or refresh
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
SnackbarUtils.showError(context, 'Failed to delete survey.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showEditSurveyDialog(Map<String, dynamic> survey) {
|
||||
_showSurveyFormDialog(survey: survey);
|
||||
}
|
||||
|
||||
void _showAddSurveyDialog() {
|
||||
_showSurveyFormDialog();
|
||||
}
|
||||
|
||||
void _showSurveyFormDialog({Map<String, dynamic>? survey}) {
|
||||
final isEditing = survey != null;
|
||||
final companyCtrl = TextEditingController(text: survey?['company']?.toString() ?? '');
|
||||
final areaCtrl = TextEditingController(text: survey?['area']?.toString() ?? '');
|
||||
final phoneCtrl = TextEditingController(text: survey?['phone']?.toString() ?? '');
|
||||
final rateCtrl = TextEditingController(text: survey?['rate_per_kg']?.toString() ?? '');
|
||||
final addressCtrl = TextEditingController(text: survey?['address']?.toString() ?? '');
|
||||
final id = survey?['id']?.toString() ?? '';
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 20),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.blueLight,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
|
||||
child: Icon(isEditing ? Icons.edit_outlined : Icons.add_circle_outline, color: AppColors.primary, size: 24),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(isEditing ? 'Edit Survey' : 'Add Competitor Survey', style: const TextStyle(fontWeight: FontWeight.w800, color: AppColors.darkBlue, fontSize: 18)),
|
||||
const SizedBox(height: 2),
|
||||
Text(isEditing ? 'Update competitor details' : 'Log a new provider survey', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Form Fields
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildModernTextField(
|
||||
controller: companyCtrl,
|
||||
label: 'Company / Provider',
|
||||
hint: 'e.g. DTDC, Delhivery',
|
||||
icon: Icons.storefront_outlined,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildModernTextField(
|
||||
controller: areaCtrl,
|
||||
label: 'Area / Zone',
|
||||
hint: 'e.g. SOUTH ZONE',
|
||||
icon: Icons.place_outlined,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildModernTextField(
|
||||
controller: phoneCtrl,
|
||||
label: 'Contact Number',
|
||||
hint: 'Optional',
|
||||
icon: Icons.phone_outlined,
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildModernTextField(
|
||||
controller: rateCtrl,
|
||||
label: 'Rate / KG',
|
||||
hint: '0.00',
|
||||
icon: Icons.currency_rupee,
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildModernTextField(
|
||||
controller: addressCtrl,
|
||||
label: 'Full Address',
|
||||
hint: 'Optional physical address',
|
||||
icon: Icons.map_outlined,
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Actions
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('Cancel', style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (companyCtrl.text.isEmpty || areaCtrl.text.isEmpty) {
|
||||
SnackbarUtils.showError(ctx, 'Company and Area are required');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
Navigator.pop(ctx);
|
||||
|
||||
final payload = {
|
||||
'company': companyCtrl.text,
|
||||
'area': areaCtrl.text,
|
||||
'phone': phoneCtrl.text,
|
||||
'rate_per_kg': rateCtrl.text,
|
||||
'address': addressCtrl.text,
|
||||
'offers_pickup': survey?['offers_pickup'] ?? 'no',
|
||||
'offers_drop': survey?['offers_drop'] ?? 'no',
|
||||
'packing_charge': survey?['packing_charge'] ?? '',
|
||||
'time_in_days': survey?['time_in_days'] ?? '',
|
||||
'plus_code': survey?['plus_code'] ?? '',
|
||||
'frequency': survey?['frequency'] ?? 'Daily',
|
||||
'pincodes': survey?['pincodes'] ?? '',
|
||||
};
|
||||
|
||||
final success = isEditing
|
||||
? await _api.updateSurvey(id, payload)
|
||||
: await _api.createSurvey(payload);
|
||||
|
||||
setState(() => _isSaving = false);
|
||||
|
||||
if (success) {
|
||||
widget.onDataChanged();
|
||||
if (mounted) {
|
||||
SnackbarUtils.showSuccess(context, isEditing ? 'Survey updated!' : 'Survey added!');
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
SnackbarUtils.showError(context, isEditing ? 'Update failed.' : 'Failed to add survey.');
|
||||
}
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: Text(isEditing ? 'Save Changes' : 'Add Survey', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModernTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required String hint,
|
||||
required IconData icon,
|
||||
TextInputType keyboardType = TextInputType.text,
|
||||
int maxLines = 1,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: AppColors.darkBlue, letterSpacing: 0.5),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
maxLines: maxLines,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, color: AppColors.darkBlue, fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(color: AppColors.textSecondary.withValues(alpha: 0.5)),
|
||||
prefixIcon: maxLines == 1 ? Icon(icon, size: 18, color: AppColors.textSecondary) : null,
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF8F9FA),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: maxLines == 1 ? 16 : 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatCard(String label, String value, IconData icon) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withValues(alpha: 0.02), blurRadius: 4, offset: const Offset(0, 2)),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: AppColors.primary),
|
||||
const SizedBox(height: 8),
|
||||
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: AppColors.darkBlue)),
|
||||
const SizedBox(height: 2),
|
||||
Text(label, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Group surveys by company
|
||||
Map<String, List<dynamic>> get groupedSurveys {
|
||||
final Map<String, List<dynamic>> map = {};
|
||||
for (var s in widget.surveys) {
|
||||
final company = s['company']?.toString().trim();
|
||||
final key = (company == null || company.isEmpty) ? 'Unknown' : company;
|
||||
if (!map.containsKey(key)) {
|
||||
map[key] = [];
|
||||
}
|
||||
map[key]!.add(s);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groups = groupedSurveys;
|
||||
final companies = groups.keys.toList()..sort();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.scaffold,
|
||||
appBar: AppBar(
|
||||
title: const Text('Competitor Intelligence', style: TextStyle(color: AppColors.darkBlue, fontWeight: FontWeight.w800)),
|
||||
backgroundColor: Colors.white,
|
||||
iconTheme: const IconThemeData(color: AppColors.darkBlue),
|
||||
elevation: 0,
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _isSaving ? null : _showAddSurveyDialog,
|
||||
backgroundColor: AppColors.primary,
|
||||
icon: const Icon(Icons.add, color: Colors.white),
|
||||
label: const Text('Add Provider', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
body: _isSaving
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: widget.surveys.isEmpty
|
||||
? const Center(child: Text('No surveys available.', style: TextStyle(color: AppColors.textSecondary)))
|
||||
: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppColors.blueLight, Colors.white],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.primary.withValues(alpha: 0.1)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.radar_outlined, color: AppColors.primary, size: 24),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Market Overview', style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18, color: AppColors.darkBlue)),
|
||||
const SizedBox(height: 2),
|
||||
Text('Tracking ${widget.surveys.length} surveys across ${companies.length} providers', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatCard('Active Providers', companies.length.toString(), Icons.storefront),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildStatCard('Total Branches', widget.surveys.length.toString(), Icons.location_on_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final company = companies[index];
|
||||
final locations = groups[company] ?? [];
|
||||
final initial = company.isNotEmpty ? company.substring(0, 1).toUpperCase() : 'U';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: Colors.grey.shade200, width: 1),
|
||||
),
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
child: ExpansionTile(
|
||||
shape: const Border(),
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initial,
|
||||
style: const TextStyle(color: AppColors.primary, fontSize: 20, fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
company,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800, color: AppColors.darkBlue, fontSize: 16),
|
||||
),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
const Icon(Icons.storefront, size: 14, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 4),
|
||||
Text('${locations.length} Locations Surveyed', style: const TextStyle(color: AppColors.textSecondary, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade200)),
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: locations.map((survey) {
|
||||
return _SurveyLocationCard(
|
||||
survey: survey as Map<String, dynamic>,
|
||||
onEdit: () => _showEditSurveyDialog(survey),
|
||||
onDelete: () => _deleteSurvey(survey['id'].toString()),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: companies.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 80)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SurveyLocationCard extends StatefulWidget {
|
||||
final Map<String, dynamic> survey;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
const _SurveyLocationCard({
|
||||
required this.survey,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SurveyLocationCard> createState() => _SurveyLocationCardState();
|
||||
}
|
||||
|
||||
class _SurveyLocationCardState extends State<_SurveyLocationCard> {
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final survey = widget.survey;
|
||||
final address = survey['address']?.toString() ?? survey['area']?.toString() ?? 'No address';
|
||||
final area = survey['area']?.toString() ?? 'Unknown';
|
||||
final phone = survey['phone']?.toString() ?? 'N/A';
|
||||
final rate = survey['rate_per_kg']?.toString() ?? '';
|
||||
final hasRate = rate.isNotEmpty;
|
||||
|
||||
final pickup = survey['offers_pickup']?.toString().toLowerCase() == 'yes' ? 'Yes' : '—';
|
||||
final drop = survey['offers_drop']?.toString().toLowerCase() == 'yes' ? 'Yes' : '—';
|
||||
final packing = survey['packing_charge']?.toString() ?? '—';
|
||||
final time = survey['time_in_days']?.toString() ?? '—';
|
||||
final freq = survey['frequency']?.toString() ?? '—';
|
||||
final plusCode = survey['plus_code']?.toString() ?? '—';
|
||||
final pincodes = survey['pincodes']?.toString() ?? '—';
|
||||
final company = survey['company']?.toString() ?? '—';
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header Row
|
||||
InkWell(
|
||||
onTap: () => setState(() => _isExpanded = !_isExpanded),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.place_outlined, color: AppColors.primary, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
area,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, color: AppColors.darkBlue, fontSize: 14),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.phone, size: 12, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 4),
|
||||
Text(phone, style: const TextStyle(color: AppColors.textSecondary, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const Text('RATE PER KG', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w800, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: hasRate ? AppColors.green.withValues(alpha: 0.1) : Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
hasRate ? '₹$rate' : 'Not Answered',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: hasRate ? AppColors.greenDark : AppColors.textSecondary
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (!_isExpanded)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
child: Column(
|
||||
children: [
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('LOGISTICS', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w800, color: AppColors.textSecondary)),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _isExpanded = true),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
|
||||
minimumSize: const Size(0, 30),
|
||||
),
|
||||
child: const Text('More Info', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.darkBlue)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: widget.onEdit,
|
||||
child: const Padding(padding: EdgeInsets.all(4.0), child: Icon(Icons.edit_outlined, size: 18, color: AppColors.textSecondary)),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
InkWell(
|
||||
onTap: widget.onDelete,
|
||||
child: const Padding(padding: EdgeInsets.all(4.0), child: Icon(Icons.delete_outline, size: 18, color: AppColors.primaryDark)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (_isExpanded)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)),
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade200)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _isExpanded = false),
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: AppColors.primary.withValues(alpha: 0.05),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
minimumSize: const Size(0, 30),
|
||||
),
|
||||
icon: const Text('Hide Info', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.primary)),
|
||||
label: const Icon(Icons.expand_less, size: 16, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
InkWell(
|
||||
onTap: widget.onEdit,
|
||||
child: const Padding(padding: EdgeInsets.all(4.0), child: Icon(Icons.edit_outlined, size: 18, color: AppColors.textSecondary)),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
InkWell(
|
||||
onTap: widget.onDelete,
|
||||
child: const Padding(padding: EdgeInsets.all(4.0), child: Icon(Icons.delete_outline, size: 18, color: AppColors.primaryDark)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Grid of Details
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildDetailSection(
|
||||
icon: Icons.assignment_outlined,
|
||||
title: 'ENQUIRY DETAILS',
|
||||
items: [
|
||||
_buildInfoItem('RATE PER KG', hasRate ? '₹$rate' : 'Not Answered', isHighlight: hasRate),
|
||||
_buildInfoItem('PICKUP', pickup),
|
||||
_buildInfoItem('DROP', drop),
|
||||
_buildInfoItem('PACKING', packing),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildDetailSection(
|
||||
icon: Icons.local_shipping_outlined,
|
||||
title: 'LOGISTICS & OPS',
|
||||
items: [
|
||||
_buildInfoItem('TIME IN DAYS', time),
|
||||
_buildInfoItem('COMPANY', company),
|
||||
_buildInfoItem('FREQUENCY', freq),
|
||||
_buildInfoItem('CONTACT NUMBER', phone),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildDetailSection(
|
||||
icon: Icons.place_outlined,
|
||||
title: 'LOCATION & PINCODE',
|
||||
items: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildInfoItem('AREA / ZONE', area)),
|
||||
Expanded(child: _buildInfoItem('PLUS CODE', plusCode)),
|
||||
],
|
||||
),
|
||||
_buildInfoItem('SERVICEABLE PINCODES', pincodes),
|
||||
_buildInfoItem('FULL ADDRESS', address),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.map_outlined, size: 14, color: AppColors.primary),
|
||||
label: const Text('View On Map', style: TextStyle(color: AppColors.primary, fontSize: 12, fontWeight: FontWeight.w700)),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8), side: const BorderSide(color: AppColors.primary)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildDetailSection(
|
||||
icon: Icons.list_alt,
|
||||
title: 'RECORD METADATA',
|
||||
items: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildInfoItem('CREATED BY', 'System')),
|
||||
Expanded(child: _buildInfoItem('LAST EDITED BY', '—')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailSection({required IconData icon, required String title, required List<Widget> items}) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFFDECEE),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(8)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: AppColors.primary),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(title, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w800, color: AppColors.darkBlue, letterSpacing: 0.5)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: items.expand((w) => [w, const SizedBox(height: 12)]).toList()..removeLast(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoItem(String label, String value, {bool isHighlight = false}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 9, fontWeight: FontWeight.w800, color: AppColors.textSecondary, letterSpacing: 0.5)),
|
||||
const SizedBox(height: 4),
|
||||
if (isHighlight)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: AppColors.green.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4)),
|
||||
child: Text(value, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.greenDark)),
|
||||
)
|
||||
else
|
||||
Text(value, style: const TextStyle(fontSize: 12, color: AppColors.darkBlue, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
93
lib/screens/update_screen.dart
Normal file
93
lib/screens/update_screen.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:in_app_update/in_app_update.dart';
|
||||
import 'dart:io';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
class UpdateRequiredScreen extends StatelessWidget {
|
||||
const UpdateRequiredScreen({super.key});
|
||||
|
||||
Future<void> _triggerNativeUpdate() async {
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
await InAppUpdate.performImmediateUpdate();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.scaffold,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Illustration or Icon
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF4FF),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.system_update_rounded,
|
||||
size: 50,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Text Content
|
||||
const Text(
|
||||
'Update Required',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'A new version of Doormile CRM is available. You must update to the latest version to continue using the app securely.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Update Button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 54,
|
||||
child: ElevatedButton(
|
||||
onPressed: _triggerNativeUpdate,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text(
|
||||
'Update Now',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/services/auth_service.dart
Normal file
85
lib/services/auth_service.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// A single authenticated user.
|
||||
class AuthUser {
|
||||
final String email;
|
||||
final String name;
|
||||
final String role;
|
||||
final String token;
|
||||
|
||||
const AuthUser({
|
||||
required this.email,
|
||||
required this.name,
|
||||
required this.role,
|
||||
required this.token,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'email': email,
|
||||
'name': name,
|
||||
'role': role,
|
||||
'token': token,
|
||||
};
|
||||
|
||||
factory AuthUser.fromJson(Map<String, dynamic> json) => AuthUser(
|
||||
email: json['email'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
role: json['role'] as String? ?? '',
|
||||
token: json['token'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
class AuthService {
|
||||
static const String _base = 'https://api.doormile.com/api/v1';
|
||||
|
||||
/// Login with real backend API
|
||||
Future<AuthUser?> login(String email, String password) async {
|
||||
try {
|
||||
final res = await http.post(
|
||||
Uri.parse('$_base/admin/login'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'email': email, 'password': password}),
|
||||
).timeout(const Duration(seconds: 15));
|
||||
|
||||
if (res.statusCode == 200) {
|
||||
final data = jsonDecode(res.body);
|
||||
final token = data['token'] ?? '';
|
||||
final userObj = data['user'] ?? {};
|
||||
|
||||
// Sometimes APIs return user inside 'data' or directly. Adjusting based on typical JWT responses.
|
||||
final emailStr = userObj['email'] ?? email;
|
||||
final nameStr = userObj['name'] ?? userObj['first_name'] ?? 'Admin';
|
||||
final roleStr = userObj['role'] ?? 'admin';
|
||||
|
||||
final user = AuthUser(
|
||||
email: emailStr,
|
||||
name: nameStr,
|
||||
role: roleStr,
|
||||
token: token,
|
||||
);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('auth_user_data', jsonEncode(user.toJson()));
|
||||
|
||||
return user;
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore user from local storage
|
||||
Future<AuthUser?> getSavedUser() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final userData = prefs.getString('auth_user_data');
|
||||
if (userData != null && userData.isNotEmpty) {
|
||||
return AuthUser.fromJson(jsonDecode(userData));
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
380
lib/services/crm_api_service.dart
Normal file
380
lib/services/crm_api_service.dart
Normal file
@@ -0,0 +1,380 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/client.dart';
|
||||
|
||||
class CrmApiService {
|
||||
static const String _base = 'https://api.doormile.com/api/v1';
|
||||
|
||||
final http.Client _http;
|
||||
String? _authToken;
|
||||
|
||||
CrmApiService({http.Client? client}) : _http = client ?? http.Client();
|
||||
|
||||
void setToken(String token) {
|
||||
_authToken = token;
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _getHeaders() async {
|
||||
final Map<String, String> headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (_authToken != null && _authToken!.isNotEmpty) {
|
||||
headers['Authorization'] = 'Bearer $_authToken';
|
||||
} else {
|
||||
// Fallback to shared preferences just in case
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final userData = prefs.getString('auth_user_data');
|
||||
if (userData != null && userData.isNotEmpty) {
|
||||
final token = jsonDecode(userData)['token']?.toString();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fetch all clients from the backend.
|
||||
/// Throws on network failure so the caller can distinguish "backend down"
|
||||
/// from "backend returned an empty list".
|
||||
Future<List<Client>> loadClients() async {
|
||||
try {
|
||||
final res = await _http
|
||||
.get(Uri.parse('$_base/crm/clients?limit=1000'), headers: await _getHeaders())
|
||||
.timeout(const Duration(seconds: 10));
|
||||
if (res.statusCode != 200) {
|
||||
debugPrint('loadClients failed: ${res.statusCode} ${res.body}');
|
||||
return [];
|
||||
}
|
||||
final decoded = jsonDecode(res.body);
|
||||
List<dynamic> body = [];
|
||||
if (decoded is List) {
|
||||
body = decoded;
|
||||
} else if (decoded is Map && decoded['data'] is List) {
|
||||
body = decoded['data'];
|
||||
}
|
||||
return body.map((j) => _fromJson(j as Map<String, dynamic>)).toList();
|
||||
} catch (e) {
|
||||
debugPrint('loadClients exception: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new client on the backend. Returns the server-assigned record,
|
||||
/// or null if the call fails (caller keeps the locally-created record).
|
||||
Future<Client?> createClient(Client client) async {
|
||||
try {
|
||||
final res = await _http
|
||||
.post(
|
||||
Uri.parse('$_base/crm/clients'),
|
||||
headers: await _getHeaders(),
|
||||
body: jsonEncode(_toCreatePayload(client)),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
if (res.statusCode != 201) return null;
|
||||
return _fromJson(jsonDecode(res.body) as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an existing client. `client.id` must be a valid numeric backend ID.
|
||||
Future<Client?> updateClient(Client client) async {
|
||||
final numId = int.tryParse(client.id);
|
||||
if (numId == null) return null; // temp local ID — not yet persisted
|
||||
try {
|
||||
final res = await _http
|
||||
.put(
|
||||
Uri.parse('$_base/crm/clients/$numId'),
|
||||
headers: await _getHeaders(),
|
||||
body: jsonEncode(_toUpdatePayload(client)),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
if (res.statusCode != 200) return null;
|
||||
return _fromJson(jsonDecode(res.body) as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a client by ID. Returns true if successful.
|
||||
Future<bool> deleteClient(String clientId) async {
|
||||
final numId = int.tryParse(clientId);
|
||||
if (numId == null) return false;
|
||||
try {
|
||||
final res = await _http
|
||||
.delete(
|
||||
Uri.parse('$_base/crm/clients/$numId'),
|
||||
headers: await _getHeaders(),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
return res.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch all competitor branch surveys
|
||||
Future<List<dynamic>> fetchSurveys() async {
|
||||
try {
|
||||
final res = await _http
|
||||
.get(Uri.parse('$_base/admin/competitor-branches?limit=1000'), headers: await _getHeaders())
|
||||
.timeout(const Duration(seconds: 10));
|
||||
if (res.statusCode != 200) return [];
|
||||
final data = jsonDecode(res.body);
|
||||
if (data is List) return data;
|
||||
if (data is Map && data['data'] is List) return data['data'];
|
||||
return [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch carrier pricing
|
||||
Future<List<dynamic>> fetchPricing() async {
|
||||
try {
|
||||
final res = await _http
|
||||
.get(Uri.parse('$_base/admin/carrier-pricing?limit=1000'), headers: await _getHeaders())
|
||||
.timeout(const Duration(seconds: 10));
|
||||
if (res.statusCode != 200) return [];
|
||||
final data = jsonDecode(res.body);
|
||||
if (data is List) return data;
|
||||
if (data is Map && data['data'] is List) return data['data'];
|
||||
return [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Create carrier pricing
|
||||
Future<bool> createPricing(Map<String, dynamic> data) async {
|
||||
try {
|
||||
final res = await _http
|
||||
.post(
|
||||
Uri.parse('$_base/admin/carrier-pricing'),
|
||||
headers: await _getHeaders(),
|
||||
body: jsonEncode({
|
||||
'company': data['company'],
|
||||
'weight_slab': data['weight_slab'],
|
||||
'zone': data['zone'] ?? '',
|
||||
'service_type': data['service_type'] ?? '',
|
||||
'rate': data['rate'].toString(),
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
return res.statusCode == 200 || res.statusCode == 201 || res.statusCode == 204;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update carrier pricing
|
||||
Future<bool> updatePricing(String id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
final res = await _http
|
||||
.put(
|
||||
Uri.parse('$_base/admin/carrier-pricing/$id'),
|
||||
headers: await _getHeaders(),
|
||||
body: jsonEncode({
|
||||
'company': data['company'],
|
||||
'weight_slab': data['weight_slab'],
|
||||
'zone': data['zone'] ?? '',
|
||||
'service_type': data['service_type'] ?? '',
|
||||
'rate': data['rate'].toString(),
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
return res.statusCode == 200 || res.statusCode == 204;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete carrier pricing
|
||||
Future<bool> deletePricing(String id) async {
|
||||
try {
|
||||
final res = await _http
|
||||
.delete(
|
||||
Uri.parse('$_base/admin/carrier-pricing/$id'),
|
||||
headers: await _getHeaders(),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
return res.statusCode == 200 || res.statusCode == 204;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create competitor branch (survey)
|
||||
Future<bool> createSurvey(Map<String, dynamic> data) async {
|
||||
try {
|
||||
final res = await _http
|
||||
.post(
|
||||
Uri.parse('$_base/admin/competitor-branches'),
|
||||
headers: await _getHeaders(),
|
||||
body: jsonEncode(data),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
return res.statusCode == 200 || res.statusCode == 201 || res.statusCode == 204;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update competitor branch (survey)
|
||||
Future<bool> updateSurvey(String id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
final res = await _http
|
||||
.put(
|
||||
Uri.parse('$_base/admin/competitor-branches/$id'),
|
||||
headers: await _getHeaders(),
|
||||
body: jsonEncode(data),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
return res.statusCode == 200 || res.statusCode == 204;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete competitor branch (survey)
|
||||
Future<bool> deleteSurvey(String id) async {
|
||||
try {
|
||||
final res = await _http
|
||||
.delete(
|
||||
Uri.parse('$_base/admin/competitor-branches/$id'),
|
||||
headers: await _getHeaders(),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
return res.statusCode == 200 || res.statusCode == 204;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Serialisation ───────────────────────────────────────────────────────────
|
||||
|
||||
Client _fromJson(Map<String, dynamic> j) => Client(
|
||||
id: j['id'].toString(),
|
||||
name: _joinName(j['first_name'], j['last_name']),
|
||||
city: j['city'] as String? ?? '',
|
||||
businessType: _parseBusinessType(j['businessType'] as String?),
|
||||
parcelVolume: (j['parcelVolume'] as num?)?.toDouble() ?? 0.0,
|
||||
activeContracts: j['activeContracts'] as int? ?? 0,
|
||||
provider: j['provider'] as String? ?? '',
|
||||
efficiency: j['efficiency'] as String? ?? '',
|
||||
status: _parseStatus(j['status'] as String?),
|
||||
lastUpdated: j['lastUpdated'] as String? ?? '',
|
||||
surveySubmitted: true, // in DB = was submitted
|
||||
notes: j['notes'] as String? ?? '',
|
||||
dataConsent: _parseConsent(j['dataConsent'] as String?),
|
||||
phone: j['phone'] as String? ?? '',
|
||||
frequency: j['frequency'] as String? ?? '',
|
||||
businessState: j['businessState'] as String? ?? '',
|
||||
transitFrom: j['transitFrom'] as String? ?? '',
|
||||
transitTo: j['transitTo'] as String? ?? '',
|
||||
neighbourhood: j['neighbourhood'] as String? ?? '',
|
||||
logisticsSegment: j['logisticsSegment'] as String? ?? '',
|
||||
pincode: j['pincode'] as String? ?? '',
|
||||
surveyLat: (j['survey_lat'] as num?)?.toDouble() ?? 0.0,
|
||||
surveyLng: (j['survey_long'] as num?)?.toDouble() ?? 0.0,
|
||||
surveyAddress: j['surveyAddress'] as String? ?? '',
|
||||
surveyZone: j['surveyZone'] as String? ?? '',
|
||||
surveyPincode: j['surveyPincode'] as String? ?? '',
|
||||
recordedByName: '',
|
||||
recordedByEmail: j['email'] as String? ?? '',
|
||||
recordedByRole: j['role'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _toCreatePayload(Client c) => {
|
||||
'first_name': c.name,
|
||||
'phone': c.phone,
|
||||
'city': c.city,
|
||||
'businessState': c.businessState,
|
||||
'businessType': c.businessType.name,
|
||||
'status': c.status.name,
|
||||
'frequency': c.frequency,
|
||||
'parcelVolume': c.parcelVolume,
|
||||
'activeContracts': c.activeContracts,
|
||||
'provider': c.provider,
|
||||
'efficiency': c.efficiency,
|
||||
'logisticsSegment': c.logisticsSegment,
|
||||
'transitFrom': c.transitFrom,
|
||||
'transitTo': c.transitTo,
|
||||
'neighbourhood': c.neighbourhood,
|
||||
'pincode': c.pincode,
|
||||
'surveyAddress': c.surveyAddress,
|
||||
'surveyZone': c.surveyZone,
|
||||
'surveyPincode': c.surveyPincode,
|
||||
'survey_lat': c.surveyLat,
|
||||
'survey_long': c.surveyLng,
|
||||
'notes': c.notes,
|
||||
'dataConsent': c.dataConsent.name,
|
||||
'registration_source': 'mobile',
|
||||
};
|
||||
|
||||
Map<String, dynamic> _toUpdatePayload(Client c) => {
|
||||
'first_name': c.name,
|
||||
'phone': c.phone,
|
||||
'city': c.city,
|
||||
'businessState': c.businessState,
|
||||
'businessType': c.businessType.name,
|
||||
'status': c.status.name,
|
||||
'frequency': c.frequency,
|
||||
'parcelVolume': c.parcelVolume,
|
||||
'activeContracts': c.activeContracts,
|
||||
'provider': c.provider,
|
||||
'efficiency': c.efficiency,
|
||||
'logisticsSegment': c.logisticsSegment,
|
||||
'transitFrom': c.transitFrom,
|
||||
'transitTo': c.transitTo,
|
||||
'neighbourhood': c.neighbourhood,
|
||||
'pincode': c.pincode,
|
||||
'surveyAddress': c.surveyAddress,
|
||||
'surveyZone': c.surveyZone,
|
||||
'surveyPincode': c.surveyPincode,
|
||||
'survey_lat': c.surveyLat,
|
||||
'survey_long': c.surveyLng,
|
||||
'notes': c.notes,
|
||||
'dataConsent': c.dataConsent.name,
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
String _joinName(dynamic first, dynamic last) {
|
||||
final f = (first as String?) ?? '';
|
||||
final l = (last as String?) ?? '';
|
||||
final joined = [f, l].where((s) => s.isNotEmpty).join(' ');
|
||||
return joined.isEmpty ? '—' : joined;
|
||||
}
|
||||
|
||||
BusinessType _parseBusinessType(String? s) {
|
||||
if (s == null) return BusinessType.retail;
|
||||
return BusinessType.values.firstWhere(
|
||||
(b) => b.name == s,
|
||||
orElse: () => BusinessType.retail,
|
||||
);
|
||||
}
|
||||
|
||||
ClientStatus _parseStatus(String? s) {
|
||||
if (s == null) return ClientStatus.newClient;
|
||||
return ClientStatus.values.firstWhere(
|
||||
(st) => st.name == s,
|
||||
orElse: () => ClientStatus.newClient,
|
||||
);
|
||||
}
|
||||
|
||||
DataConsent _parseConsent(String? s) {
|
||||
if (s == null) return DataConsent.full;
|
||||
return DataConsent.values.firstWhere(
|
||||
(d) => d.name == s,
|
||||
orElse: () => DataConsent.full,
|
||||
);
|
||||
}
|
||||
}
|
||||
146
lib/services/location_service.dart
Normal file
146
lib/services/location_service.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'dart:convert';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Why location cannot be captured (used to show the right alert).
|
||||
enum LocationCheckResult {
|
||||
ready,
|
||||
serviceDisabled, // device GPS/location is switched off
|
||||
permissionDenied, // user denied — can ask again
|
||||
permissionPermanentlyDenied, // "Don't ask again" was checked
|
||||
}
|
||||
|
||||
/// GPS coordinates + reverse-geocoded details captured at survey submission.
|
||||
class SurveyLocation {
|
||||
final double lat;
|
||||
final double lng;
|
||||
final String address;
|
||||
final String zone; // suburb / neighbourhood
|
||||
final String city; // detected city / town
|
||||
final String state; // detected state
|
||||
final String pincode; // postal code from reverse-geocode
|
||||
|
||||
const SurveyLocation({
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
required this.address,
|
||||
required this.zone,
|
||||
this.city = '',
|
||||
this.state = '',
|
||||
this.pincode = '',
|
||||
});
|
||||
|
||||
factory SurveyLocation.empty() =>
|
||||
const SurveyLocation(lat: 0.0, lng: 0.0, address: '', zone: '');
|
||||
|
||||
bool get hasFix => lat != 0.0 || lng != 0.0;
|
||||
}
|
||||
|
||||
class LocationService {
|
||||
/// Checks service + permission WITHOUT triggering a system prompt.
|
||||
/// Use this to decide whether to show an in-app alert before capturing.
|
||||
static Future<LocationCheckResult> checkStatus() async {
|
||||
if (!await Geolocator.isLocationServiceEnabled()) {
|
||||
return LocationCheckResult.serviceDisabled;
|
||||
}
|
||||
final perm = await Geolocator.checkPermission();
|
||||
if (perm == LocationPermission.deniedForever) {
|
||||
return LocationCheckResult.permissionPermanentlyDenied;
|
||||
}
|
||||
if (perm == LocationPermission.denied) {
|
||||
return LocationCheckResult.permissionDenied;
|
||||
}
|
||||
return LocationCheckResult.ready;
|
||||
}
|
||||
|
||||
/// Requests permission (one-shot OS prompt) then captures GPS + address.
|
||||
/// Never throws — returns [SurveyLocation.empty] on any failure.
|
||||
static Future<SurveyLocation> capture() async {
|
||||
try {
|
||||
if (!await Geolocator.isLocationServiceEnabled()) {
|
||||
return SurveyLocation.empty();
|
||||
}
|
||||
|
||||
var perm = await Geolocator.checkPermission();
|
||||
if (perm == LocationPermission.denied) {
|
||||
perm = await Geolocator.requestPermission();
|
||||
}
|
||||
if (perm == LocationPermission.denied ||
|
||||
perm == LocationPermission.deniedForever) {
|
||||
return SurveyLocation.empty();
|
||||
}
|
||||
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
),
|
||||
).timeout(const Duration(seconds: 10));
|
||||
|
||||
final geo = await _reverseGeocode(position.latitude, position.longitude);
|
||||
|
||||
return SurveyLocation(
|
||||
lat: position.latitude,
|
||||
lng: position.longitude,
|
||||
address: geo['display_name'] ?? '',
|
||||
zone: geo['zone'] ?? '',
|
||||
city: geo['city'] ?? '',
|
||||
state: geo['state'] ?? '',
|
||||
pincode: geo['pincode'] ?? '',
|
||||
);
|
||||
} catch (_) {
|
||||
return SurveyLocation.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the device's Location / GPS settings page.
|
||||
static Future<void> openLocationSettings() =>
|
||||
Geolocator.openLocationSettings();
|
||||
|
||||
/// Opens this app's permission settings page (for permanently-denied case).
|
||||
static Future<void> openAppSettings() => Geolocator.openAppSettings();
|
||||
|
||||
static Future<Map<String, String>> _reverseGeocode(
|
||||
double lat, double lng) async {
|
||||
try {
|
||||
final uri = Uri.parse(
|
||||
'https://nominatim.openstreetmap.org/reverse'
|
||||
'?lat=$lat&lon=$lng&format=json&addressdetails=1',
|
||||
);
|
||||
final res = await http
|
||||
.get(uri, headers: {'User-Agent': 'DoorMile-CRM/1.0'})
|
||||
.timeout(const Duration(seconds: 6));
|
||||
|
||||
if (res.statusCode != 200) return {};
|
||||
|
||||
final body = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
final addr = (body['address'] as Map<String, dynamic>?) ?? {};
|
||||
|
||||
final zone = (addr['suburb'] ??
|
||||
addr['neighbourhood'] ??
|
||||
addr['quarter'] ??
|
||||
addr['village'] ??
|
||||
'') as String;
|
||||
|
||||
final city = (addr['city'] ??
|
||||
addr['town'] ??
|
||||
addr['municipality'] ??
|
||||
addr['county'] ??
|
||||
addr['state_district'] ??
|
||||
'') as String;
|
||||
|
||||
final state = (addr['state'] ?? '') as String;
|
||||
|
||||
final pincode = (addr['postcode'] ?? '') as String;
|
||||
|
||||
return {
|
||||
'display_name': (body['display_name'] as String?) ?? '',
|
||||
'zone': zone,
|
||||
'city': city,
|
||||
'state': state,
|
||||
'pincode': pincode,
|
||||
};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
235
lib/services/qdrant_service.dart
Normal file
235
lib/services/qdrant_service.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/client.dart';
|
||||
|
||||
class QdrantService {
|
||||
static const String _baseUrl = 'http://66.116.207.225:6333';
|
||||
static const String _apiKey = 'Package@321#';
|
||||
|
||||
static const String collectionName = 'doormile_clients';
|
||||
|
||||
// 16-dim feature vector (city-agnostic — city stored in payload only)
|
||||
//
|
||||
// [0] parcelVolume (normalised 0–1 over max 1 200)
|
||||
// [1] activeContracts (normalised 0–1 over max 40)
|
||||
// [2–5] businessType one-hot (ecommerce, manufacturing, wholesale, retail)
|
||||
// [6–9] status one-hot (newClient, pending, completed, updated)
|
||||
// [10] surveySubmitted (0 / 1)
|
||||
// [11] dataConsent basicOnly=0, full=1
|
||||
// [12] hasPhone (0 / 1)
|
||||
// [13] hasTransitRoute both from+to non-empty (0 / 1)
|
||||
// [14] frequencyScore none=0, monthly=0.25, biweekly=0.5, weekly=0.75, daily=1
|
||||
// [15] hasNeighbourhood (0 / 1)
|
||||
static const int _vectorSize = 16;
|
||||
|
||||
final http.Client _http;
|
||||
QdrantService({http.Client? client}) : _http = client ?? http.Client();
|
||||
|
||||
Map<String, String> get _headers => {
|
||||
'Content-Type': 'application/json',
|
||||
'api-key': _apiKey,
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
};
|
||||
|
||||
/// Creates the collection if it does not already exist.
|
||||
Future<void> ensureCollection() async {
|
||||
final res = await _http.get(
|
||||
Uri.parse('$_baseUrl/collections/$collectionName'),
|
||||
headers: _headers,
|
||||
);
|
||||
if (res.statusCode == 200) return;
|
||||
|
||||
await _http.put(
|
||||
Uri.parse('$_baseUrl/collections/$collectionName'),
|
||||
headers: _headers,
|
||||
body: jsonEncode({
|
||||
'vectors': {'size': _vectorSize, 'distance': 'Cosine'},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Upsert (insert or update) a single client.
|
||||
Future<void> upsertClient(Client client) async {
|
||||
await _http.put(
|
||||
Uri.parse('$_baseUrl/collections/$collectionName/points'),
|
||||
headers: _headers,
|
||||
body: jsonEncode({
|
||||
'points': [_toPoint(client)],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Batch upsert a list of clients.
|
||||
Future<void> upsertClients(List<Client> clients) async {
|
||||
if (clients.isEmpty) return;
|
||||
await _http.put(
|
||||
Uri.parse('$_baseUrl/collections/$collectionName/points'),
|
||||
headers: _headers,
|
||||
body: jsonEncode({
|
||||
'points': clients.map(_toPoint).toList(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Fetch all stored clients from Qdrant (up to 500).
|
||||
Future<List<Client>> loadClients() async {
|
||||
final res = await _http.post(
|
||||
Uri.parse('$_baseUrl/collections/$collectionName/points/scroll'),
|
||||
headers: _headers,
|
||||
body: jsonEncode({
|
||||
'limit': 500,
|
||||
'with_payload': true,
|
||||
'with_vector': false,
|
||||
}),
|
||||
);
|
||||
if (res.statusCode != 200) return [];
|
||||
final body = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
final points = (body['result']?['points'] as List?) ?? [];
|
||||
return points.map((p) => _fromPoint(p as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
/// Drop and recreate the collection — used for staging resets.
|
||||
Future<void> deleteCollection() async {
|
||||
await _http.delete(
|
||||
Uri.parse('$_baseUrl/collections/$collectionName'),
|
||||
headers: _headers,
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete a client by its string ID.
|
||||
Future<void> deleteClient(String clientId) async {
|
||||
await _http.post(
|
||||
Uri.parse('$_baseUrl/collections/$collectionName/points/delete'),
|
||||
headers: _headers,
|
||||
body: jsonEncode({
|
||||
'points': [_stableId(clientId)],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
int _stableId(String id) {
|
||||
int h = 0;
|
||||
for (final c in id.codeUnits) {
|
||||
h = (h * 31 + c) & 0x7FFFFFFF;
|
||||
}
|
||||
return h == 0 ? 1 : h;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _toPoint(Client client) => {
|
||||
'id': _stableId(client.id),
|
||||
'vector': _toVector(client),
|
||||
'payload': {
|
||||
'clientId': client.id,
|
||||
'name': client.name,
|
||||
'city': client.city,
|
||||
'businessType': client.businessType.name,
|
||||
'parcelVolume': client.parcelVolume,
|
||||
'activeContracts': client.activeContracts,
|
||||
'provider': client.provider,
|
||||
'efficiency': client.efficiency,
|
||||
'status': client.status.name,
|
||||
'lastUpdated': client.lastUpdated,
|
||||
'surveySubmitted': client.surveySubmitted,
|
||||
'notes': client.notes,
|
||||
'dataConsent': client.dataConsent.name,
|
||||
'phone': client.phone,
|
||||
'frequency': client.frequency,
|
||||
'businessState': client.businessState,
|
||||
'transitType': client.transitType,
|
||||
'transitFrom': client.transitFrom,
|
||||
'transitTo': client.transitTo,
|
||||
'neighbourhood': client.neighbourhood,
|
||||
'logisticsSegment': client.logisticsSegment,
|
||||
'pincode': client.pincode,
|
||||
// GPS captured at submission — not shown in app, stored for field audit
|
||||
'surveyLat': client.surveyLat,
|
||||
'surveyLng': client.surveyLng,
|
||||
'surveyAddress': client.surveyAddress,
|
||||
'surveyZone': client.surveyZone,
|
||||
'surveyPincode': client.surveyPincode,
|
||||
// Field-agent attribution — who collected this survey (login session)
|
||||
'recordedByName': client.recordedByName,
|
||||
'recordedByEmail': client.recordedByEmail,
|
||||
'recordedByRole': client.recordedByRole,
|
||||
// Qdrant geo payload — enables geo-radius queries later
|
||||
if (client.surveyLat != 0.0 || client.surveyLng != 0.0)
|
||||
'surveyGeo': {'lat': client.surveyLat, 'lon': client.surveyLng},
|
||||
},
|
||||
};
|
||||
|
||||
List<double> _toVector(Client client) {
|
||||
final v = List<double>.filled(_vectorSize, 0.0);
|
||||
v[0] = (client.parcelVolume / 1200.0).clamp(0.0, 1.0);
|
||||
v[1] = (client.activeContracts / 40.0).clamp(0.0, 1.0);
|
||||
v[2 + client.businessType.index] = 1.0;
|
||||
v[6 + client.status.index] = 1.0;
|
||||
v[10] = client.surveySubmitted ? 1.0 : 0.0;
|
||||
v[11] = client.dataConsent == DataConsent.full ? 1.0 : 0.0;
|
||||
v[12] = client.phone.isNotEmpty ? 1.0 : 0.0;
|
||||
v[13] = (client.transitFrom.isNotEmpty && client.transitTo.isNotEmpty)
|
||||
? 1.0
|
||||
: 0.0;
|
||||
v[14] = _frequencyScore(client.frequency);
|
||||
v[15] = client.neighbourhood.isNotEmpty ? 1.0 : 0.0;
|
||||
return v;
|
||||
}
|
||||
|
||||
double _frequencyScore(String freq) {
|
||||
switch (freq.toLowerCase()) {
|
||||
case 'daily':
|
||||
return 1.0;
|
||||
case 'weekly':
|
||||
return 0.75;
|
||||
case 'bi-weekly':
|
||||
return 0.5;
|
||||
case 'monthly':
|
||||
return 0.25;
|
||||
default:
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
Client _fromPoint(Map<String, dynamic> point) {
|
||||
final p = point['payload'] as Map<String, dynamic>;
|
||||
return Client(
|
||||
id: p['clientId'] as String,
|
||||
name: p['name'] as String,
|
||||
city: p['city'] as String,
|
||||
businessType:
|
||||
BusinessType.values.firstWhere((b) => b.name == p['businessType']),
|
||||
parcelVolume: (p['parcelVolume'] as num?)?.toDouble() ?? 0.0,
|
||||
activeContracts: p['activeContracts'] as int,
|
||||
provider: p['provider'] as String,
|
||||
efficiency: p['efficiency'] as String,
|
||||
status: ClientStatus.values.firstWhere((s) => s.name == p['status']),
|
||||
lastUpdated: p['lastUpdated'] as String,
|
||||
surveySubmitted: p['surveySubmitted'] as bool,
|
||||
notes: (p['notes'] as String?) ?? '',
|
||||
dataConsent: DataConsent.values.firstWhere(
|
||||
(d) => d.name == (p['dataConsent'] as String? ?? 'full'),
|
||||
orElse: () => DataConsent.full,
|
||||
),
|
||||
phone: (p['phone'] as String?) ?? '',
|
||||
frequency: (p['frequency'] as String?) ?? '',
|
||||
businessState: (p['businessState'] as String?) ?? '',
|
||||
transitType: (p['transitType'] as String?) ?? '',
|
||||
transitFrom: (p['transitFrom'] as String?) ?? '',
|
||||
transitTo: (p['transitTo'] as String?) ?? '',
|
||||
neighbourhood: (p['neighbourhood'] as String?) ?? '',
|
||||
logisticsSegment: (p['logisticsSegment'] as String?) ?? '',
|
||||
pincode: (p['pincode'] as String?) ?? '',
|
||||
surveyLat: (p['surveyLat'] as num?)?.toDouble() ?? 0.0,
|
||||
surveyLng: (p['surveyLng'] as num?)?.toDouble() ?? 0.0,
|
||||
surveyAddress: (p['surveyAddress'] as String?) ?? '',
|
||||
surveyZone: (p['surveyZone'] as String?) ?? '',
|
||||
surveyPincode: (p['surveyPincode'] as String?) ?? '',
|
||||
recordedByName: (p['recordedByName'] as String?) ?? '',
|
||||
recordedByEmail: (p['recordedByEmail'] as String?) ?? '',
|
||||
recordedByRole: (p['recordedByRole'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
361
lib/theme/app_theme.dart
Normal file
361
lib/theme/app_theme.dart
Normal file
@@ -0,0 +1,361 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AppColors {
|
||||
static const primary = Color(0xFF8D0012);
|
||||
static const primaryDark = Color(0xFFB51621);
|
||||
static const darkBlue = Color(0xFF0B1C30);
|
||||
static const lightBg = Color(0xFFF8F9FF);
|
||||
static const border = Color(0xFFE4BEBA);
|
||||
static const textSecondary = Color(0xFF5B403E);
|
||||
static const green = Color(0xFF006760);
|
||||
static const greenDark = Color(0xFF00504A);
|
||||
static const blueAccent = Color(0xFFD3E4FE);
|
||||
static const blueLight = Color(0xFFEFF4FF);
|
||||
static const muted = Color(0xFF8F6F6D);
|
||||
static const surface = Color(0xFFFFFFFF);
|
||||
static const scaffold = Color(0xFFF1F5F9);
|
||||
}
|
||||
|
||||
ThemeData buildAppTheme() {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: AppColors.primary,
|
||||
primary: AppColors.primary,
|
||||
surface: AppColors.lightBg,
|
||||
),
|
||||
scaffoldBackgroundColor: AppColors.scaffold,
|
||||
textTheme: GoogleFonts.interTextTheme().copyWith(
|
||||
displayLarge: GoogleFonts.inter(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
bodyMedium: GoogleFonts.inter(color: AppColors.darkBlue),
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: AppColors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
shadowColor: Colors.black12,
|
||||
scrolledUnderElevation: 1,
|
||||
titleTextStyle: GoogleFonts.inter(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 16,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: AppColors.surface,
|
||||
indicatorColor: AppColors.primaryDark,
|
||||
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||
final active = states.contains(WidgetState.selected);
|
||||
return GoogleFonts.inter(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: active ? AppColors.primary : AppColors.muted,
|
||||
);
|
||||
}),
|
||||
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||
final active = states.contains(WidgetState.selected);
|
||||
return IconThemeData(
|
||||
color: active ? Colors.white : AppColors.muted,
|
||||
size: 18,
|
||||
);
|
||||
}),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class WideTopBanner extends StatefulWidget {
|
||||
final bool backendReady;
|
||||
final String subtitle;
|
||||
final VoidCallback? onLogout;
|
||||
|
||||
const WideTopBanner({
|
||||
super.key,
|
||||
required this.backendReady,
|
||||
required this.subtitle,
|
||||
this.onLogout,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WideTopBanner> createState() => _WideTopBannerState();
|
||||
}
|
||||
|
||||
class _WideTopBannerState extends State<WideTopBanner> {
|
||||
String _title = 'Doormile CRM';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadUser();
|
||||
}
|
||||
|
||||
Future<void> _loadUser() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final name = prefs.getString('user_name');
|
||||
if (name != null && name.isNotEmpty && mounted) {
|
||||
setState(() => _title = 'Hi, $name 👋');
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Colors.white,
|
||||
Color(0xFFF8FAFC),
|
||||
],
|
||||
),
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.darkBlue.withValues(alpha: 0.03),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withValues(alpha: 0.04),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
spreadRadius: -5,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(2.5),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.primary.withValues(alpha: 0.8),
|
||||
AppColors.border,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withValues(alpha: 0.2),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: Image.asset(
|
||||
'assets/doormilelogoround.png',
|
||||
width: 38,
|
||||
height: 38,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.darkBlue,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
widget.subtitle.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.2,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_SyncStatusBadge(ready: widget.backendReady),
|
||||
if (widget.onLogout != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: widget.onLogout,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.border.withValues(alpha: 0.2)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.02),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.logout_rounded,
|
||||
size: 20,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SyncStatusBadge extends StatelessWidget {
|
||||
final bool ready;
|
||||
const _SyncStatusBadge({required this.ready});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: ready ? const Color(0xFFECFDF5) : const Color(0xFFFFFBEB),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: ready ? const Color(0xA110B981) : const Color(0xA1FBBF24),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ready
|
||||
? const Color(0xFF10B981).withValues(alpha: 0.1)
|
||||
: const Color(0xFFF59E0B).withValues(alpha: 0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_PulsingDot(
|
||||
color: ready ? const Color(0xFF10B981) : const Color(0xFFF59E0B),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
ready ? 'SYNCED' : 'CACHED',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: ready ? const Color(0xFF065F46) : const Color(0xFF92400E),
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PulsingDot extends StatefulWidget {
|
||||
final Color color;
|
||||
const _PulsingDot({required this.color});
|
||||
|
||||
@override
|
||||
State<_PulsingDot> createState() => _PulsingDotState();
|
||||
}
|
||||
|
||||
class _PulsingDotState extends State<_PulsingDot> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
_animation = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: widget.color,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: widget.color.withValues(alpha: 0.6 * _animation.value),
|
||||
blurRadius: 6 * _animation.value,
|
||||
spreadRadius: 2 * _animation.value,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
83
lib/utils/snackbar_utils.dart
Normal file
83
lib/utils/snackbar_utils.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
class SnackbarUtils {
|
||||
static void showSuccess(BuildContext context, String message) {
|
||||
_showSnackBar(
|
||||
context,
|
||||
message,
|
||||
icon: Icons.check_circle_outline,
|
||||
backgroundColor: AppColors.greenDark,
|
||||
);
|
||||
}
|
||||
|
||||
static void showError(BuildContext context, String message) {
|
||||
_showSnackBar(
|
||||
context,
|
||||
message,
|
||||
icon: Icons.error_outline,
|
||||
backgroundColor: AppColors.primaryDark,
|
||||
);
|
||||
}
|
||||
|
||||
static void showWarning(BuildContext context, String message) {
|
||||
_showSnackBar(
|
||||
context,
|
||||
message,
|
||||
icon: Icons.warning_amber_rounded,
|
||||
backgroundColor: const Color(0xFFE87C00), // Orange
|
||||
);
|
||||
}
|
||||
|
||||
static void _showSnackBar(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
required IconData icon,
|
||||
required Color backgroundColor,
|
||||
}) {
|
||||
if (!context.mounted) return;
|
||||
|
||||
final snackBar = SnackBar(
|
||||
elevation: 0,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.transparent,
|
||||
padding: EdgeInsets.zero,
|
||||
content: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: backgroundColor.withValues(alpha: 0.3),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: Colors.white, size: 22),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
margin: const EdgeInsets.only(bottom: 24, left: 16, right: 16),
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(snackBar);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user