Initial commit of Doormile CRM Mobile with UI modernizations
This commit is contained in:
291
lib/app.dart
Normal file
291
lib/app.dart
Normal file
@@ -0,0 +1,291 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'models/client.dart';
|
||||
import 'models/activity.dart';
|
||||
import 'models/app_stats.dart';
|
||||
import 'screens/dashboard_screen.dart';
|
||||
import 'screens/clients_screen.dart';
|
||||
import 'screens/survey_screen.dart';
|
||||
import 'screens/surveys_list_screen.dart';
|
||||
import 'screens/pricing_screen.dart';
|
||||
import 'services/crm_api_service.dart';
|
||||
import 'services/auth_service.dart';
|
||||
|
||||
class AppShell extends StatefulWidget {
|
||||
final AuthUser currentUser;
|
||||
final VoidCallback onLogout;
|
||||
|
||||
const AppShell({
|
||||
super.key,
|
||||
required this.currentUser,
|
||||
required this.onLogout,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends State<AppShell> {
|
||||
int _currentIndex = 0;
|
||||
bool _backendReady = false;
|
||||
|
||||
List<Client> _clients = [];
|
||||
List<Activity> _activities = [];
|
||||
List<dynamic> _surveys = [];
|
||||
List<dynamic> _pricing = [];
|
||||
AppStats _stats = const AppStats(
|
||||
totalClients: 0,
|
||||
avgVolume: 0,
|
||||
revenueOpportunity: 0,
|
||||
todayEntries: 0,
|
||||
pendingFollowups: 0,
|
||||
);
|
||||
|
||||
final _api = CrmApiService();
|
||||
|
||||
Timer? _pollingTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_api.setToken(widget.currentUser.token);
|
||||
_loadFromBackend();
|
||||
_startPolling();
|
||||
}
|
||||
|
||||
void _startPolling() {
|
||||
_pollingTimer = Timer.periodic(const Duration(seconds: 10), (_) {
|
||||
_loadFromBackend(silent: true);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollingTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadFromBackend({bool silent = false}) async {
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
_api.loadClients(),
|
||||
_api.fetchSurveys(),
|
||||
_api.fetchPricing(),
|
||||
]);
|
||||
final clients = results[0] as List<Client>;
|
||||
final surveys = results[1];
|
||||
final pricing = results[2];
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_clients = clients;
|
||||
_surveys = surveys;
|
||||
_pricing = pricing;
|
||||
_stats = _computeStats(clients);
|
||||
_backendReady = true;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted && !silent) setState(() => _backendReady = false);
|
||||
}
|
||||
}
|
||||
|
||||
AppStats _computeStats(List<Client> clients) {
|
||||
if (clients.isEmpty) {
|
||||
return const AppStats(
|
||||
totalClients: 0,
|
||||
avgVolume: 0,
|
||||
revenueOpportunity: 0,
|
||||
todayEntries: 0,
|
||||
pendingFollowups: 0,
|
||||
);
|
||||
}
|
||||
final totalVolume = clients.fold(0.0, (acc, c) => acc + c.parcelVolume);
|
||||
final avg = (totalVolume / clients.length).round();
|
||||
return AppStats(
|
||||
totalClients: clients.length,
|
||||
avgVolume: avg,
|
||||
revenueOpportunity: double.parse((totalVolume * 0.01).toStringAsFixed(2)),
|
||||
todayEntries: 0,
|
||||
pendingFollowups: clients.where((c) => c.dataConsent == DataConsent.basicOnly).length,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleAddClient(Client incoming) async {
|
||||
final newClient = incoming.copyWith(
|
||||
recordedByName: widget.currentUser.name,
|
||||
recordedByEmail: widget.currentUser.email,
|
||||
recordedByRole: widget.currentUser.role,
|
||||
);
|
||||
|
||||
// Optimistic UI update with a temp ID
|
||||
setState(() {
|
||||
_clients = [newClient, ..._clients];
|
||||
_activities = [
|
||||
Activity(
|
||||
id: 'act_${DateTime.now().millisecondsSinceEpoch}',
|
||||
clientName: newClient.name,
|
||||
action: 'Survey submitted just now',
|
||||
icon: ActivityIcon.shipping,
|
||||
status: ActivityStatus.newStatus,
|
||||
),
|
||||
..._activities,
|
||||
];
|
||||
_stats = _computeStats(_clients);
|
||||
});
|
||||
|
||||
// Persist to backend and swap temp ID with server-assigned ID
|
||||
final saved = await _api.createClient(newClient);
|
||||
if (saved != null && mounted) {
|
||||
setState(() {
|
||||
_clients = _clients
|
||||
.map((c) => c.id == newClient.id ? saved : c)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleUpdateNotes(String clientId, String newNotes) async {
|
||||
setState(() {
|
||||
_clients = _clients.map((c) {
|
||||
if (c.id == clientId) {
|
||||
return c.copyWith(notes: newNotes, lastUpdated: 'Updated just now');
|
||||
}
|
||||
return c;
|
||||
}).toList();
|
||||
|
||||
final updated = _clients.firstWhere((c) => c.id == clientId);
|
||||
_activities = [
|
||||
Activity(
|
||||
id: 'act_${DateTime.now().millisecondsSinceEpoch}',
|
||||
clientName: updated.name,
|
||||
action: 'Profile updated by Rep',
|
||||
icon: ActivityIcon.edit,
|
||||
),
|
||||
..._activities,
|
||||
];
|
||||
});
|
||||
|
||||
final updated = _clients.firstWhere((c) => c.id == clientId);
|
||||
await _api.updateClient(updated);
|
||||
}
|
||||
|
||||
void _handleCompleteClientProfile(
|
||||
String clientId,
|
||||
double parcelVolume,
|
||||
int activeContracts,
|
||||
String provider,
|
||||
String notes,
|
||||
) async {
|
||||
String efficiency = 'High Efficiency';
|
||||
if (provider == 'Blue Dart Express') efficiency = 'Premium Service';
|
||||
if (provider == 'Gati-KWE') efficiency = 'Cost Leader';
|
||||
if (provider == 'Delhivery') efficiency = 'Local Last Mile';
|
||||
|
||||
setState(() {
|
||||
_clients = _clients.map((c) {
|
||||
if (c.id != clientId) return c;
|
||||
return c.copyWith(
|
||||
parcelVolume: parcelVolume,
|
||||
activeContracts: activeContracts,
|
||||
provider: provider,
|
||||
efficiency: efficiency,
|
||||
notes: notes,
|
||||
dataConsent: DataConsent.full,
|
||||
lastUpdated: 'Just now',
|
||||
surveySubmitted: true,
|
||||
recordedByName: widget.currentUser.name,
|
||||
recordedByEmail: widget.currentUser.email,
|
||||
recordedByRole: widget.currentUser.role,
|
||||
);
|
||||
}).toList();
|
||||
_stats = _computeStats(_clients);
|
||||
});
|
||||
|
||||
final updated = _clients.firstWhere((c) => c.id == clientId);
|
||||
await _api.updateClient(updated);
|
||||
}
|
||||
|
||||
void _navigateTo(int index) {
|
||||
if (index == 4) {
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (ctx) => Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
iconTheme: const IconThemeData(color: Colors.black87),
|
||||
title: const Text('Add Client', style: TextStyle(color: Colors.black87, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
body: SurveyScreen(
|
||||
clients: _clients,
|
||||
onAddClient: _handleAddClient,
|
||||
onNavigate: (i) {
|
||||
Navigator.pop(ctx);
|
||||
_navigateTo(i);
|
||||
},
|
||||
backendReady: _backendReady,
|
||||
isActive: true,
|
||||
),
|
||||
),
|
||||
));
|
||||
return;
|
||||
}
|
||||
setState(() => _currentIndex = index);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screens = [
|
||||
DashboardScreen(
|
||||
stats: _stats,
|
||||
activities: _activities,
|
||||
clients: _clients,
|
||||
surveys: _surveys,
|
||||
pricing: _pricing,
|
||||
onNavigate: _navigateTo,
|
||||
onDataChanged: _loadFromBackend,
|
||||
backendReady: _backendReady,
|
||||
onLogout: widget.onLogout,
|
||||
isActive: _currentIndex == 0,
|
||||
),
|
||||
ClientsScreen(
|
||||
clients: _clients,
|
||||
onUpdateClientNotes: _handleUpdateNotes,
|
||||
onCompleteClientProfile: _handleCompleteClientProfile,
|
||||
onNavigate: _navigateTo,
|
||||
backendReady: _backendReady,
|
||||
isActive: _currentIndex == 1,
|
||||
),
|
||||
SurveysListScreen(
|
||||
surveys: _surveys,
|
||||
onDataChanged: _loadFromBackend,
|
||||
isActive: _currentIndex == 2,
|
||||
),
|
||||
PricingScreen(
|
||||
pricing: _pricing,
|
||||
backendReady: _backendReady,
|
||||
onDataChanged: _loadFromBackend,
|
||||
isActive: _currentIndex == 3,
|
||||
),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: IndexedStack(
|
||||
index: _currentIndex,
|
||||
children: screens,
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: _navigateTo,
|
||||
height: 68,
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.dashboard_outlined), selectedIcon: Icon(Icons.dashboard), label: 'Home'),
|
||||
NavigationDestination(icon: Icon(Icons.people_outline), selectedIcon: Icon(Icons.people), label: 'Clients'),
|
||||
NavigationDestination(icon: Icon(Icons.storefront_outlined), selectedIcon: Icon(Icons.storefront), label: 'Surveys'),
|
||||
NavigationDestination(icon: Icon(Icons.local_shipping_outlined), selectedIcon: Icon(Icons.local_shipping), label: 'Pricing'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user