770 lines
24 KiB
Dart
770 lines
24 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
import '../../../core/models/analytics.dart';
|
|
import '../../../core/models/reservation.dart';
|
|
import '../../../core/providers/auth_provider.dart';
|
|
import '../../../widgets/error_view.dart';
|
|
import '../../../widgets/reservation_card.dart';
|
|
import '../../../widgets/stat_card.dart';
|
|
import '../providers/dashboard_providers.dart';
|
|
import 'dashboard_shell.dart';
|
|
|
|
class HomeScreen extends ConsumerWidget {
|
|
const HomeScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
final employee = ref.watch(authProvider).employee;
|
|
final canViewRevenue = employee?.role != 'AGENT';
|
|
final metricsAsync = ref.watch(dashboardMetricsProvider);
|
|
final fallbackRecentAsync = ref.watch(
|
|
reservationsProvider((
|
|
page: 1,
|
|
pageSize: 5,
|
|
status: null,
|
|
source: null,
|
|
search: null,
|
|
customerId: null,
|
|
)),
|
|
);
|
|
final onlineAsync = ref.watch(
|
|
reservationsProvider((
|
|
page: 1,
|
|
pageSize: 100,
|
|
status: 'DRAFT',
|
|
source: 'MARKETPLACE',
|
|
search: null,
|
|
customerId: null,
|
|
)),
|
|
);
|
|
final pendingOnline = onlineAsync.valueOrNull?.data.length ?? 0;
|
|
final unreadCount = ref.watch(unreadCountProvider).valueOrNull ?? 0;
|
|
|
|
return DashboardShell(
|
|
appBar: AppBar(
|
|
title: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Dashboard',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
Text(
|
|
DateFormat('EEEE, MMMM d').format(DateTime.now()),
|
|
style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: unreadCount > 0
|
|
? Badge(
|
|
label: Text('$unreadCount'),
|
|
child: const Icon(Icons.notifications_outlined),
|
|
)
|
|
: const Icon(Icons.notifications_outlined),
|
|
onPressed: () => context.push('/dashboard/notifications'),
|
|
),
|
|
_AvatarMenu(employee: employee),
|
|
],
|
|
),
|
|
body: RefreshIndicator(
|
|
onRefresh: () async {
|
|
ref.invalidate(dashboardMetricsProvider);
|
|
ref.invalidate(reservationsProvider);
|
|
},
|
|
child: metricsAsync.when(
|
|
loading: () => ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: const [
|
|
SizedBox(height: 240, child: Center(child: CircularProgressIndicator())),
|
|
],
|
|
),
|
|
error: (e, _) => ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
ErrorView(
|
|
message: 'Could not load dashboard',
|
|
onRetry: () => ref.invalidate(dashboardMetricsProvider),
|
|
),
|
|
],
|
|
),
|
|
data: (metrics) => ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
if (metrics.subscription != null &&
|
|
metrics.subscription!.status != 'ACTIVE')
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
child: _SubscriptionBanner(
|
|
subscription: metrics.subscription!,
|
|
),
|
|
),
|
|
_KpiGrid(
|
|
kpis: metrics.kpis,
|
|
canViewRevenue: canViewRevenue,
|
|
),
|
|
if (pendingOnline > 0) ...[
|
|
const SizedBox(height: 16),
|
|
InkWell(
|
|
onTap: () => context.push('/dashboard/online-reservations'),
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 14,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFFFF3CD),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: const Color(0xFFFF6B00)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.public,
|
|
color: Color(0xFFFF6B00),
|
|
size: 22,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
'$pendingOnline online reservation${pendingOnline == 1 ? '' : 's'} pending approval',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
color: Color(0xFF92400E),
|
|
),
|
|
),
|
|
),
|
|
const Icon(Icons.chevron_right, color: Color(0xFFFF6B00)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 16),
|
|
_SourceBreakdownSection(
|
|
sourceBreakdown: metrics.sourceBreakdown,
|
|
canViewRevenue: canViewRevenue,
|
|
),
|
|
const SizedBox(height: 16),
|
|
_RecentReservationsSection(
|
|
analyticsReservations: metrics.recentReservations,
|
|
fallbackReservations: fallbackRecentAsync.valueOrNull?.data ?? const [],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _KpiGrid extends StatelessWidget {
|
|
final DashboardKpis kpis;
|
|
final bool canViewRevenue;
|
|
|
|
const _KpiGrid({
|
|
required this.kpis,
|
|
required this.canViewRevenue,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cards = <Widget>[
|
|
StatCard(
|
|
title: 'Total Bookings',
|
|
value: NumberFormat.decimalPattern().format(kpis.totalBookings),
|
|
icon: Icons.calendar_today_outlined,
|
|
color: const Color(0xFF1A56DB),
|
|
change: kpis.bookingsChange,
|
|
footerLabel: 'vs last month',
|
|
),
|
|
StatCard(
|
|
title: 'Active Vehicles',
|
|
value: NumberFormat.decimalPattern().format(kpis.activeVehicles),
|
|
icon: Icons.directions_car_outlined,
|
|
color: const Color(0xFF0E9F6E),
|
|
change: kpis.vehiclesChange,
|
|
footerLabel: 'vs last month',
|
|
),
|
|
if (canViewRevenue)
|
|
StatCard(
|
|
title: 'Monthly Revenue',
|
|
value: _formatMoney(kpis.monthlyRevenue),
|
|
icon: Icons.attach_money,
|
|
color: const Color(0xFFFF6B00),
|
|
change: kpis.revenueChange,
|
|
footerLabel: 'vs last month',
|
|
),
|
|
StatCard(
|
|
title: 'Total Customers',
|
|
value: NumberFormat.decimalPattern().format(kpis.totalCustomers),
|
|
icon: Icons.people_outline,
|
|
color: const Color(0xFF7C3AED),
|
|
change: kpis.customersChange,
|
|
footerLabel: 'vs last month',
|
|
),
|
|
];
|
|
|
|
return GridView.builder(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 2,
|
|
mainAxisExtent: 170,
|
|
crossAxisSpacing: 12,
|
|
mainAxisSpacing: 12,
|
|
),
|
|
itemCount: cards.length,
|
|
itemBuilder: (_, i) => cards[i],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SourceBreakdownSection extends StatelessWidget {
|
|
final List<DashboardSourceBreakdown> sourceBreakdown;
|
|
final bool canViewRevenue;
|
|
|
|
const _SourceBreakdownSection({
|
|
required this.sourceBreakdown,
|
|
required this.canViewRevenue,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
final maxCount = sourceBreakdown.isEmpty
|
|
? 1
|
|
: sourceBreakdown.map((e) => e.count).reduce(math.max);
|
|
|
|
return Column(
|
|
children: [
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Booking Sources',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
color: cs.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
if (sourceBreakdown.isEmpty)
|
|
Text(
|
|
'No booking data yet.',
|
|
style: TextStyle(color: cs.onSurfaceVariant),
|
|
)
|
|
else
|
|
...sourceBreakdown.map(
|
|
(item) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
_sourceLabel(item.source),
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
color: cs.onSurface,
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
'${item.count} bookings',
|
|
style: TextStyle(color: cs.onSurfaceVariant),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
LinearProgressIndicator(
|
|
value: maxCount == 0 ? 0 : item.count / maxCount,
|
|
minHeight: 8,
|
|
borderRadius: BorderRadius.circular(99),
|
|
backgroundColor: cs.surfaceContainerHighest,
|
|
valueColor: const AlwaysStoppedAnimation<Color>(
|
|
Color(0xFF1A56DB),
|
|
),
|
|
),
|
|
if (canViewRevenue) ...[
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
_formatMoney(item.revenue),
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: cs.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (sourceBreakdown.isNotEmpty) ...[
|
|
const SizedBox(height: 12),
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Quick Stats',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
color: cs.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
...sourceBreakdown.map(
|
|
(item) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
_sourceLabel(item.source),
|
|
style: TextStyle(color: cs.onSurfaceVariant),
|
|
),
|
|
),
|
|
Text(
|
|
'${item.count}',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
color: cs.onSurface,
|
|
),
|
|
),
|
|
if (canViewRevenue) ...[
|
|
const SizedBox(width: 10),
|
|
Text(
|
|
_formatMoney(item.revenue),
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: cs.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RecentReservationsSection extends StatelessWidget {
|
|
final List<DashboardRecentReservation> analyticsReservations;
|
|
final List<Reservation> fallbackReservations;
|
|
|
|
const _RecentReservationsSection({
|
|
required this.analyticsReservations,
|
|
required this.fallbackReservations,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'Recent Reservations',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
color: cs.onSurface,
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () => context.go('/dashboard/reservations'),
|
|
child: const Text('View all'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (analyticsReservations.isNotEmpty)
|
|
...analyticsReservations.map(
|
|
(reservation) => _DashboardRecentReservationCard(
|
|
reservation: reservation,
|
|
),
|
|
)
|
|
else if (fallbackReservations.isNotEmpty)
|
|
...fallbackReservations.map(
|
|
(reservation) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: ReservationCard(
|
|
reservation: reservation,
|
|
onTap: () =>
|
|
context.push('/dashboard/reservations/${reservation.id}'),
|
|
),
|
|
),
|
|
)
|
|
else
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
child: Text(
|
|
'No reservations yet',
|
|
style: TextStyle(color: cs.onSurfaceVariant),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DashboardRecentReservationCard extends StatelessWidget {
|
|
final DashboardRecentReservation reservation;
|
|
|
|
const _DashboardRecentReservationCard({required this.reservation});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
final dateFmt = DateFormat('MMM d');
|
|
|
|
return InkWell(
|
|
onTap: () => context.push('/dashboard/reservations/${reservation.id}'),
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: cs.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: cs.outlineVariant),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
'#${reservation.bookingRef}',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
color: Color(0xFF1A56DB),
|
|
),
|
|
),
|
|
),
|
|
_StatusPill(status: reservation.status),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
reservation.customerName,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
color: cs.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
reservation.vehicleName,
|
|
style: TextStyle(color: cs.onSurfaceVariant),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
'${dateFmt.format(reservation.startDate)} - ${DateFormat('MMM d, yyyy').format(reservation.endDate)}',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: cs.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
_formatMoney(reservation.totalAmount),
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
color: cs.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SubscriptionBanner extends StatelessWidget {
|
|
final DashboardSubscription subscription;
|
|
|
|
const _SubscriptionBanner({required this.subscription});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final trialEnds = subscription.trialEndsAt;
|
|
late final Color borderColor;
|
|
late final Color backgroundColor;
|
|
late final Color foregroundColor;
|
|
late final IconData icon;
|
|
late final String message;
|
|
|
|
switch (subscription.status) {
|
|
case 'TRIALING':
|
|
borderColor = const Color(0xFF93C5FD);
|
|
backgroundColor = const Color(0xFFEFF6FF);
|
|
foregroundColor = const Color(0xFF1D4ED8);
|
|
icon = Icons.schedule_rounded;
|
|
message = trialEnds == null
|
|
? 'Trial active'
|
|
: 'Trial ends ${DateFormat('MMM d, yyyy').format(trialEnds)}';
|
|
break;
|
|
case 'PAST_DUE':
|
|
borderColor = const Color(0xFFFBBF24);
|
|
backgroundColor = const Color(0xFFFFFBEB);
|
|
foregroundColor = const Color(0xFFB45309);
|
|
icon = Icons.warning_amber_rounded;
|
|
message = 'Subscription payment is past due';
|
|
break;
|
|
default:
|
|
borderColor = const Color(0xFFFCA5A5);
|
|
backgroundColor = const Color(0xFFFEF2F2);
|
|
foregroundColor = const Color(0xFFB91C1C);
|
|
icon = Icons.block_rounded;
|
|
message = 'Subscription is suspended';
|
|
break;
|
|
}
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: backgroundColor,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: borderColor),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, color: foregroundColor),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
message,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
color: foregroundColor,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatusPill extends StatelessWidget {
|
|
final String status;
|
|
|
|
const _StatusPill({required this.status});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final normalized = status.toUpperCase();
|
|
late final Color bg;
|
|
late final Color fg;
|
|
|
|
switch (normalized) {
|
|
case 'CONFIRMED':
|
|
bg = const Color(0xFFDBEAFE);
|
|
fg = const Color(0xFF1D4ED8);
|
|
break;
|
|
case 'PENDING':
|
|
case 'DRAFT':
|
|
bg = const Color(0xFFFEF3C7);
|
|
fg = const Color(0xFFB45309);
|
|
break;
|
|
case 'ACTIVE':
|
|
bg = const Color(0xFFDCFCE7);
|
|
fg = const Color(0xFF15803D);
|
|
break;
|
|
case 'CANCELLED':
|
|
bg = const Color(0xFFFEE2E2);
|
|
fg = const Color(0xFFB91C1C);
|
|
break;
|
|
default:
|
|
bg = const Color(0xFFE5E7EB);
|
|
fg = const Color(0xFF374151);
|
|
break;
|
|
}
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: bg,
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Text(
|
|
normalized,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
color: fg,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AvatarMenu extends ConsumerWidget {
|
|
final dynamic employee;
|
|
|
|
const _AvatarMenu({required this.employee});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
final initial = employee?.firstName.substring(0, 1).toUpperCase() ?? 'U';
|
|
final name = employee?.fullName ?? 'Employee';
|
|
final role = employee?.role ?? '';
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: PopupMenuButton<String>(
|
|
onSelected: (value) async {
|
|
if (value == 'logout') {
|
|
final confirm = await showDialog<bool>(
|
|
context: context,
|
|
builder: (_) => AlertDialog(
|
|
title: const Text('Sign out'),
|
|
content: const Text('Are you sure you want to sign out?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: const Color(0xFFE02424),
|
|
),
|
|
child: const Text('Sign out'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirm == true) {
|
|
await ref.read(authProvider.notifier).logout();
|
|
if (context.mounted) context.go('/landing');
|
|
}
|
|
}
|
|
},
|
|
offset: const Offset(0, 44),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
itemBuilder: (_) => [
|
|
PopupMenuItem<String>(
|
|
enabled: false,
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
name,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
color: cs.onSurface,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
if (role.isNotEmpty)
|
|
Text(
|
|
role,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: cs.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
const Divider(height: 1),
|
|
],
|
|
),
|
|
),
|
|
const PopupMenuItem<String>(
|
|
value: 'logout',
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.logout, size: 18, color: Color(0xFFE02424)),
|
|
SizedBox(width: 10),
|
|
Text('Sign out', style: TextStyle(color: Color(0xFFE02424))),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
child: CircleAvatar(
|
|
backgroundColor: const Color(0xFF1A56DB),
|
|
radius: 18,
|
|
child: Text(
|
|
initial,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _formatMoney(double amount) {
|
|
return NumberFormat.currency(
|
|
name: 'MAD',
|
|
symbol: 'MAD ',
|
|
decimalDigits: 2,
|
|
).format(amount / 100);
|
|
}
|
|
|
|
String _sourceLabel(String source) {
|
|
switch (source.toUpperCase()) {
|
|
case 'MARKETPLACE':
|
|
return 'Marketplace';
|
|
case 'WALK_IN':
|
|
return 'Walk-in';
|
|
case 'DIRECT':
|
|
return 'Direct';
|
|
default:
|
|
return source.replaceAll('_', ' ');
|
|
}
|
|
}
|