This commit is contained in:
2026-10-01 16:03:32 +05:30
commit 3db47c4648
102 changed files with 5067 additions and 0 deletions
+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),
],
);
}
}