Added class clearance fixes and improvements
Some checks failed
Release / meta (push) Successful in 18s
Release / android-build (push) Successful in 11m48s
Release / linux-build (push) Successful in 1m24s
Release / web-build (push) Successful in 6m15s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Some checks failed
Release / meta (push) Successful in 18s
Release / android-build (push) Successful in 11m48s
Release / linux-build (push) Successful in 1m24s
Release / web-build (push) Successful in 6m15s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
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';
|
||||
import 'package:mileograph_flutter/components/widgets/animated_count_text.dart';
|
||||
import 'package:mileograph_flutter/objects/objects.dart';
|
||||
import 'package:mileograph_flutter/services/authservice.dart';
|
||||
import 'package:mileograph_flutter/services/data_service.dart';
|
||||
@@ -19,6 +23,18 @@ class Dashboard extends StatefulWidget {
|
||||
|
||||
class _DashboardState extends State<Dashboard> {
|
||||
bool _showAllOnThisDay = false;
|
||||
bool _isCurrent = false;
|
||||
Timer? _carouselTimer;
|
||||
int _carouselIndex = 0;
|
||||
int _carouselItemCount = 0;
|
||||
final Random _carouselRandom = Random();
|
||||
List<ClassClearanceProgress> _carouselItems = const [];
|
||||
String _carouselSignature = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -31,16 +47,11 @@ class _DashboardState extends State<Dashboard> {
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await data.fetchHomepageStats();
|
||||
await Future.wait([
|
||||
data.fetchOnThisDay(),
|
||||
data.fetchTripDetails(),
|
||||
data.fetchHadTraction(),
|
||||
data.fetchLatestLocoChanges(),
|
||||
]);
|
||||
await _refreshDashboardData(force: true);
|
||||
},
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
_handleRouteFocus();
|
||||
const spacing = 16.0;
|
||||
final maxWidth = constraints.maxWidth;
|
||||
return Stack(
|
||||
@@ -97,9 +108,6 @@ class _DashboardState extends State<Dashboard> {
|
||||
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,
|
||||
@@ -125,62 +133,248 @@ class _DashboardState extends State<Dashboard> {
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_metricTile(
|
||||
_animatedMetricTile(
|
||||
context,
|
||||
label: 'Total mileage',
|
||||
value: distanceUnits.format(totalMileage, decimals: 1),
|
||||
value: totalMileage.toDouble(),
|
||||
formatter: (val) =>
|
||||
distanceUnits.format(val, decimals: 1),
|
||||
icon: Icons.route,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
_metricTile(
|
||||
_animatedMetricTile(
|
||||
context,
|
||||
label: 'This year',
|
||||
value: distanceUnits.format(currentYearMileage, decimals: 1),
|
||||
value: currentYearMileage.toDouble(),
|
||||
formatter: (val) =>
|
||||
distanceUnits.format(val, decimals: 1),
|
||||
icon: Icons.calendar_today,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
_metricTile(
|
||||
_animatedMetricTile(
|
||||
context,
|
||||
label: 'Entries logged',
|
||||
value: legCount.toString(),
|
||||
value: legCount.toDouble(),
|
||||
formatter: (val) => val.round().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(
|
||||
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),
|
||||
),
|
||||
),
|
||||
_buildClassClearanceCarousel(context, data, colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _metricTile(
|
||||
Widget _buildClassClearanceCarousel(
|
||||
BuildContext context,
|
||||
DataService data,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final items = data.classClearanceProgress;
|
||||
final loading = data.isClassClearanceProgressLoading;
|
||||
_refreshCarouselItems(items);
|
||||
_startCarouselIfNeeded(_carouselItems.length);
|
||||
|
||||
if (loading && _carouselItems.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_carouselItems.isEmpty) {
|
||||
return Text(
|
||||
'No class clearance progress yet.',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Class clearance (in progress)',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: 58,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 450),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, animation) {
|
||||
final tween = Tween<Offset>(
|
||||
begin: const Offset(0, 0.4),
|
||||
end: Offset.zero,
|
||||
);
|
||||
return ClipRect(
|
||||
child: SlideTransition(
|
||||
position: animation.drive(tween),
|
||||
child: FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: _buildClassClearanceSlide(
|
||||
context,
|
||||
_carouselItems[_carouselIndex % _carouselItems.length],
|
||||
colorScheme,
|
||||
key: ValueKey(_carouselIndex),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildClassClearanceSlide(
|
||||
BuildContext context,
|
||||
ClassClearanceProgress progress,
|
||||
ColorScheme colorScheme,
|
||||
{Key? key}
|
||||
) {
|
||||
final pct = progress.percentComplete.clamp(0, 100);
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final ratio = progress.total == 0
|
||||
? 0.0
|
||||
: (progress.completed / progress.total).clamp(0.0, 1.0);
|
||||
final activeRatio = progress.total == 0
|
||||
? 0.0
|
||||
: (progress.activeTotal / progress.total).clamp(0.0, 1.0);
|
||||
return TweenAnimationBuilder<double>(
|
||||
key: key ?? ValueKey(progress.className),
|
||||
tween: Tween(begin: 0, end: ratio),
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, value, child) {
|
||||
final ratioValue = ratio == 0 ? 0.0 : (value / ratio).clamp(0.0, 1.0);
|
||||
final animatedHad = (progress.completed * ratioValue).round();
|
||||
final animatedActive = (progress.activeTotal * ratioValue).round();
|
||||
final animatedActiveRatio =
|
||||
(activeRatio * ratioValue).clamp(0.0, activeRatio);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
progress.className,
|
||||
style: textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${pct.toStringAsFixed(0)}% • $animatedHad/$animatedActive/${progress.total}',
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer
|
||||
.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox(
|
||||
height: 8,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
color: colorScheme.onPrimaryContainer.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
),
|
||||
FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: animatedActiveRatio.isNaN
|
||||
? 0
|
||||
: animatedActiveRatio,
|
||||
child: Container(
|
||||
color:
|
||||
colorScheme.onPrimaryContainer.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: value.isNaN ? 0 : value,
|
||||
child: Container(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _startCarouselIfNeeded(int count) {
|
||||
if (count <= 1) {
|
||||
_stopCarousel();
|
||||
return;
|
||||
}
|
||||
if (_carouselItemCount != count) {
|
||||
_carouselItemCount = count;
|
||||
_carouselIndex = 0;
|
||||
}
|
||||
if (_carouselTimer != null) return;
|
||||
_carouselTimer = Timer.periodic(const Duration(seconds: 8), (_) {
|
||||
if (!mounted || _carouselItemCount == 0) return;
|
||||
setState(() {
|
||||
_carouselIndex = (_carouselIndex + 1) % _carouselItemCount;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _stopCarousel() {
|
||||
_carouselTimer?.cancel();
|
||||
_carouselTimer = null;
|
||||
}
|
||||
|
||||
void _refreshCarouselItems(List<ClassClearanceProgress> items) {
|
||||
final signature = items
|
||||
.map((item) =>
|
||||
'${item.className}:${item.completed}:${item.activeTotal}:${item.total}')
|
||||
.join('|');
|
||||
if (signature == _carouselSignature) return;
|
||||
_carouselSignature = signature;
|
||||
_carouselItems = List<ClassClearanceProgress>.from(items)
|
||||
..shuffle(_carouselRandom);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopCarousel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _animatedMetricTile(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
required String value,
|
||||
required double value,
|
||||
required String Function(double) formatter,
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
}) {
|
||||
@@ -207,8 +401,9 @@ class _DashboardState extends State<Dashboard> {
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
AnimatedCountText(
|
||||
value: value,
|
||||
formatter: formatter,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: color,
|
||||
@@ -555,8 +750,10 @@ class _DashboardState extends State<Dashboard> {
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
Text(
|
||||
distanceUnits.format(trip.tripMileage, decimals: 1),
|
||||
AnimatedCountText(
|
||||
value: trip.tripMileage,
|
||||
formatter: (val) =>
|
||||
distanceUnits.format(val, decimals: 1),
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
@@ -573,4 +770,31 @@ class _DashboardState extends State<Dashboard> {
|
||||
String _formatTime(DateTime date) {
|
||||
return DateFormat('HH:mm').format(date);
|
||||
}
|
||||
|
||||
Future<void> _refreshDashboardData({bool force = false}) async {
|
||||
final data = context.read<DataService>();
|
||||
await data.fetchHomepageStats();
|
||||
await Future.wait([
|
||||
data.fetchOnThisDay(),
|
||||
data.fetchTripDetails(),
|
||||
data.fetchHadTraction(),
|
||||
data.fetchLatestLocoChanges(),
|
||||
data.fetchClassClearanceProgress(limit: 75, onlyIncomplete: true),
|
||||
]);
|
||||
}
|
||||
|
||||
void _handleRouteFocus() {
|
||||
final isCurrent = ModalRoute.of(context)?.isCurrent ?? true;
|
||||
if (isCurrent && !_isCurrent) {
|
||||
_isCurrent = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_refreshDashboardData();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isCurrent && _isCurrent) {
|
||||
_isCurrent = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user