add dashboard to phone app
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../providers/dashboard_providers.dart';
|
||||
import '../../../widgets/error_view.dart';
|
||||
|
||||
class ReportsScreen extends ConsumerStatefulWidget {
|
||||
const ReportsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ReportsScreen> createState() => _ReportsScreenState();
|
||||
}
|
||||
|
||||
class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
DateTime _start = DateTime.now().subtract(const Duration(days: 30));
|
||||
DateTime _end = DateTime.now();
|
||||
String? _statusFilter;
|
||||
|
||||
Map<String, String> get _params => {
|
||||
'startDate':
|
||||
'${_start.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
||||
'endDate': '${_end.toIso8601String().substring(0, 10)}T23:59:59.000Z',
|
||||
'status': ?_statusFilter,
|
||||
};
|
||||
|
||||
Future<void> _pickRange() async {
|
||||
final range = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime.now(),
|
||||
initialDateRange: DateTimeRange(start: _start, end: _end),
|
||||
);
|
||||
if (range != null) {
|
||||
setState(() {
|
||||
_start = range.start;
|
||||
_end = range.end;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fmt = DateFormat('MMM d, yyyy');
|
||||
final fmtMoney = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
|
||||
final reportAsync = ref.watch(reportProvider(_params));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Reports'),
|
||||
actions: [
|
||||
PopupMenuButton<String?>(
|
||||
icon: Icon(
|
||||
Icons.filter_list,
|
||||
color: _statusFilter != null ? const Color(0xFF1A56DB) : null,
|
||||
),
|
||||
onSelected: (v) => setState(() => _statusFilter = v),
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(value: null, child: Text('All statuses')),
|
||||
PopupMenuItem(value: 'CONFIRMED', child: Text('Confirmed')),
|
||||
PopupMenuItem(value: 'ACTIVE', child: Text('Active')),
|
||||
PopupMenuItem(value: 'CLOSED', child: Text('Closed')),
|
||||
PopupMenuItem(value: 'CANCELLED', child: Text('Cancelled')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_DateRangeBar(
|
||||
start: _start,
|
||||
end: _end,
|
||||
fmt: fmt,
|
||||
onTap: _pickRange,
|
||||
),
|
||||
Expanded(
|
||||
child: reportAsync.when(
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => ErrorView(
|
||||
message: 'Failed to load report',
|
||||
onRetry: () => ref.invalidate(reportProvider(_params)),
|
||||
),
|
||||
data: (report) => CustomScrollView(
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MetricCard(
|
||||
label: 'Reservations',
|
||||
value:
|
||||
report.totalReservations.toString(),
|
||||
icon: Icons.event_note,
|
||||
color: const Color(0xFF1A56DB),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _MetricCard(
|
||||
label: 'Revenue',
|
||||
value: fmtMoney
|
||||
.format(report.rentalRevenue / 100),
|
||||
icon: Icons.attach_money,
|
||||
color: const Color(0xFF0E9F6E),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MetricCard(
|
||||
label: 'Collected',
|
||||
value: fmtMoney
|
||||
.format(report.totalPaid / 100),
|
||||
icon: Icons.check_circle_outline,
|
||||
color: const Color(0xFF0E9F6E),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _MetricCard(
|
||||
label: 'Outstanding',
|
||||
value: fmtMoney
|
||||
.format(report.totalOutstanding / 100),
|
||||
icon: Icons.pending_outlined,
|
||||
color: const Color(0xFFE02424),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (report.items.isNotEmpty) ...[
|
||||
const SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Text(
|
||||
'Reservations',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverList.separated(
|
||||
itemCount: report.items.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const Divider(height: 1),
|
||||
itemBuilder: (_, i) {
|
||||
final item = report.items[i];
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
item.customerName ?? 'Walk-in',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${item.vehicleName ?? 'Vehicle'} • ${fmt.format(item.startDate)} – ${fmt.format(item.endDate)}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
trailing: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
fmtMoney.format(item.totalAmount / 100),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
item.paymentStatus,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: item.paymentStatus == 'PAID'
|
||||
? const Color(0xFF0E9F6E)
|
||||
: const Color(0xFF9CA3AF),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DateRangeBar extends StatelessWidget {
|
||||
final DateTime start;
|
||||
final DateTime end;
|
||||
final DateFormat fmt;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _DateRangeBar({
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.fmt,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF9FAFB),
|
||||
border: Border(bottom: BorderSide(color: Color(0xFFE5E7EB))),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.date_range_outlined,
|
||||
size: 18, color: Color(0xFF6B7280)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${fmt.format(start)} → ${fmt.format(end)}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const Spacer(),
|
||||
const Text('Change',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF1A56DB), fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetricCard extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _MetricCard({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: color),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user