Compare commits
1 Commits
v0.3.0-dev
...
v0.3.1-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| b1a8f7baf4 |
@@ -38,9 +38,25 @@ class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Latest loco changes',
|
||||
style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.bolt, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Latest loco changes',
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isLoading && changes.isNotEmpty)
|
||||
const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (isLoading && changes.isEmpty)
|
||||
|
||||
@@ -10,6 +10,7 @@ class LeaderboardPanel extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final data = context.watch<DataService>();
|
||||
final leaderboard = data.homepageStats?.leaderboard ?? [];
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
if (data.isHomepageLoading && leaderboard.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
@@ -23,12 +24,32 @@ class LeaderboardPanel extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
"Leaderboard",
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.emoji_events, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"Leaderboard",
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (leaderboard.isNotEmpty)
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'Top ${leaderboard.length}',
|
||||
style: textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (leaderboard.isEmpty)
|
||||
@@ -38,43 +59,38 @@ class LeaderboardPanel extends StatelessWidget {
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: List.generate(
|
||||
leaderboard.length,
|
||||
(index) {
|
||||
final leaderboardEntry = leaderboard[index];
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 0, vertical: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${index + 1}. ',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: leaderboardEntry.userFullName,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${leaderboardEntry.mileage.toStringAsFixed(1)} mi',
|
||||
),
|
||||
],
|
||||
children: [
|
||||
for (int index = 0; index < leaderboard.length; index++) ...[
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.secondaryContainer,
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
leaderboard[index].userFullName,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
'${leaderboard[index].mileage.toStringAsFixed(1)} mi',
|
||||
style: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (index != leaderboard.length - 1) const Divider(height: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ class TopTractionPanel extends StatelessWidget {
|
||||
final data = context.watch<DataService>();
|
||||
final stats = data.homepageStats;
|
||||
final locos = stats?.topLocos ?? [];
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
if (data.isHomepageLoading && locos.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
@@ -24,12 +25,19 @@ class TopTractionPanel extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
"Top Traction",
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.train, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"Top traction",
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (locos.isEmpty)
|
||||
@@ -39,52 +47,46 @@ class TopTractionPanel extends StatelessWidget {
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: List.generate(
|
||||
locos.length,
|
||||
(index) {
|
||||
final loco = locos[index];
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin:
|
||||
const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${index + 1}. ',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text:
|
||||
'${loco.locoClass} ${loco.number}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
loco.name ?? '',
|
||||
style:
|
||||
const TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text('${loco.mileage?.toStringAsFixed(1)} mi'),
|
||||
],
|
||||
children: [
|
||||
for (int index = 0; index < locos.length; index++) ...[
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
'${locos[index].locoClass} ${locos[index].number}',
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
subtitle: (locos[index].name ?? '').isEmpty
|
||||
? null
|
||||
: Text(
|
||||
locos[index].name ?? '',
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
trailing: Text(
|
||||
'${locos[index].mileage?.toStringAsFixed(1)} mi',
|
||||
style: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (index != locos.length - 1) const Divider(height: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:mileograph_flutter/components/dashboard/latest_loco_changes_panel.dart';
|
||||
import 'package:mileograph_flutter/components/dashboard/leaderboard_panel.dart';
|
||||
import 'package:mileograph_flutter/components/dashboard/top_traction_panel.dart';
|
||||
@@ -38,41 +39,16 @@ class _DashboardState extends State<Dashboard> {
|
||||
},
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth > 1100;
|
||||
final metricChips = _buildMetricChips(
|
||||
context,
|
||||
totalMileage: stats?.totalMileage ?? 0,
|
||||
currentYearMileage: data.getMileageForCurrentYear(),
|
||||
legCount: stats?.legCount ?? data.trips.length,
|
||||
);
|
||||
const spacing = 16.0;
|
||||
final maxWidth = constraints.maxWidth;
|
||||
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),
|
||||
],
|
||||
),
|
||||
_buildHero(context, auth, data, stats),
|
||||
const SizedBox(height: spacing),
|
||||
_buildTiles(context, data, maxWidth, spacing),
|
||||
],
|
||||
),
|
||||
if (isInitialLoading)
|
||||
@@ -100,139 +76,451 @@ class _DashboardState extends State<Dashboard> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(
|
||||
Widget _buildHero(
|
||||
BuildContext context,
|
||||
AuthService auth,
|
||||
DataService data,
|
||||
HomepageStats? stats,
|
||||
bool loading,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isCompact = MediaQuery.of(context).size.width < 720;
|
||||
final greetingName =
|
||||
stats?.user?.fullName ?? auth.fullName ?? auth.username ?? 'there';
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
final totalMileage = stats?.totalMileage ?? 0;
|
||||
final currentYearMileage = data.getMileageForCurrentYear();
|
||||
final legCount = stats?.legCount ?? data.trips.length;
|
||||
final progress = totalMileage == 0
|
||||
? 0.0
|
||||
: (currentYearMileage / totalMileage).clamp(0, 1).toDouble();
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
elevation: 2,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
colorScheme.primaryContainer,
|
||||
colorScheme.secondaryContainer,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Dashboard', style: Theme.of(context).textTheme.labelMedium),
|
||||
const SizedBox(height: 2),
|
||||
isCompact
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_heroHeading(context, greetingName, colorScheme),
|
||||
const SizedBox(height: 12),
|
||||
_heroActions(context, colorScheme, wrap: true),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _heroHeading(context, greetingName, colorScheme),
|
||||
),
|
||||
_heroActions(context, colorScheme, wrap: false),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_metricTile(
|
||||
context,
|
||||
label: 'Total mileage',
|
||||
value: '${totalMileage.toStringAsFixed(1)} mi',
|
||||
icon: Icons.route,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
_metricTile(
|
||||
context,
|
||||
label: 'This year',
|
||||
value: '${currentYearMileage.toStringAsFixed(1)} mi',
|
||||
icon: Icons.calendar_today,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
_metricTile(
|
||||
context,
|
||||
label: 'Entries logged',
|
||||
value: legCount.toString(),
|
||||
icon: Icons.format_list_bulleted,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress.isNaN ? 0 : progress,
|
||||
minHeight: 10,
|
||||
backgroundColor: colorScheme.onPrimaryContainer.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Welcome back, $greetingName',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
totalMileage == 0
|
||||
? 'Log a new entry to start your timeline.'
|
||||
: 'Year-to-date is ${(progress * 100).toStringAsFixed(0)}% of all mileage.',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (loading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 8.0),
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildMetricChips(
|
||||
Widget _metricTile(
|
||||
BuildContext context, {
|
||||
required double totalMileage,
|
||||
required double currentYearMileage,
|
||||
required int legCount,
|
||||
required String label,
|
||||
required String value,
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
}) {
|
||||
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(
|
||||
final bg = Colors.white.withValues(alpha: 0.14);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.18)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: color),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label.toUpperCase(),
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
letterSpacing: 0.7,
|
||||
color: textTheme.bodySmall?.color?.withValues(alpha: 0.7),
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: color.withValues(alpha: 0.85),
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
metricCard('Total mileage', '${totalMileage.toStringAsFixed(1)} mi'),
|
||||
metricCard('This year', '${currentYearMileage.toStringAsFixed(1)} mi'),
|
||||
metricCard('Entries logged', legCount.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),
|
||||
_buildTripsCard(context, data),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSidebar(BuildContext context, DataService data) {
|
||||
Widget _buildTiles(
|
||||
BuildContext context,
|
||||
DataService data,
|
||||
double maxWidth,
|
||||
double spacing,
|
||||
) {
|
||||
final isWide = maxWidth >= 1200;
|
||||
|
||||
if (isWide) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildOnThisDayCard(context, data),
|
||||
const SizedBox(height: 16),
|
||||
_buildTripsCard(context, data),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: const [
|
||||
TopTractionPanel(),
|
||||
SizedBox(height: 16),
|
||||
LeaderboardPanel(),
|
||||
SizedBox(height: 16),
|
||||
LatestLocoChangesPanel(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildOnThisDayCard(context, data),
|
||||
const SizedBox(height: 16),
|
||||
const TopTractionPanel(),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 16),
|
||||
const LeaderboardPanel(),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 16),
|
||||
_buildTripsCard(context, data),
|
||||
const SizedBox(height: 16),
|
||||
const LatestLocoChangesPanel(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCard(
|
||||
Widget _heroHeading(
|
||||
BuildContext context,
|
||||
String greetingName,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Dashboard',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Welcome back, $greetingName',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _heroActions(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme, {
|
||||
required bool wrap,
|
||||
}) {
|
||||
final buttons = [
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: colorScheme.primary,
|
||||
),
|
||||
onPressed: () => context.go('/add'),
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
label: const Text('Add entry'),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => context.go('/traction'),
|
||||
icon: const Icon(Icons.train),
|
||||
label: const Text('Traction'),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => context.go('/trips'),
|
||||
icon: const Icon(Icons.book),
|
||||
label: const Text('Trips'),
|
||||
),
|
||||
];
|
||||
|
||||
if (wrap) {
|
||||
return Wrap(spacing: 8, runSpacing: 8, children: buttons);
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
...buttons
|
||||
.map(
|
||||
(btn) => Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: btn,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOnThisDayCard(BuildContext context, DataService data) {
|
||||
final filtered = data.onThisDay
|
||||
.where((leg) => leg.beginTime.year != DateTime.now().year)
|
||||
.toList();
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final showMore = filtered.length > 5;
|
||||
final visible = _showAllOnThisDay ? filtered : filtered.take(6).toList();
|
||||
return _panel(
|
||||
context,
|
||||
icon: Icons.history_toggle_off,
|
||||
title: 'On this day',
|
||||
trailing: data.isOnThisDayLoading
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: null,
|
||||
action: showMore
|
||||
? TextButton(
|
||||
onPressed: () =>
|
||||
setState(() => _showAllOnThisDay = !_showAllOnThisDay),
|
||||
child: Text(_showAllOnThisDay ? 'Show less' : 'Show more'),
|
||||
)
|
||||
: null,
|
||||
child: filtered.isEmpty
|
||||
? Text(
|
||||
'No historical moves for today yet.',
|
||||
style: textTheme.bodyMedium,
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
for (int idx = 0; idx < visible.length; idx++) ...[
|
||||
_otdRow(context, visible[idx], textTheme),
|
||||
if (idx != visible.length - 1) const Divider(height: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _otdRow(BuildContext context, Leg leg, TextTheme textTheme) {
|
||||
final traction = leg.locos;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 56,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
leg.beginTime.year.toString(),
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(_formatTime(leg.beginTime), style: textTheme.labelSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${leg.start} → ${leg.end}',
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (leg.headcode.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
leg.headcode,
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: textTheme.bodySmall?.color?.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: traction.isEmpty
|
||||
? Text(
|
||||
'No traction recorded',
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: textTheme.bodySmall?.color?.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: traction.map((loco) {
|
||||
final iconColor = loco.powering
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).hintColor;
|
||||
final label = '${loco.locoClass} ${loco.number}';
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.train, size: 14, color: iconColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: textTheme.bodySmall?.color
|
||||
?.withValues(alpha: 0.85),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${leg.mileage.toStringAsFixed(1)} mi',
|
||||
style: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _panel(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required Widget child,
|
||||
Widget? trailing,
|
||||
@@ -241,29 +529,29 @@ class _DashboardState extends State<Dashboard> {
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
Icon(icon, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (action != null) action,
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
trailing,
|
||||
],
|
||||
],
|
||||
),
|
||||
if (action != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: action,
|
||||
),
|
||||
if (trailing != null) trailing,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -274,56 +562,15 @@ class _DashboardState extends State<Dashboard> {
|
||||
);
|
||||
}
|
||||
|
||||
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 _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(
|
||||
return _panel(
|
||||
context,
|
||||
icon: Icons.bookmark,
|
||||
title: 'Trips',
|
||||
action: TextButton(
|
||||
onPressed: () => context.push('/trips'),
|
||||
@@ -335,19 +582,46 @@ class _DashboardState extends State<Dashboard> {
|
||||
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'),
|
||||
children: trips.take(6).map((trip) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(Icons.book, size: 18),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
trip.tripName,
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
Text(
|
||||
'${trip.tripMileage.toStringAsFixed(1)} mi',
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).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')}';
|
||||
String _formatTime(DateTime date) {
|
||||
return DateFormat('HH:mm').format(date);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +268,7 @@ class _TripsPageState extends State<TripsPage> {
|
||||
isScrollControlled: true,
|
||||
builder: (_) {
|
||||
bool renaming = false;
|
||||
bool deleting = false;
|
||||
String tripName = trip.name;
|
||||
return StatefulBuilder(
|
||||
builder: (sheetCtx, setSheetState) {
|
||||
@@ -285,6 +286,58 @@ class _TripsPageState extends State<TripsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleDelete() async {
|
||||
if (deleting || trip.legs.isNotEmpty) return;
|
||||
final ok = await showDialog<bool>(
|
||||
context: sheetCtx,
|
||||
builder: (ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text('Delete trip?'),
|
||||
content: Text(
|
||||
'This will delete "${trip.name}". This cannot be undone.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (ok != true) return;
|
||||
|
||||
final data = context.read<DataService>();
|
||||
final api = data.api;
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
setSheetState(() => deleting = true);
|
||||
try {
|
||||
await api.delete('/trips/delete/${trip.id}');
|
||||
await Future.wait([
|
||||
data.fetchTripDetails(),
|
||||
data.fetchTrips(),
|
||||
]);
|
||||
if (context.mounted) {
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text('Deleted "${trip.name}"')),
|
||||
);
|
||||
Navigator.of(sheetCtx).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text('Failed to delete trip: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setSheetState(() => deleting = false);
|
||||
}
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
@@ -317,6 +370,21 @@ class _TripsPageState extends State<TripsPage> {
|
||||
tooltip: 'Rename trip',
|
||||
onPressed: renaming ? null : handleRename,
|
||||
),
|
||||
if (trip.legs.isEmpty) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: deleting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Delete trip',
|
||||
onPressed: deleting ? null : handleDelete,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 4),
|
||||
Text('${trip.mileage.toStringAsFixed(1)} mi'),
|
||||
],
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.3.0+1
|
||||
version: 0.3.1+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.8.1
|
||||
|
||||
Reference in New Issue
Block a user