320 lines
9.7 KiB
Dart
320 lines
9.7 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 StatelessWidget {
|
|
const Dashboard({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final data = context.watch<DataService>();
|
|
final auth = context.watch<AuthService>();
|
|
final stats = data.homepageStats;
|
|
|
|
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 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),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
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',
|
|
trailing: data.isOnThisDayLoading
|
|
? const SizedBox(
|
|
height: 18,
|
|
width: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: null,
|
|
child: _buildLegList(
|
|
context,
|
|
data.onThisDay,
|
|
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,
|
|
}) {
|
|
if (legs.isEmpty) {
|
|
return Text(
|
|
emptyMessage,
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
);
|
|
}
|
|
return Column(
|
|
children: legs.take(5).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 = data.trips;
|
|
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')}';
|
|
}
|
|
}
|