Files
mileograph_flutter/lib/components/pages/dashboard.dart
Pete Gregory f0dfbd185b
All checks were successful
Release / meta (push) Successful in 22s
Release / linux-build (push) Successful in 4m32s
Release / android-build (push) Successful in 7m10s
Release / release-dev (push) Successful in 9s
Release / release-master (push) Successful in 9s
QoL changes
2025-12-14 09:45:32 +00:00

366 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 trips_unsorted = data.trips;
List trips = [];
if (trips_unsorted.isNotEmpty) {
trips = [...trips_unsorted]..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')}';
}
}