368 lines
11 KiB
Dart
368 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:mileograph_flutter/components/dashboard/leaderboardPanel.dart';
|
|
import 'package:mileograph_flutter/components/dashboard/topTractionPanel.dart';
|
|
import 'package:mileograph_flutter/objects/objects.dart';
|
|
import 'package:mileograph_flutter/services/authservice.dart';
|
|
import 'package:mileograph_flutter/services/dataService.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
class Dashboard extends StatefulWidget {
|
|
const Dashboard({super.key});
|
|
|
|
@override
|
|
State<Dashboard> createState() => _DashboardState();
|
|
}
|
|
|
|
class _DashboardState extends State<Dashboard> {
|
|
bool _showAllOnThisDay = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final data = context.watch<DataService>();
|
|
final auth = context.watch<AuthService>();
|
|
final stats = data.homepageStats;
|
|
|
|
final isInitialLoading = data.isHomepageLoading || stats == null;
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: () async {
|
|
await data.fetchHomepageStats();
|
|
await Future.wait([
|
|
data.fetchOnThisDay(),
|
|
data.fetchTripDetails(),
|
|
data.fetchHadTraction(),
|
|
]);
|
|
},
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final isWide = constraints.maxWidth > 1100;
|
|
final metricChips = _buildMetricChips(
|
|
context,
|
|
totalMileage: stats?.totalMileage ?? 0,
|
|
currentYearMileage: data.getMileageForCurrentYear(),
|
|
trips: data.trips.length,
|
|
);
|
|
return Stack(
|
|
children: [
|
|
ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
_buildHeader(context, auth, stats, data.isHomepageLoading),
|
|
const SizedBox(height: 12),
|
|
Wrap(spacing: 12, runSpacing: 12, children: metricChips),
|
|
const SizedBox(height: 16),
|
|
isWide
|
|
? Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(child: _buildMainColumn(context, data)),
|
|
const SizedBox(width: 16),
|
|
SizedBox(
|
|
width: 360,
|
|
child: _buildSidebar(context, data),
|
|
),
|
|
],
|
|
)
|
|
: Column(
|
|
children: [
|
|
_buildMainColumn(context, data),
|
|
const SizedBox(height: 16),
|
|
_buildSidebar(context, data),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
if (isInitialLoading)
|
|
Positioned.fill(
|
|
child: Container(
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.surface.withOpacity(0.7),
|
|
child: const Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
CircularProgressIndicator(),
|
|
SizedBox(height: 12),
|
|
Text('Loading dashboard data...'),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader(
|
|
BuildContext context,
|
|
AuthService auth,
|
|
HomepageStats? stats,
|
|
bool loading,
|
|
) {
|
|
final greetingName =
|
|
stats?.user?.full_name ?? auth.fullName ?? auth.username ?? 'there';
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Dashboard', style: Theme.of(context).textTheme.labelMedium),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'Welcome back, $greetingName',
|
|
style: Theme.of(context).textTheme.headlineSmall,
|
|
),
|
|
],
|
|
),
|
|
if (loading)
|
|
const Padding(
|
|
padding: EdgeInsets.only(right: 8.0),
|
|
child: SizedBox(
|
|
height: 24,
|
|
width: 24,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildMetricChips(
|
|
BuildContext context, {
|
|
required double totalMileage,
|
|
required double currentYearMileage,
|
|
required int trips,
|
|
}) {
|
|
final textTheme = Theme.of(context).textTheme;
|
|
Widget metricCard(String label, String value) {
|
|
return Card(
|
|
elevation: 1,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
label.toUpperCase(),
|
|
style: textTheme.labelSmall?.copyWith(
|
|
letterSpacing: 0.7,
|
|
color: textTheme.bodySmall?.color?.withOpacity(0.7),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
value,
|
|
style: textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return [
|
|
metricCard('Total mileage', '${totalMileage.toStringAsFixed(1)} mi'),
|
|
metricCard('This year', '${currentYearMileage.toStringAsFixed(1)} mi'),
|
|
metricCard('Trips logged', trips.toString()),
|
|
];
|
|
}
|
|
|
|
Widget _buildMainColumn(BuildContext context, DataService data) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_buildCard(
|
|
context,
|
|
title: 'On this day',
|
|
action:
|
|
data.onThisDay
|
|
.where((leg) => leg.beginTime.year != DateTime.now().year)
|
|
.length >
|
|
5
|
|
? TextButton(
|
|
onPressed: () => setState(() {
|
|
_showAllOnThisDay = !_showAllOnThisDay;
|
|
}),
|
|
child: Text(_showAllOnThisDay ? 'Show less' : 'Show more'),
|
|
)
|
|
: null,
|
|
trailing: data.isOnThisDayLoading
|
|
? const SizedBox(
|
|
height: 18,
|
|
width: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: null,
|
|
child: _buildLegList(
|
|
context,
|
|
data.onThisDay,
|
|
showAll: _showAllOnThisDay,
|
|
emptyMessage: 'No historical moves for today yet.',
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildQuickCalcCard(context),
|
|
const SizedBox(height: 12),
|
|
_buildTripsCard(context, data),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildSidebar(BuildContext context, DataService data) {
|
|
return Column(
|
|
children: [
|
|
TopTractionPanel(),
|
|
const SizedBox(height: 12),
|
|
LeaderboardPanel(),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildCard(
|
|
BuildContext context, {
|
|
required String title,
|
|
required Widget child,
|
|
Widget? trailing,
|
|
Widget? action,
|
|
}) {
|
|
return Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (action != null) action,
|
|
if (trailing != null) ...[
|
|
const SizedBox(width: 8),
|
|
trailing,
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
child,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLegList(
|
|
BuildContext context,
|
|
List<Leg> legs, {
|
|
required String emptyMessage,
|
|
bool showAll = false,
|
|
}) {
|
|
final filtered = legs
|
|
.where((leg) => leg.beginTime.year != DateTime.now().year)
|
|
.toList();
|
|
if (filtered.isEmpty) {
|
|
return Text(emptyMessage, style: Theme.of(context).textTheme.bodyMedium);
|
|
}
|
|
final toShow = showAll ? filtered : filtered.take(5).toList();
|
|
return Column(
|
|
children: toShow.map((leg) {
|
|
return ListTile(
|
|
dense: true,
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: CircleAvatar(
|
|
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
|
child: const Icon(Icons.train),
|
|
),
|
|
title: Text('${leg.start} → ${leg.end}'),
|
|
subtitle: Text(_formatDate(leg.beginTime)),
|
|
trailing: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text('${leg.mileage.toStringAsFixed(1)} mi'),
|
|
if (leg.headcode.isNotEmpty)
|
|
Text(
|
|
leg.headcode,
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: Theme.of(context).hintColor,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
Widget _buildQuickCalcCard(BuildContext context) {
|
|
return _buildCard(
|
|
context,
|
|
title: 'Quick mileage calculator',
|
|
action: TextButton.icon(
|
|
onPressed: () => context.push('/calculator'),
|
|
icon: const Icon(Icons.open_in_new),
|
|
label: const Text('Open calculator'),
|
|
),
|
|
child: Text(
|
|
'Jump into the route calculator to quickly total a journey before saving it.',
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTripsCard(BuildContext context, DataService data) {
|
|
final tripsUnsorted = data.trips;
|
|
List trips = [];
|
|
if (tripsUnsorted.isNotEmpty) {
|
|
trips = [...tripsUnsorted]..sort((a, b) => b.tripId.compareTo(a.tripId));
|
|
}
|
|
return _buildCard(
|
|
context,
|
|
title: 'Trips',
|
|
action: TextButton(
|
|
onPressed: () => context.push('/trips'),
|
|
child: const Text('View all'),
|
|
),
|
|
child: trips.isEmpty
|
|
? Text(
|
|
'No trips logged yet. Add one from the Trips page.',
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
)
|
|
: Column(
|
|
children: trips.take(5).map((trip) {
|
|
return ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(trip.tripName),
|
|
subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatDate(DateTime? dt) {
|
|
if (dt == null) return '';
|
|
return '${dt.year.toString().padLeft(4, '0')}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
|
}
|
|
}
|