init
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
/// Hosted API. Override with --dart-define=API_BASE_URL=...
|
||||
String resolveApiBaseUrl() {
|
||||
const fromEnv = String.fromEnvironment('API_BASE_URL');
|
||||
if (fromEnv.isNotEmpty) return fromEnv;
|
||||
return 'https://snrodealer.playpoolstudios.com/api';
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
const stageOrder = [
|
||||
'received',
|
||||
'orderedByCustomer',
|
||||
'documentsProcessed',
|
||||
'paymentApproved',
|
||||
'rmvRegistration',
|
||||
'plateReceived',
|
||||
];
|
||||
|
||||
const modelOptions = ['gn125', 'boxer125', 'click150i'];
|
||||
|
||||
const sriLankaDistricts = [
|
||||
'Ampara',
|
||||
'Anuradhapura',
|
||||
'Badulla',
|
||||
'Batticaloa',
|
||||
'Colombo',
|
||||
'Galle',
|
||||
'Gampaha',
|
||||
'Hambantota',
|
||||
'Jaffna',
|
||||
'Kalutara',
|
||||
'Kandy',
|
||||
'Kegalle',
|
||||
'Kilinochchi',
|
||||
'Kurunegala',
|
||||
'Mannar',
|
||||
'Matale',
|
||||
'Matara',
|
||||
'Moneragala',
|
||||
'Mullaitivu',
|
||||
'Nuwara Eliya',
|
||||
'Polonnaruwa',
|
||||
'Puttalam',
|
||||
'Ratnapura',
|
||||
'Trincomalee',
|
||||
'Vavuniya',
|
||||
];
|
||||
|
||||
String stageLabel(String stage) {
|
||||
switch (stage) {
|
||||
case 'received':
|
||||
return 'Received';
|
||||
case 'orderedByCustomer':
|
||||
return 'Ordered by customer';
|
||||
case 'documentsProcessed':
|
||||
return 'Documents processed';
|
||||
case 'paymentApproved':
|
||||
return 'Payment approved';
|
||||
case 'rmvRegistration':
|
||||
return 'RMV registration';
|
||||
case 'plateReceived':
|
||||
return 'Plate received';
|
||||
default:
|
||||
return stage;
|
||||
}
|
||||
}
|
||||
|
||||
String? nextStage(String stage) {
|
||||
final index = stageOrder.indexOf(stage);
|
||||
if (index < 0 || index >= stageOrder.length - 1) return null;
|
||||
return stageOrder[index + 1];
|
||||
}
|
||||
|
||||
String modelLabel(String model) {
|
||||
switch (model) {
|
||||
case 'gn125':
|
||||
return 'GN125';
|
||||
case 'boxer125':
|
||||
return 'Boxer 125';
|
||||
case 'click150i':
|
||||
return 'Click 150i';
|
||||
default:
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
String roleLabel(String role) {
|
||||
switch (role) {
|
||||
case 'superAdmin':
|
||||
return 'Super admin';
|
||||
case 'salesRep':
|
||||
return 'Sales rep';
|
||||
case 'dealer':
|
||||
return 'Dealer';
|
||||
default:
|
||||
return role;
|
||||
}
|
||||
}
|
||||
|
||||
String leaseProviderLabel(String provider) {
|
||||
switch (provider) {
|
||||
case 'gci':
|
||||
return 'GCI';
|
||||
case 'thirdPartyFinance':
|
||||
return 'Finance';
|
||||
case 'both':
|
||||
return 'GCI and finance';
|
||||
default:
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
|
||||
String planLabel(String planType) {
|
||||
switch (planType) {
|
||||
case 'monthlySpecial':
|
||||
return 'Monthly special';
|
||||
case 'general':
|
||||
return 'General';
|
||||
default:
|
||||
return planType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'screens/auth_flow.dart';
|
||||
import 'screens/home_shell.dart';
|
||||
import 'screens/splash_screen.dart';
|
||||
import 'services/auth_controller.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(const DealerNetworkApp());
|
||||
}
|
||||
|
||||
class DealerNetworkApp extends StatelessWidget {
|
||||
const DealerNetworkApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => AuthController()..bootstrap(),
|
||||
child: MaterialApp(
|
||||
title: 'Dealer Network',
|
||||
theme: buildAppTheme(),
|
||||
home: const _Root(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Root extends StatelessWidget {
|
||||
const _Root();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.watch<AuthController>();
|
||||
if (!auth.ready) return const SplashScreen();
|
||||
if (auth.user == null) return const AuthFlow();
|
||||
return const HomeShell();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
class Dealer {
|
||||
Dealer({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.code,
|
||||
this.district,
|
||||
this.address,
|
||||
this.phone,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String code;
|
||||
final String? district;
|
||||
final String? address;
|
||||
final String? phone;
|
||||
|
||||
factory Dealer.fromJson(Map<String, dynamic> json) {
|
||||
return Dealer(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
code: json['code'] as String,
|
||||
district: json['district'] as String?,
|
||||
address: json['address'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppUser {
|
||||
AppUser({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.phone,
|
||||
required this.role,
|
||||
this.dealerId,
|
||||
this.assignedDealerIds = const [],
|
||||
this.dealers = const [],
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String phone;
|
||||
final String role;
|
||||
final String? dealerId;
|
||||
final List<String> assignedDealerIds;
|
||||
final List<Dealer> dealers;
|
||||
|
||||
factory AppUser.fromJson(Map<String, dynamic> json) {
|
||||
return AppUser(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
phone: json['phone'] as String,
|
||||
role: json['role'] as String,
|
||||
dealerId: json['dealerId'] as String?,
|
||||
assignedDealerIds: ((json['assignedDealerIds'] as List?) ?? const [])
|
||||
.map((id) => id as String)
|
||||
.toList(),
|
||||
dealers: ((json['dealers'] as List?) ?? const [])
|
||||
.map((item) => Dealer.fromJson(item as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LoginRequestItem {
|
||||
LoginRequestItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.phone,
|
||||
this.dealerName,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String phone;
|
||||
final String? dealerName;
|
||||
final DateTime createdAt;
|
||||
|
||||
factory LoginRequestItem.fromJson(Map<String, dynamic> json) {
|
||||
final user = json['user'] as Map<String, dynamic>;
|
||||
return LoginRequestItem(
|
||||
id: json['id'] as String,
|
||||
name: user['name'] as String,
|
||||
phone: user['phone'] as String,
|
||||
dealerName: json['dealerName'] as String?,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DealerLoginStatus {
|
||||
DealerLoginStatus({required this.status, this.token, this.user});
|
||||
|
||||
final String status;
|
||||
final String? token;
|
||||
final AppUser? user;
|
||||
|
||||
factory DealerLoginStatus.fromJson(Map<String, dynamic> json) {
|
||||
return DealerLoginStatus(
|
||||
status: json['status'] as String,
|
||||
token: json['token'] as String?,
|
||||
user: json['user'] is Map<String, dynamic>
|
||||
? AppUser.fromJson(json['user'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StockItem {
|
||||
StockItem({
|
||||
required this.id,
|
||||
required this.chassisNumber,
|
||||
required this.engineNumber,
|
||||
required this.model,
|
||||
required this.color,
|
||||
required this.currentDealerId,
|
||||
this.dealerName,
|
||||
required this.dateReceived,
|
||||
required this.currentStage,
|
||||
required this.stageLogDates,
|
||||
this.paymentType,
|
||||
this.leaseProviderType,
|
||||
this.financeCompanyName,
|
||||
this.plateNumber,
|
||||
required this.ageInDays,
|
||||
required this.badge,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String chassisNumber;
|
||||
final String engineNumber;
|
||||
final String model;
|
||||
final String color;
|
||||
final String currentDealerId;
|
||||
final String? dealerName;
|
||||
final DateTime dateReceived;
|
||||
final String currentStage;
|
||||
final Map<String, DateTime> stageLogDates;
|
||||
final String? paymentType;
|
||||
final String? leaseProviderType;
|
||||
final String? financeCompanyName;
|
||||
final String? plateNumber;
|
||||
final int ageInDays;
|
||||
final String badge;
|
||||
|
||||
factory StockItem.fromJson(Map<String, dynamic> json) {
|
||||
final rawLogs = json['stageLogDates'];
|
||||
final logs = <String, DateTime>{};
|
||||
if (rawLogs is Map) {
|
||||
rawLogs.forEach((key, value) {
|
||||
if (value != null) {
|
||||
logs[key.toString()] = DateTime.parse(value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
return StockItem(
|
||||
id: json['id'] as String,
|
||||
chassisNumber: json['chassisNumber'] as String,
|
||||
engineNumber: json['engineNumber'] as String,
|
||||
model: json['model'] as String,
|
||||
color: json['color'] as String,
|
||||
currentDealerId: json['currentDealerId'] as String,
|
||||
dealerName: json['dealerName'] as String?,
|
||||
dateReceived: DateTime.parse(json['dateReceived'] as String),
|
||||
currentStage: json['currentStage'] as String,
|
||||
stageLogDates: logs,
|
||||
paymentType: json['paymentType'] as String?,
|
||||
leaseProviderType: json['leaseProviderType'] as String?,
|
||||
financeCompanyName: json['financeCompanyName'] as String?,
|
||||
plateNumber: json['plateNumber'] as String?,
|
||||
ageInDays: (json['ageInDays'] as num).toInt(),
|
||||
badge: json['badge'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EventPlan {
|
||||
EventPlan({
|
||||
required this.id,
|
||||
required this.dealerId,
|
||||
this.dealerName,
|
||||
required this.planType,
|
||||
this.month,
|
||||
this.year,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.expense,
|
||||
required this.imageUrls,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String dealerId;
|
||||
final String? dealerName;
|
||||
final String planType;
|
||||
final int? month;
|
||||
final int? year;
|
||||
final String title;
|
||||
final String description;
|
||||
final double expense;
|
||||
final List<String> imageUrls;
|
||||
final DateTime createdAt;
|
||||
|
||||
factory EventPlan.fromJson(Map<String, dynamic> json) {
|
||||
return EventPlan(
|
||||
id: json['id'] as String,
|
||||
dealerId: json['dealerId'] as String,
|
||||
dealerName: json['dealerName'] as String?,
|
||||
planType: json['planType'] as String,
|
||||
month: json['month'] as int?,
|
||||
year: json['year'] as int?,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String,
|
||||
expense: (json['expense'] as num).toDouble(),
|
||||
imageUrls: ((json['imageUrls'] as List?) ?? const [])
|
||||
.map((url) => url as String)
|
||||
.toList(),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ServicePoint {
|
||||
ServicePoint({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.district,
|
||||
this.address,
|
||||
this.phone,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String district;
|
||||
final String? address;
|
||||
final String? phone;
|
||||
final String? notes;
|
||||
|
||||
factory ServicePoint.fromJson(Map<String, dynamic> json) {
|
||||
return ServicePoint(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
district: json['district'] as String,
|
||||
address: json['address'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
notes: json['notes'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../config/api_config.dart';
|
||||
import '../models/models.dart';
|
||||
|
||||
class ApiException implements Exception {
|
||||
ApiException(this.message, {this.statusCode});
|
||||
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
ApiClient() {
|
||||
dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: resolveApiBaseUrl(),
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
headers: const {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
if (token != null && token!.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
late final Dio dio;
|
||||
String? token;
|
||||
|
||||
Future<({String token, AppUser user})> staffLogin(String phone, String password) {
|
||||
return _send(
|
||||
dio.post('/auth/staff-login', data: {'phone': phone, 'password': password}),
|
||||
(data) {
|
||||
final map = data as Map<String, dynamic>;
|
||||
return (
|
||||
token: map['token'] as String,
|
||||
user: AppUser.fromJson(map['user'] as Map<String, dynamic>),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<({String requestId, String secret})> dealerLogin(String phone) {
|
||||
return _send(dio.post('/auth/dealer-login', data: {'phone': phone}), (data) {
|
||||
final map = data as Map<String, dynamic>;
|
||||
return (requestId: map['requestId'] as String, secret: map['secret'] as String);
|
||||
});
|
||||
}
|
||||
|
||||
Future<DealerLoginStatus> dealerLoginStatus(String requestId, String secret) {
|
||||
return _send(
|
||||
dio.get('/auth/dealer-login/$requestId', queryParameters: {'secret': secret}),
|
||||
(data) => DealerLoginStatus.fromJson(data as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<LoginRequestItem>> loginRequests() {
|
||||
return _send(dio.get('/auth/login-requests'), (data) => _list(data, LoginRequestItem.fromJson));
|
||||
}
|
||||
|
||||
Future<void> approveLogin(String id) {
|
||||
return _send(dio.post('/auth/login-requests/$id/approve'), (_) {});
|
||||
}
|
||||
|
||||
Future<void> rejectLogin(String id) {
|
||||
return _send(dio.post('/auth/login-requests/$id/reject'), (_) {});
|
||||
}
|
||||
|
||||
Future<AppUser> me() {
|
||||
return _send(
|
||||
dio.get('/auth/me'),
|
||||
(data) => AppUser.fromJson(data as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Dealer>> dealers() {
|
||||
return _send(dio.get('/dealers'), (data) => _list(data, Dealer.fromJson));
|
||||
}
|
||||
|
||||
Future<List<StockItem>> stocks({String? dealerId}) {
|
||||
return _send(
|
||||
dio.get(
|
||||
'/stocks',
|
||||
queryParameters: {
|
||||
if (dealerId != null && dealerId.isNotEmpty) 'dealerId': dealerId,
|
||||
},
|
||||
),
|
||||
(data) => _list(data, StockItem.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
Future<StockItem> createStock(Map<String, dynamic> body) {
|
||||
return _send(
|
||||
dio.post('/stocks', data: body),
|
||||
(data) => StockItem.fromJson((data as Map<String, dynamic>)['data'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Future<StockItem> updateStage(String chassisNumber, Map<String, dynamic> body) {
|
||||
return _send(
|
||||
dio.patch('/stocks/$chassisNumber/stage', data: body),
|
||||
(data) => StockItem.fromJson((data as Map<String, dynamic>)['data'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Future<StockItem> transferStock(String chassisNumber, String dealerId) {
|
||||
return _send(
|
||||
dio.post('/stocks/$chassisNumber/transfer', data: {'dealerId': dealerId}),
|
||||
(data) => StockItem.fromJson((data as Map<String, dynamic>)['data'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<EventPlan>> events({String? dealerId, String? planType}) {
|
||||
return _send(
|
||||
dio.get(
|
||||
'/events',
|
||||
queryParameters: {
|
||||
if (dealerId != null && dealerId.isNotEmpty) 'dealerId': dealerId,
|
||||
if (planType != null && planType.isNotEmpty) 'planType': planType,
|
||||
},
|
||||
),
|
||||
(data) => _list(data, EventPlan.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
Future<EventPlan> createEvent(Map<String, dynamic> body) {
|
||||
return _send(
|
||||
dio.post('/events', data: body),
|
||||
(data) => EventPlan.fromJson((data as Map<String, dynamic>)['data'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ServicePoint>> servicePoints({String? district, String? search}) {
|
||||
return _send(
|
||||
dio.get(
|
||||
'/service-points',
|
||||
queryParameters: {
|
||||
if (district != null && district.isNotEmpty) 'district': district,
|
||||
if (search != null && search.isNotEmpty) 'search': search,
|
||||
},
|
||||
),
|
||||
(data) => _list(data, ServicePoint.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ServicePoint> createServicePoint(Map<String, dynamic> body) {
|
||||
return _send(
|
||||
dio.post('/service-points', data: body),
|
||||
(data) => ServicePoint.fromJson((data as Map<String, dynamic>)['data'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Dealer> createDealer(Map<String, dynamic> body) {
|
||||
return _send(
|
||||
dio.post('/admin/dealers', data: body),
|
||||
(data) => Dealer.fromJson((data as Map<String, dynamic>)['data'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteDealer(String id) {
|
||||
return _send(dio.delete('/admin/dealers/$id'), (_) {});
|
||||
}
|
||||
|
||||
Future<List<Dealer>> adminDealers() {
|
||||
return _send(dio.get('/admin/dealers'), (data) => _list(data, Dealer.fromJson));
|
||||
}
|
||||
|
||||
Future<List<AppUser>> salesReps() {
|
||||
return _send(dio.get('/admin/sales-reps'), (data) => _list(data, AppUser.fromJson));
|
||||
}
|
||||
|
||||
Future<AppUser> createSalesRep(Map<String, dynamic> body) {
|
||||
return _send(
|
||||
dio.post('/admin/sales-reps', data: body),
|
||||
(data) => AppUser.fromJson((data as Map<String, dynamic>)['data'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Future<AppUser> assignDealer({
|
||||
required String salesRepId,
|
||||
required String dealerId,
|
||||
required bool assigned,
|
||||
}) {
|
||||
return _send(
|
||||
dio.patch(
|
||||
'/admin/assign-dealer',
|
||||
data: {
|
||||
'salesRepId': salesRepId,
|
||||
'dealerId': dealerId,
|
||||
'assigned': assigned,
|
||||
},
|
||||
),
|
||||
(data) => AppUser.fromJson((data as Map<String, dynamic>)['data'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Future<T> _send<T>(Future<Response<dynamic>> request, T Function(dynamic data) parse) async {
|
||||
try {
|
||||
final response = await request;
|
||||
return parse(response.data);
|
||||
} on DioException catch (error) {
|
||||
final data = error.response?.data;
|
||||
final message = data is Map && data['message'] is String
|
||||
? data['message'] as String
|
||||
: 'Network request failed';
|
||||
throw ApiException(message, statusCode: error.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
List<T> _list<T>(dynamic data, T Function(Map<String, dynamic>) parse) {
|
||||
final raw = (data as Map<String, dynamic>)['data'] as List<dynamic>;
|
||||
return raw.map((item) => parse(item as Map<String, dynamic>)).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../models/models.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class AuthController extends ChangeNotifier {
|
||||
AuthController({FlutterSecureStorage? storage, ApiClient? apiClient})
|
||||
: _storage = storage ?? const FlutterSecureStorage(),
|
||||
api = apiClient ?? ApiClient();
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
final ApiClient api;
|
||||
|
||||
AppUser? user;
|
||||
bool ready = false;
|
||||
bool busy = false;
|
||||
String? error;
|
||||
|
||||
Future<void> bootstrap() async {
|
||||
try {
|
||||
final stored = await _storage.read(key: 'jwt_token');
|
||||
if (stored == null || stored.isEmpty) return;
|
||||
api.token = stored;
|
||||
user = await api.me();
|
||||
} catch (_) {
|
||||
api.token = null;
|
||||
user = null;
|
||||
await _storage.delete(key: 'jwt_token');
|
||||
} finally {
|
||||
ready = true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> staffLogin(String phone, String password) async {
|
||||
await _run(() async {
|
||||
final result = await api.staffLogin(phone, password);
|
||||
await _storeSession(result.token, result.user);
|
||||
});
|
||||
}
|
||||
|
||||
Future<({String requestId, String secret})> startDealerLogin(String phone) async {
|
||||
late ({String requestId, String secret}) started;
|
||||
await _run(() async {
|
||||
started = await api.dealerLogin(phone);
|
||||
});
|
||||
return started;
|
||||
}
|
||||
|
||||
Future<String> pollDealerLogin(String requestId, String secret) async {
|
||||
final status = await api.dealerLoginStatus(requestId, secret);
|
||||
if (status.status == 'approved' && status.token != null && status.user != null) {
|
||||
await _storeSession(status.token!, status.user!);
|
||||
notifyListeners();
|
||||
} else if (status.status == 'rejected') {
|
||||
error = 'Login was rejected';
|
||||
notifyListeners();
|
||||
} else if (status.status == 'expired' || status.status == 'consumed') {
|
||||
error = 'Login request expired. Try again.';
|
||||
notifyListeners();
|
||||
}
|
||||
return status.status;
|
||||
}
|
||||
|
||||
Future<void> _storeSession(String token, AppUser profile) async {
|
||||
api.token = token;
|
||||
await _storage.write(key: 'jwt_token', value: token);
|
||||
user = profile;
|
||||
}
|
||||
|
||||
Future<void> refreshProfile() async {
|
||||
if (api.token == null) return;
|
||||
user = await api.me();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
api.token = null;
|
||||
user = null;
|
||||
error = null;
|
||||
await _storage.delete(key: 'jwt_token');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _run(Future<void> Function() action) async {
|
||||
busy = true;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
await action();
|
||||
} on ApiException catch (error) {
|
||||
this.error = error.message;
|
||||
rethrow;
|
||||
} catch (error) {
|
||||
this.error = 'Network request failed';
|
||||
rethrow;
|
||||
} finally {
|
||||
busy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
ThemeData buildAppTheme() {
|
||||
const seed = Color(0xFF0E4D6C);
|
||||
final scheme = ColorScheme.fromSeed(seedColor: seed);
|
||||
return ThemeData(
|
||||
colorScheme: scheme,
|
||||
useMaterial3: true,
|
||||
scaffoldBackgroundColor: const Color(0xFFF4F7F8),
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color badgeColor(String badge) {
|
||||
switch (badge) {
|
||||
case 'green':
|
||||
return const Color(0xFF1B7F4E);
|
||||
case 'yellow':
|
||||
return const Color(0xFF8A6A12);
|
||||
case 'red':
|
||||
return const Color(0xFFB42318);
|
||||
default:
|
||||
return const Color(0xFF5C6B73);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/api_client.dart';
|
||||
|
||||
void showError(BuildContext context, Object error) {
|
||||
final message = switch (error) {
|
||||
ApiException(:final message) => message,
|
||||
String message => message,
|
||||
_ => 'Something went wrong',
|
||||
};
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
Future<void> showNotice(BuildContext context, String message) {
|
||||
return ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(message)))
|
||||
.closed;
|
||||
}
|
||||
Reference in New Issue
Block a user