diff --git a/lib/components/dashboard/latest_loco_changes_panel.dart b/lib/components/dashboard/latest_loco_changes_panel.dart index 6108122..0e07b23 100644 --- a/lib/components/dashboard/latest_loco_changes_panel.dart +++ b/lib/components/dashboard/latest_loco_changes_panel.dart @@ -38,9 +38,25 @@ class _LatestLocoChangesPanelState extends State { 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) diff --git a/lib/components/dashboard/leaderboard_panel.dart b/lib/components/dashboard/leaderboard_panel.dart index 52c0f67..b69eb42 100644 --- a/lib/components/dashboard/leaderboard_panel.dart +++ b/lib/components/dashboard/leaderboard_panel.dart @@ -10,6 +10,7 @@ class LeaderboardPanel extends StatelessWidget { Widget build(BuildContext context) { final data = context.watch(); 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), + ], + ], ), ], ), diff --git a/lib/components/dashboard/top_traction_panel.dart b/lib/components/dashboard/top_traction_panel.dart index a8570ae..f752462 100644 --- a/lib/components/dashboard/top_traction_panel.dart +++ b/lib/components/dashboard/top_traction_panel.dart @@ -11,6 +11,7 @@ class TopTractionPanel extends StatelessWidget { final data = context.watch(); 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), + ], + ], ), ], ), diff --git a/lib/components/pages/dashboard.dart b/lib/components/pages/dashboard.dart index ad28a16..6d860d6 100644 --- a/lib/components/pages/dashboard.dart +++ b/lib/components/pages/dashboard.dart @@ -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 { }, 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 { ); } - 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( + 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 _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 { 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 { ); } - Widget _buildLegList( - BuildContext context, - List 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 { 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); } } diff --git a/lib/components/pages/trips.dart b/lib/components/pages/trips.dart index 54a6134..fa1e4f7 100644 --- a/lib/components/pages/trips.dart +++ b/lib/components/pages/trips.dart @@ -268,6 +268,7 @@ class _TripsPageState extends State { 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 { } } + Future handleDelete() async { + if (deleting || trip.legs.isNotEmpty) return; + final ok = await showDialog( + 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(); + 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 { 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'), ], diff --git a/pubspec.yaml b/pubspec.yaml index 4388848..002b174 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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