Initial commit of Doormile CRM Mobile with UI modernizations

This commit is contained in:
2026-06-26 13:43:37 +05:30
commit 3965046984
183 changed files with 18135 additions and 0 deletions

291
lib/app.dart Normal file
View 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
View 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;
}

View 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();

View 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
View 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
View 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
View 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
View 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,
);
}
}

File diff suppressed because it is too large Load Diff

View 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;
}

View 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,
),
),
),
);
}
}

View 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)),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View 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)),
],
);
}
}

View 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,
),
),
),
),
],
),
),
),
);
}
}

View 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;
}
}

View 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,
);
}
}

View 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 {};
}
}
}

View 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 01 over max 1 200)
// [1] activeContracts (normalised 01 over max 40)
// [25] businessType one-hot (ecommerce, manufacturing, wholesale, retail)
// [69] 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
View 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,
),
],
),
);
},
);
}
}

View 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);
}
}