init
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user