This commit is contained in:
2026-10-01 16:03:32 +05:30
commit 3db47c4648
102 changed files with 5067 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
/// Hosted API. Override with --dart-define=API_BASE_URL=...
String resolveApiBaseUrl() {
const fromEnv = String.fromEnvironment('API_BASE_URL');
if (fromEnv.isNotEmpty) return fromEnv;
return 'https://snrodealer.playpoolstudios.com/api';
}
+113
View File
@@ -0,0 +1,113 @@
const stageOrder = [
'received',
'orderedByCustomer',
'documentsProcessed',
'paymentApproved',
'rmvRegistration',
'plateReceived',
];
const modelOptions = ['gn125', 'boxer125', 'click150i'];
const sriLankaDistricts = [
'Ampara',
'Anuradhapura',
'Badulla',
'Batticaloa',
'Colombo',
'Galle',
'Gampaha',
'Hambantota',
'Jaffna',
'Kalutara',
'Kandy',
'Kegalle',
'Kilinochchi',
'Kurunegala',
'Mannar',
'Matale',
'Matara',
'Moneragala',
'Mullaitivu',
'Nuwara Eliya',
'Polonnaruwa',
'Puttalam',
'Ratnapura',
'Trincomalee',
'Vavuniya',
];
String stageLabel(String stage) {
switch (stage) {
case 'received':
return 'Received';
case 'orderedByCustomer':
return 'Ordered by customer';
case 'documentsProcessed':
return 'Documents processed';
case 'paymentApproved':
return 'Payment approved';
case 'rmvRegistration':
return 'RMV registration';
case 'plateReceived':
return 'Plate received';
default:
return stage;
}
}
String? nextStage(String stage) {
final index = stageOrder.indexOf(stage);
if (index < 0 || index >= stageOrder.length - 1) return null;
return stageOrder[index + 1];
}
String modelLabel(String model) {
switch (model) {
case 'gn125':
return 'GN125';
case 'boxer125':
return 'Boxer 125';
case 'click150i':
return 'Click 150i';
default:
return model;
}
}
String roleLabel(String role) {
switch (role) {
case 'superAdmin':
return 'Super admin';
case 'salesRep':
return 'Sales rep';
case 'dealer':
return 'Dealer';
default:
return role;
}
}
String leaseProviderLabel(String provider) {
switch (provider) {
case 'gci':
return 'GCI';
case 'thirdPartyFinance':
return 'Finance';
case 'both':
return 'GCI and finance';
default:
return provider;
}
}
String planLabel(String planType) {
switch (planType) {
case 'monthlySpecial':
return 'Monthly special';
case 'general':
return 'General';
default:
return planType;
}
}
+41
View File
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'screens/auth_flow.dart';
import 'screens/home_shell.dart';
import 'screens/splash_screen.dart';
import 'services/auth_controller.dart';
import 'theme/app_theme.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const DealerNetworkApp());
}
class DealerNetworkApp extends StatelessWidget {
const DealerNetworkApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => AuthController()..bootstrap(),
child: MaterialApp(
title: 'Dealer Network',
theme: buildAppTheme(),
home: const _Root(),
),
);
}
}
class _Root extends StatelessWidget {
const _Root();
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthController>();
if (!auth.ready) return const SplashScreen();
if (auth.user == null) return const AuthFlow();
return const HomeShell();
}
}
+252
View File
@@ -0,0 +1,252 @@
class Dealer {
Dealer({
required this.id,
required this.name,
required this.code,
this.district,
this.address,
this.phone,
});
final String id;
final String name;
final String code;
final String? district;
final String? address;
final String? phone;
factory Dealer.fromJson(Map<String, dynamic> json) {
return Dealer(
id: json['id'] as String,
name: json['name'] as String,
code: json['code'] as String,
district: json['district'] as String?,
address: json['address'] as String?,
phone: json['phone'] as String?,
);
}
}
class AppUser {
AppUser({
required this.id,
required this.name,
required this.phone,
required this.role,
this.dealerId,
this.assignedDealerIds = const [],
this.dealers = const [],
});
final String id;
final String name;
final String phone;
final String role;
final String? dealerId;
final List<String> assignedDealerIds;
final List<Dealer> dealers;
factory AppUser.fromJson(Map<String, dynamic> json) {
return AppUser(
id: json['id'] as String,
name: json['name'] as String,
phone: json['phone'] as String,
role: json['role'] as String,
dealerId: json['dealerId'] as String?,
assignedDealerIds: ((json['assignedDealerIds'] as List?) ?? const [])
.map((id) => id as String)
.toList(),
dealers: ((json['dealers'] as List?) ?? const [])
.map((item) => Dealer.fromJson(item as Map<String, dynamic>))
.toList(),
);
}
}
class LoginRequestItem {
LoginRequestItem({
required this.id,
required this.name,
required this.phone,
this.dealerName,
required this.createdAt,
});
final String id;
final String name;
final String phone;
final String? dealerName;
final DateTime createdAt;
factory LoginRequestItem.fromJson(Map<String, dynamic> json) {
final user = json['user'] as Map<String, dynamic>;
return LoginRequestItem(
id: json['id'] as String,
name: user['name'] as String,
phone: user['phone'] as String,
dealerName: json['dealerName'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
}
class DealerLoginStatus {
DealerLoginStatus({required this.status, this.token, this.user});
final String status;
final String? token;
final AppUser? user;
factory DealerLoginStatus.fromJson(Map<String, dynamic> json) {
return DealerLoginStatus(
status: json['status'] as String,
token: json['token'] as String?,
user: json['user'] is Map<String, dynamic>
? AppUser.fromJson(json['user'] as Map<String, dynamic>)
: null,
);
}
}
class StockItem {
StockItem({
required this.id,
required this.chassisNumber,
required this.engineNumber,
required this.model,
required this.color,
required this.currentDealerId,
this.dealerName,
required this.dateReceived,
required this.currentStage,
required this.stageLogDates,
this.paymentType,
this.leaseProviderType,
this.financeCompanyName,
this.plateNumber,
required this.ageInDays,
required this.badge,
});
final String id;
final String chassisNumber;
final String engineNumber;
final String model;
final String color;
final String currentDealerId;
final String? dealerName;
final DateTime dateReceived;
final String currentStage;
final Map<String, DateTime> stageLogDates;
final String? paymentType;
final String? leaseProviderType;
final String? financeCompanyName;
final String? plateNumber;
final int ageInDays;
final String badge;
factory StockItem.fromJson(Map<String, dynamic> json) {
final rawLogs = json['stageLogDates'];
final logs = <String, DateTime>{};
if (rawLogs is Map) {
rawLogs.forEach((key, value) {
if (value != null) {
logs[key.toString()] = DateTime.parse(value.toString());
}
});
}
return StockItem(
id: json['id'] as String,
chassisNumber: json['chassisNumber'] as String,
engineNumber: json['engineNumber'] as String,
model: json['model'] as String,
color: json['color'] as String,
currentDealerId: json['currentDealerId'] as String,
dealerName: json['dealerName'] as String?,
dateReceived: DateTime.parse(json['dateReceived'] as String),
currentStage: json['currentStage'] as String,
stageLogDates: logs,
paymentType: json['paymentType'] as String?,
leaseProviderType: json['leaseProviderType'] as String?,
financeCompanyName: json['financeCompanyName'] as String?,
plateNumber: json['plateNumber'] as String?,
ageInDays: (json['ageInDays'] as num).toInt(),
badge: json['badge'] as String,
);
}
}
class EventPlan {
EventPlan({
required this.id,
required this.dealerId,
this.dealerName,
required this.planType,
this.month,
this.year,
required this.title,
required this.description,
required this.expense,
required this.imageUrls,
required this.createdAt,
});
final String id;
final String dealerId;
final String? dealerName;
final String planType;
final int? month;
final int? year;
final String title;
final String description;
final double expense;
final List<String> imageUrls;
final DateTime createdAt;
factory EventPlan.fromJson(Map<String, dynamic> json) {
return EventPlan(
id: json['id'] as String,
dealerId: json['dealerId'] as String,
dealerName: json['dealerName'] as String?,
planType: json['planType'] as String,
month: json['month'] as int?,
year: json['year'] as int?,
title: json['title'] as String,
description: json['description'] as String,
expense: (json['expense'] as num).toDouble(),
imageUrls: ((json['imageUrls'] as List?) ?? const [])
.map((url) => url as String)
.toList(),
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
}
class ServicePoint {
ServicePoint({
required this.id,
required this.name,
required this.district,
this.address,
this.phone,
this.notes,
});
final String id;
final String name;
final String district;
final String? address;
final String? phone;
final String? notes;
factory ServicePoint.fromJson(Map<String, dynamic> json) {
return ServicePoint(
id: json['id'] as String,
name: json['name'] as String,
district: json['district'] as String,
address: json['address'] as String?,
phone: json['phone'] as String?,
notes: json['notes'] as String?,
);
}
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../labels.dart';
import '../services/auth_controller.dart';
import '../ui/feedback.dart';
class AddDealerScreen extends StatefulWidget {
const AddDealerScreen({super.key});
@override
State<AddDealerScreen> createState() => _AddDealerScreenState();
}
class _AddDealerScreenState extends State<AddDealerScreen> {
final _name = TextEditingController();
final _code = TextEditingController();
final _address = TextEditingController();
final _phone = TextEditingController();
String? _district;
bool _saving = false;
@override
void dispose() {
_name.dispose();
_code.dispose();
_address.dispose();
_phone.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() => _saving = true);
try {
await context.read<AuthController>().api.createDealer({
'name': _name.text.trim(),
'code': _code.text.trim(),
'district': _district ?? '',
'address': _address.text.trim(),
'phone': _phone.text.trim(),
});
if (!mounted) return;
showNotice(context, 'Dealer and login created');
Navigator.pop(context, true);
} catch (error) {
if (mounted) showError(context, error);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Add dealer')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(controller: _name, decoration: const InputDecoration(labelText: 'Name')),
const SizedBox(height: 12),
TextField(controller: _code, decoration: const InputDecoration(labelText: 'Code')),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _district,
isExpanded: true,
decoration: const InputDecoration(labelText: 'District'),
items: [
for (final district in sriLankaDistricts)
DropdownMenuItem(value: district, child: Text(district)),
],
onChanged: (value) => setState(() => _district = value),
),
const SizedBox(height: 12),
TextField(controller: _address, decoration: const InputDecoration(labelText: 'Address')),
const SizedBox(height: 12),
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: 'Contact phone',
hintText: '0715127342',
helperText: 'This number becomes the dealer login',
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: _saving ? null : _save,
child: Text(_saving ? 'Saving' : 'Create dealer'),
),
],
),
);
}
}
+387
View File
@@ -0,0 +1,387 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../labels.dart';
import '../models/models.dart';
import '../services/auth_controller.dart';
import '../ui/feedback.dart';
class AdminScreen extends StatelessWidget {
const AdminScreen({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Column(
children: [
const TabBar(
tabs: [
Tab(text: 'Dealers'),
Tab(text: 'Sales reps'),
],
),
const Expanded(
child: TabBarView(
children: [
_DealersTab(),
_RepsTab(),
],
),
),
],
),
);
}
}
class _DealersTab extends StatefulWidget {
const _DealersTab();
@override
State<_DealersTab> createState() => _DealersTabState();
}
class _DealersTabState extends State<_DealersTab> {
final _name = TextEditingController();
final _code = TextEditingController();
final _address = TextEditingController();
final _phone = TextEditingController();
String? _district;
int _districtField = 0;
List<Dealer> _dealers = [];
bool _loading = true;
bool _saving = false;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_name.dispose();
_code.dispose();
_address.dispose();
_phone.dispose();
super.dispose();
}
Future<void> _load() async {
try {
final dealers = await context.read<AuthController>().api.adminDealers();
if (!mounted) return;
setState(() {
_dealers = dealers;
_loading = false;
});
} catch (error) {
if (!mounted) return;
setState(() => _loading = false);
showError(context, error);
}
}
Future<void> _delete(Dealer dealer) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete dealer'),
content: Text(
'Delete ${dealer.name}? Its login, stock, and plans will be removed.',
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete')),
],
),
);
if (confirmed != true || !mounted) return;
try {
await context.read<AuthController>().api.deleteDealer(dealer.id);
await _load();
} catch (error) {
if (mounted) showError(context, error);
}
}
Future<void> _save() async {
setState(() => _saving = true);
try {
await context.read<AuthController>().api.createDealer({
'name': _name.text.trim(),
'code': _code.text.trim(),
'district': _district ?? '',
'address': _address.text.trim(),
'phone': _phone.text.trim(),
});
_name.clear();
_code.clear();
_address.clear();
_phone.clear();
_district = null;
_districtField++;
await _load();
} catch (error) {
if (mounted) showError(context, error);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
if (_loading) return const Center(child: CircularProgressIndicator());
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text('New dealer', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
TextField(controller: _name, decoration: const InputDecoration(labelText: 'Name')),
const SizedBox(height: 12),
TextField(controller: _code, decoration: const InputDecoration(labelText: 'Code')),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
key: ValueKey(_districtField),
initialValue: _district,
isExpanded: true,
decoration: const InputDecoration(labelText: 'District'),
items: [
for (final district in sriLankaDistricts)
DropdownMenuItem(value: district, child: Text(district)),
],
onChanged: (value) => setState(() => _district = value),
),
const SizedBox(height: 12),
TextField(controller: _address, decoration: const InputDecoration(labelText: 'Address')),
const SizedBox(height: 12),
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: 'Contact phone',
hintText: '0715127342',
helperText: 'This number becomes the dealer login',
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _saving ? null : _save,
child: Text(_saving ? 'Saving' : 'Create dealer'),
),
const SizedBox(height: 20),
for (final dealer in _dealers)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(dealer.name),
subtitle: Text('${dealer.code}${dealer.district == null ? '' : ' · ${dealer.district}'}'),
trailing: IconButton(
tooltip: 'Delete',
onPressed: () => _delete(dealer),
icon: const Icon(Icons.delete_outline),
),
),
],
);
}
}
class _RepsTab extends StatefulWidget {
const _RepsTab();
@override
State<_RepsTab> createState() => _RepsTabState();
}
class _RepsTabState extends State<_RepsTab> {
final _name = TextEditingController();
final _phone = TextEditingController();
final _password = TextEditingController();
List<Dealer> _dealers = [];
List<AppUser> _reps = [];
final Set<String> _selected = {};
bool _loading = true;
bool _saving = false;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_name.dispose();
_phone.dispose();
_password.dispose();
super.dispose();
}
Future<void> _load() async {
final api = context.read<AuthController>().api;
try {
final results = await Future.wait([api.adminDealers(), api.salesReps()]);
if (!mounted) return;
setState(() {
_dealers = results[0] as List<Dealer>;
_reps = results[1] as List<AppUser>;
_loading = false;
});
} catch (error) {
if (!mounted) return;
setState(() => _loading = false);
showError(context, error);
}
}
Future<void> _save() async {
setState(() => _saving = true);
try {
await context.read<AuthController>().api.createSalesRep({
'name': _name.text.trim(),
'phone': _phone.text.trim(),
'password': _password.text,
'dealerIds': _selected.toList(),
});
_name.clear();
_phone.clear();
_password.clear();
_selected.clear();
await _load();
} catch (error) {
if (mounted) showError(context, error);
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _changeAssignment(AppUser rep, {required bool assign}) async {
final choices = _dealers.where((dealer) {
final has = rep.assignedDealerIds.contains(dealer.id);
return assign ? !has : has;
}).toList();
if (choices.isEmpty) {
showError(context, assign ? 'Every dealer is already assigned' : 'No dealers to remove');
return;
}
String? dealerId = choices.first.id;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(assign ? 'Assign dealer' : 'Remove dealer'),
content: DropdownButtonFormField<String>(
initialValue: dealerId,
items: [
for (final dealer in choices)
DropdownMenuItem(value: dealer.id, child: Text(dealer.name)),
],
onChanged: (value) => dealerId = value,
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Save')),
],
),
);
if (confirmed != true || dealerId == null || !mounted) return;
try {
await context.read<AuthController>().api.assignDealer(
salesRepId: rep.id,
dealerId: dealerId!,
assigned: assign,
);
await _load();
} catch (error) {
if (mounted) showError(context, error);
}
}
String _dealerNames(AppUser rep) {
if (rep.assignedDealerIds.isEmpty) return 'No dealers assigned';
return rep.assignedDealerIds
.map((id) {
for (final dealer in _dealers) {
if (dealer.id == id) return dealer.name;
}
return id;
})
.join(', ');
}
@override
Widget build(BuildContext context) {
if (_loading) return const Center(child: CircularProgressIndicator());
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text('New sales rep', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
TextField(controller: _name, decoration: const InputDecoration(labelText: 'Name')),
const SizedBox(height: 12),
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(labelText: 'Phone', hintText: '0715127342'),
),
const SizedBox(height: 12),
TextField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(labelText: 'Password'),
),
const SizedBox(height: 8),
for (final dealer in _dealers)
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: Text(dealer.name),
value: _selected.contains(dealer.id),
onChanged: (checked) {
setState(() {
if (checked == true) {
_selected.add(dealer.id);
} else {
_selected.remove(dealer.id);
}
});
},
),
FilledButton(
onPressed: _saving ? null : _save,
child: Text(_saving ? 'Saving' : 'Create sales rep'),
),
const SizedBox(height: 20),
for (final rep in _reps)
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(rep.name, style: Theme.of(context).textTheme.titleMedium),
Text(rep.phone),
const SizedBox(height: 4),
Text(_dealerNames(rep)),
Row(
children: [
TextButton(
onPressed: () => _changeAssignment(rep, assign: true),
child: const Text('Assign'),
),
TextButton(
onPressed: () => _changeAssignment(rep, assign: false),
child: const Text('Remove'),
),
],
),
],
),
),
),
],
);
}
}
+177
View File
@@ -0,0 +1,177 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/auth_controller.dart';
class AuthFlow extends StatefulWidget {
const AuthFlow({super.key});
@override
State<AuthFlow> createState() => _AuthFlowState();
}
class _AuthFlowState extends State<AuthFlow> {
final _phone = TextEditingController();
final _password = TextEditingController();
bool _staff = false;
bool _waiting = false;
Timer? _poll;
@override
void dispose() {
_poll?.cancel();
_phone.dispose();
_password.dispose();
super.dispose();
}
Future<void> _staffLogin() async {
final auth = context.read<AuthController>();
try {
await auth.staffLogin(_phone.text.trim(), _password.text);
} catch (_) {}
}
Future<void> _dealerLogin() async {
final auth = context.read<AuthController>();
try {
final started = await auth.startDealerLogin(_phone.text.trim());
if (!mounted) return;
setState(() => _waiting = true);
_poll = Timer.periodic(
const Duration(seconds: 2),
(_) => _check(started.requestId, started.secret),
);
await _check(started.requestId, started.secret);
} catch (_) {}
}
Future<void> _check(String requestId, String secret) async {
final auth = context.read<AuthController>();
try {
final status = await auth.pollDealerLogin(requestId, secret);
if (!mounted) return;
if (status == 'pending') return;
_poll?.cancel();
if (status != 'approved') setState(() => _waiting = false);
} catch (_) {
if (!mounted) return;
_poll?.cancel();
setState(() => _waiting = false);
}
}
void _cancelWait() {
_poll?.cancel();
context.read<AuthController>().error = null;
setState(() => _waiting = false);
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthController>();
final theme = Theme.of(context);
return Scaffold(
body: SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: ListView(
padding: const EdgeInsets.fromLTRB(24, 48, 24, 24),
children: [
Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
'assets/images/senaro_logo.png',
width: 300,
semanticLabel: 'Senaro',
),
),
),
const SizedBox(height: 20),
Text(
'Dealer Network',
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
_waiting
? 'Waiting for a sales rep or super admin to approve ${_phone.text.trim()}.'
: _staff
? 'Staff sign in with the phone number and password on the account.'
: 'Dealers sign in with the phone number already registered for the dealership.',
style: theme.textTheme.bodyLarge,
),
const SizedBox(height: 28),
if (_waiting)
const Center(child: CircularProgressIndicator())
else ...[
SegmentedButton<bool>(
segments: const [
ButtonSegment(value: false, label: Text('Dealer')),
ButtonSegment(value: true, label: Text('Staff')),
],
selected: {_staff},
onSelectionChanged: auth.busy
? null
: (value) => setState(() => _staff = value.first),
),
const SizedBox(height: 16),
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: 'Phone',
hintText: '071xxxxxxx',
),
onSubmitted: (_) => _staff ? _staffLogin() : _dealerLogin(),
),
if (_staff) ...[
const SizedBox(height: 12),
TextField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(labelText: 'Password'),
onSubmitted: (_) => _staffLogin(),
),
],
],
if (auth.error != null) ...[
const SizedBox(height: 12),
Text(
auth.error!,
style: TextStyle(color: theme.colorScheme.error),
),
],
const SizedBox(height: 20),
if (_waiting)
TextButton(
onPressed: _cancelWait,
child: const Text('Cancel'),
)
else
FilledButton(
onPressed: auth.busy
? null
: (_staff ? _staffLogin : _dealerLogin),
child: Text(
auth.busy
? 'Please wait'
: (_staff ? 'Sign in' : 'Request login'),
),
),
],
),
),
),
),
);
}
}
+112
View File
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../labels.dart';
import '../models/models.dart';
import '../services/auth_controller.dart';
import '../theme/app_theme.dart';
import '../ui/feedback.dart';
import 'stock_detail_screen.dart';
import 'stock_form_screen.dart';
class DealerStockScreen extends StatefulWidget {
const DealerStockScreen({super.key, required this.dealer, required this.dealers});
final Dealer dealer;
final List<Dealer> dealers;
@override
State<DealerStockScreen> createState() => _DealerStockScreenState();
}
class _DealerStockScreenState extends State<DealerStockScreen> {
List<StockItem>? _stocks;
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final stocks = await context.read<AuthController>().api.stocks(dealerId: widget.dealer.id);
if (!mounted) return;
setState(() {
_stocks = stocks;
_loading = false;
});
} catch (error) {
if (!mounted) return;
setState(() => _loading = false);
showError(context, error);
}
}
@override
Widget build(BuildContext context) {
final role = context.watch<AuthController>().user?.role;
final stocks = _stocks ?? [];
final canAdd = role == 'dealer' || role == 'salesRep' || role == 'superAdmin';
return Scaffold(
appBar: AppBar(title: Text(widget.dealer.name)),
floatingActionButton: canAdd
? FloatingActionButton.extended(
onPressed: () async {
final created = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => StockFormScreen(dealers: [widget.dealer]),
),
);
if (created == true) _load();
},
icon: const Icon(Icons.add),
label: const Text('Add stock'),
)
: null,
body: _loading && _stocks == null
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 96),
children: [
if (stocks.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: Text('No bikes for this dealer.')),
),
for (final stock in stocks)
Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: badgeColor(stock.badge),
child: Text(
'${stock.ageInDays}',
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
title: Text('${modelLabel(stock.model)} · ${stock.color}'),
subtitle: Text(
'${stock.chassisNumber}${stock.plateNumber == null ? '' : ' · ${stock.plateNumber}'}\n${stageLabel(stock.currentStage)}',
),
isThreeLine: true,
trailing: Text('${stock.ageInDays}d'),
onTap: () async {
await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => StockDetailScreen(stock: stock, dealers: widget.dealers),
),
);
if (mounted) _load();
},
),
),
],
),
),
);
}
}
+189
View File
@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/models.dart';
import '../services/auth_controller.dart';
import '../ui/feedback.dart';
class EventFormScreen extends StatefulWidget {
const EventFormScreen({super.key});
@override
State<EventFormScreen> createState() => _EventFormScreenState();
}
class _EventFormScreenState extends State<EventFormScreen> {
final _title = TextEditingController();
final _description = TextEditingController();
final _expense = TextEditingController();
final _urls = TextEditingController();
String _planType = 'general';
int _month = DateTime.now().month;
int _year = DateTime.now().year;
String? _dealerId;
List<Dealer> _dealers = [];
bool _loading = true;
bool _saving = false;
@override
void initState() {
super.initState();
_prepare();
}
Future<void> _prepare() async {
final user = context.read<AuthController>().user;
if (user?.role == 'dealer') {
setState(() {
_dealerId = user?.dealerId;
_loading = false;
});
return;
}
try {
final dealers = await context.read<AuthController>().api.dealers();
if (!mounted) return;
setState(() {
_dealers = dealers;
if (dealers.length == 1) _dealerId = dealers.first.id;
_loading = false;
});
} catch (error) {
if (!mounted) return;
setState(() => _loading = false);
showError(context, error);
}
}
@override
void dispose() {
_title.dispose();
_description.dispose();
_expense.dispose();
_urls.dispose();
super.dispose();
}
Future<void> _save() async {
final expense = double.tryParse(_expense.text.trim());
if (_dealerId == null || expense == null) {
showError(context, 'Dealer and a numeric expense are required');
return;
}
final urls = _urls.text
.split('\n')
.map((line) => line.trim())
.where((line) => line.isNotEmpty)
.toList();
setState(() => _saving = true);
try {
await context.read<AuthController>().api.createEvent({
'dealerId': _dealerId,
'planType': _planType,
'title': _title.text.trim(),
'description': _description.text.trim(),
'expense': expense,
'imageUrls': urls,
if (_planType == 'monthlySpecial') 'month': _month,
if (_planType == 'monthlySpecial') 'year': _year,
});
if (mounted) Navigator.pop(context, true);
} catch (error) {
if (mounted) showError(context, error);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final isDealer = context.watch<AuthController>().user?.role == 'dealer';
return Scaffold(
appBar: AppBar(title: const Text('New plan')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'general', label: Text('General')),
ButtonSegment(value: 'monthlySpecial', label: Text('Monthly')),
],
selected: {_planType},
onSelectionChanged: (value) => setState(() => _planType = value.first),
),
const SizedBox(height: 12),
TextField(controller: _title, decoration: const InputDecoration(labelText: 'Title')),
const SizedBox(height: 12),
TextField(
controller: _description,
minLines: 3,
maxLines: 5,
decoration: const InputDecoration(labelText: 'Description'),
),
const SizedBox(height: 12),
TextField(
controller: _expense,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Expense'),
),
const SizedBox(height: 12),
TextField(
controller: _urls,
minLines: 2,
maxLines: 4,
decoration: const InputDecoration(
labelText: 'Image URLs',
hintText: 'One URL per line',
),
),
if (!isDealer) ...[
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _dealerId,
decoration: const InputDecoration(labelText: 'Dealer'),
items: [
for (final dealer in _dealers)
DropdownMenuItem(value: dealer.id, child: Text('${dealer.name} (${dealer.code})')),
],
onChanged: (value) => setState(() => _dealerId = value),
),
],
if (_planType == 'monthlySpecial') ...[
const SizedBox(height: 12),
DropdownButtonFormField<int>(
initialValue: _month,
decoration: const InputDecoration(labelText: 'Month'),
items: [
for (var month = 1; month <= 12; month++)
DropdownMenuItem(value: month, child: Text('$month')),
],
onChanged: (value) {
if (value != null) setState(() => _month = value);
},
),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
initialValue: _year,
decoration: const InputDecoration(labelText: 'Year'),
items: [
for (var year = DateTime.now().year - 1; year <= DateTime.now().year + 2; year++)
DropdownMenuItem(value: year, child: Text('$year')),
],
onChanged: (value) {
if (value != null) setState(() => _year = value);
},
),
],
const SizedBox(height: 16),
FilledButton(
onPressed: _saving ? null : _save,
child: Text(_saving ? 'Saving' : 'Save plan'),
),
],
),
);
}
}
+119
View File
@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../labels.dart';
import '../models/models.dart';
import '../services/auth_controller.dart';
import '../ui/feedback.dart';
import 'event_form_screen.dart';
class EventsScreen extends StatefulWidget {
const EventsScreen({super.key});
@override
State<EventsScreen> createState() => _EventsScreenState();
}
class _EventsScreenState extends State<EventsScreen> {
List<EventPlan>? _events;
String? _planType;
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final events = await context.read<AuthController>().api.events(planType: _planType);
if (!mounted) return;
setState(() {
_events = events;
_loading = false;
});
} catch (error) {
if (!mounted) return;
setState(() => _loading = false);
showError(context, error);
}
}
@override
Widget build(BuildContext context) {
final events = _events ?? [];
if (_loading && _events == null) {
return const Center(child: CircularProgressIndicator());
}
return Scaffold(
floatingActionButton: FloatingActionButton.extended(
onPressed: () async {
final created = await Navigator.of(context).push<bool>(
MaterialPageRoute(builder: (_) => const EventFormScreen()),
);
if (created == true) _load();
},
icon: const Icon(Icons.add),
label: const Text('New plan'),
),
body: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 96),
children: [
Wrap(
spacing: 8,
children: [
ChoiceChip(
label: const Text('All'),
selected: _planType == null,
onSelected: (_) {
setState(() => _planType = null);
_load();
},
),
ChoiceChip(
label: const Text('General'),
selected: _planType == 'general',
onSelected: (_) {
setState(() => _planType = 'general');
_load();
},
),
ChoiceChip(
label: const Text('Monthly'),
selected: _planType == 'monthlySpecial',
onSelected: (_) {
setState(() => _planType = 'monthlySpecial');
_load();
},
),
],
),
const SizedBox(height: 12),
if (events.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: Text('No plans yet.')),
),
for (final event in events)
Card(
child: ListTile(
title: Text(event.title),
subtitle: Text(
'${planLabel(event.planType)}'
'${event.month != null ? ' · ${event.month}/${event.year}' : ''}'
'\n${event.dealerName ?? ''}\n${event.description}',
),
isThreeLine: true,
trailing: Text(event.expense.toStringAsFixed(0)),
),
),
],
),
),
);
}
}
+156
View File
@@ -0,0 +1,156 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../labels.dart';
import '../services/auth_controller.dart';
import 'add_dealer_screen.dart';
import 'admin_screen.dart';
import 'events_screen.dart';
import 'login_requests_screen.dart';
import 'service_points_screen.dart';
import 'stock_screen.dart';
class HomeShell extends StatefulWidget {
const HomeShell({super.key});
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
int _index = 0;
int _pendingLogins = 0;
int _stockVersion = 0;
Timer? _pendingTimer;
@override
void initState() {
super.initState();
_refreshPending();
_pendingTimer = Timer.periodic(const Duration(seconds: 8), (_) => _refreshPending());
}
@override
void dispose() {
_pendingTimer?.cancel();
super.dispose();
}
Future<void> _refreshPending() async {
final role = context.read<AuthController>().user?.role;
if (role != 'superAdmin' && role != 'salesRep') return;
try {
final requests = await context.read<AuthController>().api.loginRequests();
if (!mounted) return;
setState(() => _pendingLogins = requests.length);
} catch (_) {}
}
Future<void> _logout() async {
final auth = context.read<AuthController>();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Log out'),
content: Text('Remove the saved login for ${auth.user?.name ?? 'this account'}?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Log out')),
],
),
);
if (confirmed == true && mounted) {
await context.read<AuthController>().logout();
}
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthController>();
final role = auth.user?.role ?? 'dealer';
final pages = <Widget>[
StockScreen(key: ValueKey(_stockVersion)),
const EventsScreen(),
const ServicePointsScreen(),
if (role == 'superAdmin') const AdminScreen(),
];
final destinations = <NavigationDestination>[
const NavigationDestination(icon: Icon(Icons.inventory_2_outlined), label: 'Stock'),
const NavigationDestination(icon: Icon(Icons.event_note_outlined), label: 'Plans'),
const NavigationDestination(icon: Icon(Icons.handyman_outlined), label: 'Service'),
if (role == 'superAdmin')
const NavigationDestination(icon: Icon(Icons.admin_panel_settings_outlined), label: 'Admin'),
];
final safeIndex = _index.clamp(0, pages.length - 1);
final titles = ['Stock', 'Plans', 'Service', 'Admin'];
return Scaffold(
appBar: AppBar(
title: Text(titles[safeIndex]),
actions: [
if (role == 'superAdmin' || role == 'salesRep')
IconButton(
tooltip: 'Login requests',
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const LoginRequestsScreen()),
);
_refreshPending();
},
icon: Badge(
isLabelVisible: _pendingLogins > 0,
label: Text('$_pendingLogins'),
child: const Icon(Icons.how_to_reg_outlined),
),
),
if (role == 'salesRep')
IconButton(
tooltip: 'Add dealer',
onPressed: () async {
final created = await Navigator.of(context).push<bool>(
MaterialPageRoute(builder: (_) => const AddDealerScreen()),
);
if (created != true || !context.mounted) return;
try {
await context.read<AuthController>().refreshProfile();
} catch (_) {}
if (context.mounted) setState(() => _stockVersion++);
},
icon: const Icon(Icons.add_business_outlined),
),
IconButton(
tooltip: 'Account',
onPressed: _logout,
icon: const Icon(Icons.logout),
),
],
),
body: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 960),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Text(
'${auth.user?.name ?? ''} · ${roleLabel(role)}',
style: Theme.of(context).textTheme.labelLarge,
),
),
Expanded(child: pages[safeIndex]),
],
),
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: safeIndex,
destinations: destinations,
onDestinationSelected: (index) => setState(() => _index = index),
),
);
}
}
+102
View File
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/models.dart';
import '../services/auth_controller.dart';
import '../ui/feedback.dart';
class LoginRequestsScreen extends StatefulWidget {
const LoginRequestsScreen({super.key});
@override
State<LoginRequestsScreen> createState() => _LoginRequestsScreenState();
}
class _LoginRequestsScreenState extends State<LoginRequestsScreen> {
List<LoginRequestItem>? _requests;
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final requests = await context.read<AuthController>().api.loginRequests();
if (!mounted) return;
setState(() {
_requests = requests;
_loading = false;
});
} catch (error) {
if (!mounted) return;
setState(() => _loading = false);
showError(context, error);
}
}
Future<void> _decide(LoginRequestItem request, {required bool approve}) async {
final api = context.read<AuthController>().api;
try {
if (approve) {
await api.approveLogin(request.id);
} else {
await api.rejectLogin(request.id);
}
await _load();
} catch (error) {
if (mounted) showError(context, error);
}
}
@override
Widget build(BuildContext context) {
final requests = _requests ?? [];
return Scaffold(
appBar: AppBar(title: const Text('Login requests')),
body: _loading && _requests == null
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if (requests.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: Text('No dealers are waiting to sign in.')),
),
for (final request in requests)
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(request.name, style: Theme.of(context).textTheme.titleMedium),
Text(request.phone),
if (request.dealerName != null) Text(request.dealerName!),
Row(
children: [
TextButton(
onPressed: () => _decide(request, approve: false),
child: const Text('Reject'),
),
FilledButton(
onPressed: () => _decide(request, approve: true),
child: const Text('Approve'),
),
],
),
],
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../labels.dart';
import '../services/auth_controller.dart';
import '../ui/feedback.dart';
class ServicePointFormScreen extends StatefulWidget {
const ServicePointFormScreen({super.key});
@override
State<ServicePointFormScreen> createState() => _ServicePointFormScreenState();
}
class _ServicePointFormScreenState extends State<ServicePointFormScreen> {
final _name = TextEditingController();
final _address = TextEditingController();
String? _district;
final _phone = TextEditingController();
final _notes = TextEditingController();
bool _saving = false;
@override
void dispose() {
_name.dispose();
_address.dispose();
_phone.dispose();
_notes.dispose();
super.dispose();
}
Future<void> _save() async {
if (_district == null) {
showError(context, 'Choose a district');
return;
}
setState(() => _saving = true);
try {
await context.read<AuthController>().api.createServicePoint({
'name': _name.text.trim(),
'district': _district,
'address': _address.text.trim(),
'phone': _phone.text.trim(),
'notes': _notes.text.trim(),
});
if (mounted) Navigator.pop(context, true);
} catch (error) {
if (mounted) showError(context, error);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Add service point')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(controller: _name, decoration: const InputDecoration(labelText: 'Name')),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _district,
isExpanded: true,
decoration: const InputDecoration(labelText: 'District'),
items: [
for (final district in sriLankaDistricts)
DropdownMenuItem(value: district, child: Text(district)),
],
onChanged: (value) => setState(() => _district = value),
),
const SizedBox(height: 12),
TextField(controller: _address, decoration: const InputDecoration(labelText: 'Address')),
const SizedBox(height: 12),
TextField(controller: _phone, decoration: const InputDecoration(labelText: 'Phone')),
const SizedBox(height: 12),
TextField(
controller: _notes,
minLines: 2,
maxLines: 4,
decoration: const InputDecoration(labelText: 'Notes'),
),
const SizedBox(height: 16),
FilledButton(
onPressed: _saving ? null : _save,
child: Text(_saving ? 'Saving' : 'Save service point'),
),
],
),
);
}
}
+132
View File
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../labels.dart';
import '../models/models.dart';
import '../services/auth_controller.dart';
import '../ui/feedback.dart';
import 'service_point_form_screen.dart';
class ServicePointsScreen extends StatefulWidget {
const ServicePointsScreen({super.key});
@override
State<ServicePointsScreen> createState() => _ServicePointsScreenState();
}
class _ServicePointsScreenState extends State<ServicePointsScreen> {
final _search = TextEditingController();
String? _district;
List<ServicePoint>? _points;
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_search.dispose();
super.dispose();
}
Future<void> _load() async {
try {
final points = await context.read<AuthController>().api.servicePoints(
district: _district,
search: _search.text.trim(),
);
if (!mounted) return;
setState(() {
_points = points;
_loading = false;
});
} catch (error) {
if (!mounted) return;
setState(() => _loading = false);
showError(context, error);
}
}
@override
Widget build(BuildContext context) {
final role = context.watch<AuthController>().user?.role;
final points = _points ?? [];
if (_loading && _points == null) {
return const Center(child: CircularProgressIndicator());
}
return Scaffold(
floatingActionButton: role == 'dealer'
? null
: FloatingActionButton.extended(
onPressed: () async {
final created = await Navigator.of(context).push<bool>(
MaterialPageRoute(builder: (_) => const ServicePointFormScreen()),
);
if (created == true) _load();
},
icon: const Icon(Icons.add),
label: const Text('Add point'),
),
body: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 96),
children: [
DropdownButtonFormField<String>(
initialValue: _district,
isExpanded: true,
decoration: const InputDecoration(labelText: 'District'),
items: [
const DropdownMenuItem(value: null, child: Text('All districts')),
for (final district in sriLankaDistricts)
DropdownMenuItem(value: district, child: Text(district)),
],
onChanged: (value) {
setState(() => _district = value);
_load();
},
),
const SizedBox(height: 12),
TextField(
controller: _search,
decoration: const InputDecoration(
labelText: 'Search',
hintText: 'Name, repair work, phone',
),
onSubmitted: (_) => _load(),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: FilledButton(onPressed: _load, child: const Text('Search')),
),
const SizedBox(height: 12),
if (points.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: Text('No service points match.')),
),
for (final point in points)
Card(
child: ListTile(
title: Text(point.name),
subtitle: Text(
[
point.district,
if (point.address != null && point.address!.isNotEmpty) point.address,
if (point.phone != null && point.phone!.isNotEmpty) point.phone,
if (point.notes != null && point.notes!.isNotEmpty) point.notes,
].join('\n'),
),
),
),
],
),
),
);
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
class SplashScreen extends StatelessWidget {
const SplashScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/images/senaro_logo.png',
width: 220,
semanticLabel: 'Senaro',
),
SizedBox(height: 16),
Text('Dealer Network'),
SizedBox(height: 24),
CircularProgressIndicator(),
],
),
),
);
}
}
+224
View File
@@ -0,0 +1,224 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../labels.dart';
import '../models/models.dart';
import '../services/auth_controller.dart';
import '../theme/app_theme.dart';
import '../ui/feedback.dart';
class StockDetailScreen extends StatefulWidget {
const StockDetailScreen({super.key, required this.stock, required this.dealers});
final StockItem stock;
final List<Dealer> dealers;
@override
State<StockDetailScreen> createState() => _StockDetailScreenState();
}
class _StockDetailScreenState extends State<StockDetailScreen> {
late StockItem _stock = widget.stock;
String _paymentType = 'cash';
bool _useGci = true;
bool _useFinance = false;
String? _transferDealerId;
final _financeName = TextEditingController();
final _plateNumber = TextEditingController();
bool _saving = false;
@override
void dispose() {
_financeName.dispose();
_plateNumber.dispose();
super.dispose();
}
Future<void> _advance() async {
final stage = nextStage(_stock.currentStage);
if (stage == null) return;
final body = <String, dynamic>{'stage': stage};
if (stage == 'paymentApproved') {
body['paymentType'] = _paymentType;
if (_paymentType == 'lease') {
if (!_useGci && !_useFinance) {
showError(context, 'Choose GCI, finance, or both');
return;
}
body['leaseProviderType'] = _useGci && _useFinance
? 'both'
: _useGci
? 'gci'
: 'thirdPartyFinance';
if (_useFinance) body['financeCompanyName'] = _financeName.text.trim();
}
}
if (stage == 'plateReceived') {
final plateNumber = _plateNumber.text.trim();
if (plateNumber.isEmpty) {
showError(context, 'Enter the plate number');
return;
}
body['plateNumber'] = plateNumber;
}
setState(() => _saving = true);
try {
final updated = await context.read<AuthController>().api.updateStage(_stock.chassisNumber, body);
if (!mounted) return;
setState(() => _stock = updated);
} catch (error) {
if (mounted) showError(context, error);
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _transfer() async {
final dealerId = _transferDealerId;
if (dealerId == null) return;
setState(() => _saving = true);
try {
final updated = await context.read<AuthController>().api.transferStock(_stock.chassisNumber, dealerId);
if (!mounted) return;
setState(() {
_stock = updated;
_transferDealerId = null;
});
} catch (error) {
if (mounted) showError(context, error);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final role = context.watch<AuthController>().user?.role;
final upcoming = nextStage(_stock.currentStage);
final targets = widget.dealers.where((dealer) => dealer.id != _stock.currentDealerId).toList();
return Scaffold(
appBar: AppBar(title: Text(_stock.chassisNumber)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
children: [
CircleAvatar(
backgroundColor: badgeColor(_stock.badge),
child: Text('${_stock.ageInDays}', style: const TextStyle(color: Colors.white, fontSize: 12)),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'${_stock.ageInDays} days in stock · ${stageLabel(_stock.currentStage)}',
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
const SizedBox(height: 16),
_line('Model', modelLabel(_stock.model)),
_line('Color', _stock.color),
_line('Engine', _stock.engineNumber),
if (_stock.plateNumber != null) _line('Plate', _stock.plateNumber!),
_line('Dealer', _stock.dealerName ?? _stock.currentDealerId),
_line('Received', _stock.dateReceived.toLocal().toString().split(' ').first),
if (_stock.paymentType != null) _line('Payment', _stock.paymentType!),
if (_stock.leaseProviderType != null)
_line('Lease', leaseProviderLabel(_stock.leaseProviderType!)),
if (_stock.financeCompanyName != null) _line('Finance company', _stock.financeCompanyName!),
const SizedBox(height: 16),
Text('Stage log', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
for (final stage in stageOrder)
if (_stock.stageLogDates.containsKey(stage))
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(stageLabel(stage)),
trailing: Text(_stock.stageLogDates[stage]!.toLocal().toString().split(' ').first),
),
if (upcoming != null) ...[
const Divider(),
Text('Advance to ${stageLabel(upcoming)}', style: Theme.of(context).textTheme.titleMedium),
if (upcoming == 'paymentApproved') ...[
const SizedBox(height: 12),
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'cash', label: Text('Cash')),
ButtonSegment(value: 'lease', label: Text('Lease')),
],
selected: {_paymentType},
onSelectionChanged: (value) => setState(() => _paymentType = value.first),
),
if (_paymentType == 'lease') ...[
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: const Text('GCI'),
value: _useGci,
onChanged: (value) => setState(() => _useGci = value ?? false),
),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Finance'),
value: _useFinance,
onChanged: (value) => setState(() => _useFinance = value ?? false),
),
if (_useFinance)
TextField(
controller: _financeName,
decoration: const InputDecoration(labelText: 'Finance company'),
),
],
],
if (upcoming == 'plateReceived') ...[
const SizedBox(height: 12),
TextField(
controller: _plateNumber,
textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration(labelText: 'Plate number'),
),
],
const SizedBox(height: 12),
FilledButton(
onPressed: _saving ? null : _advance,
child: Text(_saving ? 'Saving' : 'Update stage'),
),
],
if (role != 'dealer' && targets.isNotEmpty) ...[
const Divider(),
Text('Transfer', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _transferDealerId,
decoration: const InputDecoration(labelText: 'Destination dealer'),
items: [
for (final dealer in targets)
DropdownMenuItem(value: dealer.id, child: Text('${dealer.name} (${dealer.code})')),
],
onChanged: (value) => setState(() => _transferDealerId = value),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: _saving || _transferDealerId == null ? null : _transfer,
child: const Text('Transfer unit'),
),
],
],
),
);
}
Widget _line(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 140, child: Text(label)),
Expanded(child: Text(value)),
],
),
);
}
}
+144
View File
@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../labels.dart';
import '../models/models.dart';
import '../services/auth_controller.dart';
import '../ui/feedback.dart';
class StockFormScreen extends StatefulWidget {
const StockFormScreen({super.key, required this.dealers});
final List<Dealer> dealers;
@override
State<StockFormScreen> createState() => _StockFormScreenState();
}
class _StockFormScreenState extends State<StockFormScreen> {
final _chassis = TextEditingController();
final _engine = TextEditingController();
final _color = TextEditingController();
String _model = modelOptions.first;
String? _dealerId;
DateTime _received = DateTime.now();
bool _saving = false;
@override
void initState() {
super.initState();
final user = context.read<AuthController>().user;
if (user?.role == 'dealer') {
_dealerId = user?.dealerId ?? (widget.dealers.isNotEmpty ? widget.dealers.first.id : null);
} else if (widget.dealers.length == 1) {
_dealerId = widget.dealers.first.id;
}
}
@override
void dispose() {
_chassis.dispose();
_engine.dispose();
_color.dispose();
super.dispose();
}
Future<void> _save() async {
if (_dealerId == null) {
showError(context, 'Choose a dealer');
return;
}
setState(() => _saving = true);
try {
await context.read<AuthController>().api.createStock({
'chassisNumber': _chassis.text.trim(),
'engineNumber': _engine.text.trim(),
'model': _model,
'color': _color.text.trim(),
'currentDealerId': _dealerId,
'dateReceived': _received.toUtc().toIso8601String(),
});
if (mounted) Navigator.pop(context, true);
} catch (error) {
if (mounted) showError(context, error);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final lockedToOwnDealer = context.watch<AuthController>().user?.role == 'dealer';
Dealer? ownDealer;
for (final dealer in widget.dealers) {
if (dealer.id == _dealerId) {
ownDealer = dealer;
break;
}
}
return Scaffold(
appBar: AppBar(title: const Text('Add stock')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(controller: _chassis, decoration: const InputDecoration(labelText: 'Chassis number')),
const SizedBox(height: 12),
TextField(controller: _engine, decoration: const InputDecoration(labelText: 'Engine number')),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _model,
decoration: const InputDecoration(labelText: 'Model'),
items: [
for (final model in modelOptions)
DropdownMenuItem(value: model, child: Text(modelLabel(model))),
],
onChanged: (value) {
if (value != null) setState(() => _model = value);
},
),
const SizedBox(height: 12),
TextField(controller: _color, decoration: const InputDecoration(labelText: 'Color')),
const SizedBox(height: 12),
if (lockedToOwnDealer)
InputDecorator(
decoration: const InputDecoration(labelText: 'Dealer'),
child: Text(
ownDealer == null ? 'Your dealership' : '${ownDealer.name} (${ownDealer.code})',
),
)
else
DropdownButtonFormField<String>(
initialValue: _dealerId,
decoration: const InputDecoration(labelText: 'Dealer'),
items: [
for (final dealer in widget.dealers)
DropdownMenuItem(value: dealer.id, child: Text('${dealer.name} (${dealer.code})')),
],
onChanged: (value) => setState(() => _dealerId = value),
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Received date'),
subtitle: Text(_received.toLocal().toString().split(' ').first),
trailing: const Icon(Icons.calendar_today),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: _received,
firstDate: DateTime(2000),
lastDate: DateTime.now().add(const Duration(days: 1)),
);
if (picked != null) setState(() => _received = picked);
},
),
const SizedBox(height: 16),
FilledButton(
onPressed: _saving ? null : _save,
child: Text(_saving ? 'Saving' : 'Save stock'),
),
],
),
);
}
}
+168
View File
@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/models.dart';
import '../services/auth_controller.dart';
import '../theme/app_theme.dart';
import '../ui/feedback.dart';
import 'dealer_stock_screen.dart';
import 'stock_form_screen.dart';
class StockScreen extends StatefulWidget {
const StockScreen({super.key});
@override
State<StockScreen> createState() => _StockScreenState();
}
class _StockScreenState extends State<StockScreen> {
List<Dealer> _dealers = [];
List<StockItem> _stocks = [];
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final api = context.read<AuthController>().api;
try {
final results = await Future.wait([
api.dealers(),
api.stocks(),
]);
if (!mounted) return;
setState(() {
_dealers = results[0] as List<Dealer>;
_stocks = results[1] as List<StockItem>;
_loading = false;
});
} catch (error) {
if (!mounted) return;
setState(() => _loading = false);
showError(context, error);
}
}
int _count(String dealerId, String badge) {
return _stocks.where((item) => item.currentDealerId == dealerId && item.badge == badge).length;
}
@override
Widget build(BuildContext context) {
final role = context.watch<AuthController>().user?.role;
final canAdd = role == 'dealer' || role == 'salesRep' || role == 'superAdmin';
if (_loading && _dealers.isEmpty && _stocks.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return Scaffold(
floatingActionButton: canAdd
? FloatingActionButton.extended(
onPressed: () async {
final created = await Navigator.of(context).push<bool>(
MaterialPageRoute(builder: (_) => StockFormScreen(dealers: _dealers)),
);
if (created == true) _load();
},
icon: const Icon(Icons.add),
label: const Text('Add stock'),
)
: null,
body: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 96),
children: [
if (_dealers.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: Text('No dealers to show.')),
),
for (final dealer in _dealers)
Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () async {
await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => DealerStockScreen(dealer: dealer, dealers: _dealers),
),
);
if (mounted) _load();
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(dealer.name, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 2),
Text(
dealer.district == null || dealer.district!.isEmpty
? dealer.code
: '${dealer.code} · ${dealer.district}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _AgeCount(
label: 'Under 30',
count: _count(dealer.id, 'green'),
badge: 'green',
),
),
Expanded(
child: _AgeCount(
label: '30–45',
count: _count(dealer.id, 'yellow'),
badge: 'yellow',
),
),
Expanded(
child: _AgeCount(
label: 'Over 45',
count: _count(dealer.id, 'red'),
badge: 'red',
),
),
],
),
],
),
),
),
),
],
),
),
);
}
}
class _AgeCount extends StatelessWidget {
const _AgeCount({required this.label, required this.count, required this.badge});
final String label;
final int count;
final String badge;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
'$count',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(color: badgeColor(badge)),
),
const SizedBox(height: 2),
Text(label, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center),
],
);
}
}
+228
View File
@@ -0,0 +1,228 @@
import 'package:dio/dio.dart';
import '../config/api_config.dart';
import '../models/models.dart';
class ApiException implements Exception {
ApiException(this.message, {this.statusCode});
final String message;
final int? statusCode;
@override
String toString() => message;
}
class ApiClient {
ApiClient() {
dio = Dio(
BaseOptions(
baseUrl: resolveApiBaseUrl(),
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
headers: const {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
if (token != null && token!.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
),
);
}
late final Dio dio;
String? token;
Future<({String token, AppUser user})> staffLogin(String phone, String password) {
return _send(
dio.post('/auth/staff-login', data: {'phone': phone, 'password': password}),
(data) {
final map = data as Map<String, dynamic>;
return (
token: map['token'] as String,
user: AppUser.fromJson(map['user'] as Map<String, dynamic>),
);
},
);
}
Future<({String requestId, String secret})> dealerLogin(String phone) {
return _send(dio.post('/auth/dealer-login', data: {'phone': phone}), (data) {
final map = data as Map<String, dynamic>;
return (requestId: map['requestId'] as String, secret: map['secret'] as String);
});
}
Future<DealerLoginStatus> dealerLoginStatus(String requestId, String secret) {
return _send(
dio.get('/auth/dealer-login/$requestId', queryParameters: {'secret': secret}),
(data) => DealerLoginStatus.fromJson(data as Map<String, dynamic>),
);
}
Future<List<LoginRequestItem>> loginRequests() {
return _send(dio.get('/auth/login-requests'), (data) => _list(data, LoginRequestItem.fromJson));
}
Future<void> approveLogin(String id) {
return _send(dio.post('/auth/login-requests/$id/approve'), (_) {});
}
Future<void> rejectLogin(String id) {
return _send(dio.post('/auth/login-requests/$id/reject'), (_) {});
}
Future<AppUser> me() {
return _send(
dio.get('/auth/me'),
(data) => AppUser.fromJson(data as Map<String, dynamic>),
);
}
Future<List<Dealer>> dealers() {
return _send(dio.get('/dealers'), (data) => _list(data, Dealer.fromJson));
}
Future<List<StockItem>> stocks({String? dealerId}) {
return _send(
dio.get(
'/stocks',
queryParameters: {
if (dealerId != null && dealerId.isNotEmpty) 'dealerId': dealerId,
},
),
(data) => _list(data, StockItem.fromJson),
);
}
Future<StockItem> createStock(Map<String, dynamic> body) {
return _send(
dio.post('/stocks', data: body),
(data) => StockItem.fromJson((data as Map<String, dynamic>)['data'] as Map<String, dynamic>),
);
}
Future<StockItem> updateStage(String chassisNumber, Map<String, dynamic> body) {
return _send(
dio.patch('/stocks/$chassisNumber/stage', data: body),
(data) => StockItem.fromJson((data as Map<String, dynamic>)['data'] as Map<String, dynamic>),
);
}
Future<StockItem> transferStock(String chassisNumber, String dealerId) {
return _send(
dio.post('/stocks/$chassisNumber/transfer', data: {'dealerId': dealerId}),
(data) => StockItem.fromJson((data as Map<String, dynamic>)['data'] as Map<String, dynamic>),
);
}
Future<List<EventPlan>> events({String? dealerId, String? planType}) {
return _send(
dio.get(
'/events',
queryParameters: {
if (dealerId != null && dealerId.isNotEmpty) 'dealerId': dealerId,
if (planType != null && planType.isNotEmpty) 'planType': planType,
},
),
(data) => _list(data, EventPlan.fromJson),
);
}
Future<EventPlan> createEvent(Map<String, dynamic> body) {
return _send(
dio.post('/events', data: body),
(data) => EventPlan.fromJson((data as Map<String, dynamic>)['data'] as Map<String, dynamic>),
);
}
Future<List<ServicePoint>> servicePoints({String? district, String? search}) {
return _send(
dio.get(
'/service-points',
queryParameters: {
if (district != null && district.isNotEmpty) 'district': district,
if (search != null && search.isNotEmpty) 'search': search,
},
),
(data) => _list(data, ServicePoint.fromJson),
);
}
Future<ServicePoint> createServicePoint(Map<String, dynamic> body) {
return _send(
dio.post('/service-points', data: body),
(data) => ServicePoint.fromJson((data as Map<String, dynamic>)['data'] as Map<String, dynamic>),
);
}
Future<Dealer> createDealer(Map<String, dynamic> body) {
return _send(
dio.post('/admin/dealers', data: body),
(data) => Dealer.fromJson((data as Map<String, dynamic>)['data'] as Map<String, dynamic>),
);
}
Future<void> deleteDealer(String id) {
return _send(dio.delete('/admin/dealers/$id'), (_) {});
}
Future<List<Dealer>> adminDealers() {
return _send(dio.get('/admin/dealers'), (data) => _list(data, Dealer.fromJson));
}
Future<List<AppUser>> salesReps() {
return _send(dio.get('/admin/sales-reps'), (data) => _list(data, AppUser.fromJson));
}
Future<AppUser> createSalesRep(Map<String, dynamic> body) {
return _send(
dio.post('/admin/sales-reps', data: body),
(data) => AppUser.fromJson((data as Map<String, dynamic>)['data'] as Map<String, dynamic>),
);
}
Future<AppUser> assignDealer({
required String salesRepId,
required String dealerId,
required bool assigned,
}) {
return _send(
dio.patch(
'/admin/assign-dealer',
data: {
'salesRepId': salesRepId,
'dealerId': dealerId,
'assigned': assigned,
},
),
(data) => AppUser.fromJson((data as Map<String, dynamic>)['data'] as Map<String, dynamic>),
);
}
Future<T> _send<T>(Future<Response<dynamic>> request, T Function(dynamic data) parse) async {
try {
final response = await request;
return parse(response.data);
} on DioException catch (error) {
final data = error.response?.data;
final message = data is Map && data['message'] is String
? data['message'] as String
: 'Network request failed';
throw ApiException(message, statusCode: error.response?.statusCode);
}
}
List<T> _list<T>(dynamic data, T Function(Map<String, dynamic>) parse) {
final raw = (data as Map<String, dynamic>)['data'] as List<dynamic>;
return raw.map((item) => parse(item as Map<String, dynamic>)).toList();
}
}
+103
View File
@@ -0,0 +1,103 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../models/models.dart';
import 'api_client.dart';
class AuthController extends ChangeNotifier {
AuthController({FlutterSecureStorage? storage, ApiClient? apiClient})
: _storage = storage ?? const FlutterSecureStorage(),
api = apiClient ?? ApiClient();
final FlutterSecureStorage _storage;
final ApiClient api;
AppUser? user;
bool ready = false;
bool busy = false;
String? error;
Future<void> bootstrap() async {
try {
final stored = await _storage.read(key: 'jwt_token');
if (stored == null || stored.isEmpty) return;
api.token = stored;
user = await api.me();
} catch (_) {
api.token = null;
user = null;
await _storage.delete(key: 'jwt_token');
} finally {
ready = true;
notifyListeners();
}
}
Future<void> staffLogin(String phone, String password) async {
await _run(() async {
final result = await api.staffLogin(phone, password);
await _storeSession(result.token, result.user);
});
}
Future<({String requestId, String secret})> startDealerLogin(String phone) async {
late ({String requestId, String secret}) started;
await _run(() async {
started = await api.dealerLogin(phone);
});
return started;
}
Future<String> pollDealerLogin(String requestId, String secret) async {
final status = await api.dealerLoginStatus(requestId, secret);
if (status.status == 'approved' && status.token != null && status.user != null) {
await _storeSession(status.token!, status.user!);
notifyListeners();
} else if (status.status == 'rejected') {
error = 'Login was rejected';
notifyListeners();
} else if (status.status == 'expired' || status.status == 'consumed') {
error = 'Login request expired. Try again.';
notifyListeners();
}
return status.status;
}
Future<void> _storeSession(String token, AppUser profile) async {
api.token = token;
await _storage.write(key: 'jwt_token', value: token);
user = profile;
}
Future<void> refreshProfile() async {
if (api.token == null) return;
user = await api.me();
notifyListeners();
}
Future<void> logout() async {
api.token = null;
user = null;
error = null;
await _storage.delete(key: 'jwt_token');
notifyListeners();
}
Future<void> _run(Future<void> Function() action) async {
busy = true;
error = null;
notifyListeners();
try {
await action();
} on ApiException catch (error) {
this.error = error.message;
rethrow;
} catch (error) {
this.error = 'Network request failed';
rethrow;
} finally {
busy = false;
notifyListeners();
}
}
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
ThemeData buildAppTheme() {
const seed = Color(0xFF0E4D6C);
final scheme = ColorScheme.fromSeed(seedColor: seed);
return ThemeData(
colorScheme: scheme,
useMaterial3: true,
scaffoldBackgroundColor: const Color(0xFFF4F7F8),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
cardTheme: CardThemeData(
elevation: 0,
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: scheme.outlineVariant),
),
),
);
}
Color badgeColor(String badge) {
switch (badge) {
case 'green':
return const Color(0xFF1B7F4E);
case 'yellow':
return const Color(0xFF8A6A12);
case 'red':
return const Color(0xFFB42318);
default:
return const Color(0xFF5C6B73);
}
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
import '../services/api_client.dart';
void showError(BuildContext context, Object error) {
final message = switch (error) {
ApiException(:final message) => message,
String message => message,
_ => 'Something went wrong',
};
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
Future<void> showNotice(BuildContext context, String message) {
return ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)))
.closed;
}