From d8312a3f1b787d0a53cff5f98605da9987be443e Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Thu, 22 Jan 2026 23:17:15 +0000 Subject: [PATCH] Added class clearance fixes and improvements --- .../dashboard/leaderboard_panel.dart | 10 +- .../dashboard/top_traction_panel.dart | 10 +- lib/components/pages/badges.dart | 69 +++- lib/components/pages/dashboard.dart | 312 +++++++++++++++--- lib/components/pages/profile.dart | 111 +++++++ lib/components/pages/stats.dart | 78 +++++ .../widgets/animated_count_text.dart | 81 +++++ lib/objects/objects.dart | 62 ++++ lib/services/api_service.dart | 62 ++++ .../data_service/data_service_badges.dart | 8 +- lib/utils/download_helper.dart | 29 ++ linux/CMakeLists.txt | 4 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 74 ++++- pubspec.yaml | 7 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 19 files changed, 865 insertions(+), 63 deletions(-) create mode 100644 lib/components/widgets/animated_count_text.dart create mode 100644 lib/utils/download_helper.dart diff --git a/lib/components/dashboard/leaderboard_panel.dart b/lib/components/dashboard/leaderboard_panel.dart index 88e0b39..e3a63e3 100644 --- a/lib/components/dashboard/leaderboard_panel.dart +++ b/lib/components/dashboard/leaderboard_panel.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:mileograph_flutter/components/widgets/animated_count_text.dart'; import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart'; @@ -136,11 +137,10 @@ class _LeaderboardPanelState extends State { crossAxisAlignment: WrapCrossAlignment.center, spacing: 8, children: [ - Text( - distanceUnits.format( - leaderboard[index].mileage, - decimals: 1, - ), + AnimatedCountText( + value: leaderboard[index].mileage, + formatter: (val) => + distanceUnits.format(val, decimals: 1), style: textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w700, ), diff --git a/lib/components/dashboard/top_traction_panel.dart b/lib/components/dashboard/top_traction_panel.dart index c60636e..e26a5bc 100644 --- a/lib/components/dashboard/top_traction_panel.dart +++ b/lib/components/dashboard/top_traction_panel.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:mileograph_flutter/components/widgets/animated_count_text.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:provider/provider.dart'; @@ -78,11 +79,10 @@ class TopTractionPanel extends StatelessWidget { fontStyle: FontStyle.italic, ), ), - trailing: Text( - distanceUnits.format( - locos[index].mileage ?? 0, - decimals: 1, - ), + trailing: AnimatedCountText( + value: (locos[index].mileage ?? 0).toDouble(), + formatter: (val) => + distanceUnits.format(val, decimals: 1), style: textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w700, ), diff --git a/lib/components/pages/badges.dart b/lib/components/pages/badges.dart index 90ce0d9..77fba8c 100644 --- a/lib/components/pages/badges.dart +++ b/lib/components/pages/badges.dart @@ -449,6 +449,23 @@ class _BadgesPageState extends State { ClassClearanceProgress progress, ) { final pct = progress.percentComplete.clamp(0, 100); + final activePct = progress.activePercent.clamp(0, 100); + final showActive = + progress.activeTotal > 0 || progress.activeCompleted > 0; + final showActiveCrest = + progress.activeTotal > 0 && progress.activeCompleted == progress.activeTotal; + final scheme = Theme.of(context).colorScheme; + final total = progress.total; + final activeTotal = progress.activeTotal.clamp(0, total); + final had = progress.completed.clamp(0, total); + final activeHad = progress.activeCompleted.clamp(0, activeTotal).clamp(0, had); + final activeRemaining = + (activeTotal - activeHad).clamp(0, total - had); + final remaining = + (total - activeTotal - (had - activeHad)).clamp(0, total); + final hadColor = scheme.primary; + final activeColor = scheme.primary.withValues(alpha: 0.4); + final remainingColor = scheme.primary.withValues(alpha: 0.18); return Card( margin: const EdgeInsets.symmetric(vertical: 4.0), child: Padding( @@ -471,9 +488,55 @@ class _BadgesPageState extends State { ], ), const SizedBox(height: 4), - LinearProgressIndicator( - value: progress.total == 0 ? 0 : pct / 100, - minHeight: 6, + if (showActive) + Padding( + padding: const EdgeInsets.only(bottom: 6.0), + child: Row( + children: [ + Text( + 'Active: ${progress.activeCompleted}/${progress.activeTotal} (${activePct.toStringAsFixed(0)}%)', + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith(color: Theme.of(context).hintColor), + ), + if (showActiveCrest) ...[ + const SizedBox(width: 6), + Icon( + Icons.verified, + size: 14, + color: scheme.primary, + ), + ], + ], + ), + ), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + height: 6, + child: total <= 0 + ? Container(color: remainingColor) + : Row( + children: [ + if (had > 0) + Expanded( + flex: had, + child: Container(color: hadColor), + ), + if (showActive && activeRemaining > 0) + Expanded( + flex: activeRemaining, + child: Container(color: activeColor), + ), + if (remaining > 0) + Expanded( + flex: remaining, + child: Container(color: remainingColor), + ), + ], + ), + ), ), if (progress.total > 0) Padding( diff --git a/lib/components/pages/dashboard.dart b/lib/components/pages/dashboard.dart index 5f7df56..dc41ce8 100644 --- a/lib/components/pages/dashboard.dart +++ b/lib/components/pages/dashboard.dart @@ -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 { bool _showAllOnThisDay = false; + bool _isCurrent = false; + Timer? _carouselTimer; + int _carouselIndex = 0; + int _carouselItemCount = 0; + final Random _carouselRandom = Random(); + List _carouselItems = const []; + String _carouselSignature = ''; + + @override + void initState() { + super.initState(); + } @override Widget build(BuildContext context) { @@ -31,16 +47,11 @@ class _DashboardState extends State { 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 { 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 { 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( - 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( + 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( + 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 items) { + final signature = items + .map((item) => + '${item.className}:${item.completed}:${item.activeTotal}:${item.total}') + .join('|'); + if (signature == _carouselSignature) return; + _carouselSignature = signature; + _carouselItems = List.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 { 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 { 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 { String _formatTime(DateTime date) { return DateFormat('HH:mm').format(date); } + + Future _refreshDashboardData({bool force = false}) async { + final data = context.read(); + 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; + } + } } diff --git a/lib/components/pages/profile.dart b/lib/components/pages/profile.dart index 482fa48..a8e563b 100644 --- a/lib/components/pages/profile.dart +++ b/lib/components/pages/profile.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; @@ -5,6 +7,7 @@ import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:mileograph_flutter/utils/download_helper.dart'; class ProfilePage extends StatefulWidget { const ProfilePage({super.key}); @@ -31,6 +34,10 @@ class _ProfilePageState extends State { bool _showAccountSettings = false; bool _changingPassword = false; static const List _visibilityOptions = ['private', 'friends', 'public']; + static const List _exportFormats = ['xlsx', 'json', 'csv']; + bool _exporting = false; + String _exportFormat = 'xlsx'; + String? _exportError; UserSummary? _selectedUser; Friendship? _status; @@ -71,6 +78,54 @@ class _ProfilePageState extends State { super.dispose(); } + Future _exportLogEntries() async { + if (_exporting) return; + setState(() { + _exporting = true; + _exportError = null; + }); + try { + final data = context.read(); + final response = await data.api.getBytes( + '/legs/export?export_format=$_exportFormat', + headers: const {'accept': '*/*'}, + ); + final timestamp = DateTime.now() + .toIso8601String() + .replaceAll(':', '') + .replaceAll('.', ''); + final fallbackName = 'log_entries_$timestamp.$_exportFormat'; + final filename = response.filename ?? fallbackName; + final saveResult = await saveBytes( + Uint8List.fromList(response.bytes), + filename, + mimeType: response.contentType, + ); + if (!mounted) return; + if (saveResult.canceled) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Export canceled.')), + ); + } else { + final path = saveResult.path; + final message = path == null || path.isEmpty + ? 'Download started.' + : 'Export saved to $path'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + } on ApiException catch (e) { + if (!mounted) return; + setState(() => _exportError = e.message); + } catch (e) { + if (!mounted) return; + setState(() => _exportError = e.toString()); + } finally { + if (mounted) setState(() => _exporting = false); + } + } + Future _searchUsers() async { final query = _searchController.text.trim(); if (query.isEmpty) { @@ -829,6 +884,8 @@ class _ProfilePageState extends State { firstChild: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildExportSection(theme), + const SizedBox(height: 12), if (showPrivacySpinner) const Padding( padding: EdgeInsets.symmetric(vertical: 8.0), @@ -1023,6 +1080,60 @@ class _ProfilePageState extends State { ); } + Widget _buildExportSection(ThemeData theme) { + return ExpansionTile( + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(top: 8), + title: Text( + 'Export log entries', + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + subtitle: const Text('Download your log as a file.'), + children: [ + DropdownButtonFormField( + value: _exportFormat, + decoration: const InputDecoration( + labelText: 'Export format', + border: OutlineInputBorder(), + ), + items: _exportFormats + .map( + (format) => DropdownMenuItem( + value: format, + child: Text(format.toUpperCase()), + ), + ) + .toList(), + onChanged: _exporting + ? null + : (value) { + if (value == null) return; + setState(() => _exportFormat = value); + }, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _exporting ? null : _exportLogEntries, + icon: _exporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download), + label: Text(_exporting ? 'Exporting...' : 'Export'), + ), + if (_exportError != null) ...[ + const SizedBox(height: 8), + Text( + _exportError!, + style: TextStyle(color: theme.colorScheme.error), + ), + ], + ], + ); + } + UserSummary? _otherUser(Friendship friendship, String? currentUserId) { final selfId = currentUserId ?? ''; if (friendship.requester?.userId == selfId) return friendship.addressee; diff --git a/lib/components/pages/stats.dart b/lib/components/pages/stats.dart index 1414727..17a1189 100644 --- a/lib/components/pages/stats.dart +++ b/lib/components/pages/stats.dart @@ -141,6 +141,7 @@ class _StatsPageState extends State { ], ), const SizedBox(height: 8), + _buildTypeCountsSection(context, year), _buildSection( context, title: 'Top classes', @@ -201,6 +202,83 @@ class _StatsPageState extends State { ); } + List> _sortedTypeCounts(Map counts) { + final entries = counts.entries.toList(); + entries.sort((a, b) { + final countCompare = b.value.compareTo(a.value); + if (countCompare != 0) return countCompare; + return a.key.compareTo(b.key); + }); + return entries; + } + + Widget _buildTypeCountsSection(BuildContext context, StatsYear year) { + if (year.winnerTypeCounts.isEmpty && year.totalTypeCounts.isEmpty) { + return const SizedBox.shrink(); + } + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Type counts', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 6), + _buildTypeCountsRow( + context, + label: 'Winners', + counts: year.winnerTypeCounts, + ), + const SizedBox(height: 8), + _buildTypeCountsRow( + context, + label: 'Total', + counts: year.totalTypeCounts, + ), + ], + ), + ); + } + + Widget _buildTypeCountsRow( + BuildContext context, { + required String label, + required Map counts, + }) { + final theme = Theme.of(context); + final entries = _sortedTypeCounts(counts); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelLarge, + ), + const SizedBox(height: 4), + if (entries.isEmpty) + Text( + 'No data', + style: theme.textTheme.bodySmall, + ) + else + Wrap( + spacing: 6, + runSpacing: 6, + children: entries + .map((entry) => _buildInfoChip( + context, + label: entry.key, + value: _countFormat.format(entry.value), + )) + .toList(), + ), + ], + ); + } + Widget _buildSection( BuildContext context, { required String title, diff --git a/lib/components/widgets/animated_count_text.dart b/lib/components/widgets/animated_count_text.dart new file mode 100644 index 0000000..576ce1a --- /dev/null +++ b/lib/components/widgets/animated_count_text.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +class AnimatedCountText extends StatefulWidget { + const AnimatedCountText({ + super.key, + required this.value, + required this.formatter, + this.style, + this.duration = const Duration(milliseconds: 900), + this.curve = Curves.easeOutCubic, + this.animateFromZero = true, + }); + + final double value; + final String Function(double) formatter; + final TextStyle? style; + final Duration duration; + final Curve curve; + final bool animateFromZero; + + @override + State createState() => _AnimatedCountTextState(); +} + +class _AnimatedCountTextState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + late CurvedAnimation _curve; + double _currentValue = 0; + + @override + void initState() { + super.initState(); + _currentValue = widget.animateFromZero ? 0 : widget.value; + _controller = AnimationController(vsync: this, duration: widget.duration); + _curve = CurvedAnimation(parent: _controller, curve: widget.curve); + _controller.addListener(_handleTick); + _configureAnimation(from: _currentValue, to: widget.value); + if (_currentValue != widget.value) { + _controller.forward(); + } + } + + @override + void didUpdateWidget(covariant AnimatedCountText oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.curve != widget.curve) { + _curve = CurvedAnimation(parent: _controller, curve: widget.curve); + _configureAnimation(from: _currentValue, to: widget.value); + } + if (oldWidget.value != widget.value) { + _controller.duration = widget.duration; + _configureAnimation(from: _currentValue, to: widget.value); + _controller.forward(from: 0); + } + } + + void _configureAnimation({required double from, required double to}) { + _animation = Tween(begin: from, end: to).animate(_curve); + } + + void _handleTick() { + setState(() => _currentValue = _animation.value); + } + + @override + void dispose() { + _controller.removeListener(_handleTick); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Text( + widget.formatter(_currentValue), + style: widget.style, + ); + } +} diff --git a/lib/objects/objects.dart b/lib/objects/objects.dart index f4f3859..12dc4d1 100644 --- a/lib/objects/objects.dart +++ b/lib/objects/objects.dart @@ -519,6 +519,8 @@ class StatsAbout { final networkByYear = >{}; final stationByYear = >{}; final winnersByYear = {}; + final winnerTypeCountsByYear = >{}; + final totalTypeCountsByYear = >{}; void addYearMileage(dynamic entry) { if (entry is Map) { @@ -589,6 +591,16 @@ class StatsAbout { } } + Map parseTypeCounts(dynamic value) { + if (value is Map) { + final entries = value.entries + .map((entry) => MapEntry(entry.key.toString(), _asInt(entry.value))) + .where((entry) => entry.key.isNotEmpty); + return Map.fromEntries(entries); + } + return const {}; + } + parseYearMap>( json['top_classes'], classByYear, @@ -610,6 +622,19 @@ class StatsAbout { if (year == null) return; if (value is List) { winnersByYear[year] = value.length; + } else if (value is Map) { + final mapped = value.map( + (entryKey, entryValue) => + MapEntry(entryKey.toString(), entryValue), + ); + final winners = mapped['winners']; + if (winners is List) { + winnersByYear[year] = winners.length; + } + winnerTypeCountsByYear[year] = + parseTypeCounts(mapped['winner_type_counts']); + totalTypeCountsByYear[year] = + parseTypeCounts(mapped['total_type_counts']); } }); } @@ -620,6 +645,8 @@ class StatsAbout { ...networkByYear.keys, ...stationByYear.keys, ...winnersByYear.keys, + ...winnerTypeCountsByYear.keys, + ...totalTypeCountsByYear.keys, }..removeWhere((year) => year == 0); final yearMap = {}; @@ -631,6 +658,8 @@ class StatsAbout { topNetworks: networkByYear[year] ?? const [], topStations: stationByYear[year] ?? const [], winnerCount: winnersByYear[year] ?? 0, + winnerTypeCounts: winnerTypeCountsByYear[year] ?? const {}, + totalTypeCounts: totalTypeCountsByYear[year] ?? const {}, ); } @@ -651,6 +680,8 @@ class StatsYear { final List topNetworks; final List topStations; final int winnerCount; + final Map winnerTypeCounts; + final Map totalTypeCounts; StatsYear({ required this.year, @@ -659,6 +690,8 @@ class StatsYear { required this.topNetworks, required this.topStations, required this.winnerCount, + required this.winnerTypeCounts, + required this.totalTypeCounts, }); } @@ -1779,12 +1812,18 @@ class ClassClearanceProgress { final int completed; final int total; final double percentComplete; + final int activeCompleted; + final int activeTotal; + final double activePercent; ClassClearanceProgress({ required this.className, required this.completed, required this.total, required this.percentComplete, + required this.activeCompleted, + required this.activeTotal, + required this.activePercent, }); factory ClassClearanceProgress.fromJson(Map json) { @@ -1802,11 +1841,34 @@ class ClassClearanceProgress { if (percent == 0 && total > 0) { percent = (completed / total) * 100; } + final activeCompleted = _asInt( + json['active_completed'] ?? + json['active_done'] ?? + json['active_count'] ?? + json['active_had'] ?? + json['had_active'], + ); + final activeTotal = + _asInt(json['active_total'] ?? + json['active_required'] ?? + json['active_goal'] ?? + json['total_active']); + double activePercent = _asDouble( + json['active_percent'] ?? + json['active_pct'] ?? + json['active_completion'], + ); + if (activePercent == 0 && activeTotal > 0) { + activePercent = (activeCompleted / activeTotal) * 100; + } return ClassClearanceProgress( className: name.isNotEmpty ? name : 'Class', completed: completed, total: total, percentComplete: percent, + activeCompleted: activeCompleted, + activeTotal: activeTotal, + activePercent: activePercent, ); } } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 3dc8865..0f0b56f 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -55,6 +55,40 @@ class ApiService { return _processResponse(response); } + Future getBytes( + String endpoint, { + Map? headers, + }) async { + final response = await _client + .get( + Uri.parse('$baseUrl$endpoint'), + headers: _buildHeaders(headers), + ) + .timeout(timeout); + + if (response.statusCode >= 200 && response.statusCode < 300) { + final contentDisposition = response.headers['content-disposition']; + return ApiBinaryResponse( + bytes: response.bodyBytes, + statusCode: response.statusCode, + contentType: response.headers['content-type'], + filename: _extractFilename(contentDisposition), + ); + } + + if (response.statusCode == 401 && _onUnauthorized != null) { + await _onUnauthorized!(); + } + + final body = _decodeBody(response); + final message = _extractErrorMessage(body); + throw ApiException( + statusCode: response.statusCode, + message: message, + body: body, + ); + } + Future post( String endpoint, dynamic data, { @@ -176,6 +210,20 @@ class ApiService { } return body.toString(); } + + String? _extractFilename(String? contentDisposition) { + if (contentDisposition == null || contentDisposition.isEmpty) return null; + final utf8Match = + RegExp(r"filename\\*=UTF-8''([^;]+)", caseSensitive: false) + .firstMatch(contentDisposition); + if (utf8Match != null) { + return Uri.decodeComponent(utf8Match.group(1) ?? ''); + } + final match = + RegExp(r'filename="?([^\";]+)"?', caseSensitive: false) + .firstMatch(contentDisposition); + return match?.group(1); + } } class ApiException implements Exception { @@ -192,3 +240,17 @@ class ApiException implements Exception { @override String toString() => 'API error $statusCode: $message'; } + +class ApiBinaryResponse { + final List bytes; + final int statusCode; + final String? contentType; + final String? filename; + + ApiBinaryResponse({ + required this.bytes, + required this.statusCode, + this.contentType, + this.filename, + }); +} diff --git a/lib/services/data_service/data_service_badges.dart b/lib/services/data_service/data_service_badges.dart index d262700..629ad09 100644 --- a/lib/services/data_service/data_service_badges.dart +++ b/lib/services/data_service/data_service_badges.dart @@ -52,12 +52,16 @@ extension DataServiceBadges on DataService { int offset = 0, int limit = 20, bool append = false, + bool onlyIncomplete = false, }) async { _isClassClearanceProgressLoading = true; if (!append) _classClearanceProgress = []; try { - final json = - await api.get('/badge/completion/class?limit=$limit&offset=$offset'); + final onlyIncompleteParam = + onlyIncomplete ? '&only_incomplete=true' : ''; + final json = await api.get( + '/badge/completion/class?limit=$limit&offset=$offset$onlyIncompleteParam', + ); List? list; if (json is List) { list = json; diff --git a/lib/utils/download_helper.dart b/lib/utils/download_helper.dart new file mode 100644 index 0000000..b4d99e2 --- /dev/null +++ b/lib/utils/download_helper.dart @@ -0,0 +1,29 @@ +import 'dart:typed_data'; + +import 'package:file_selector/file_selector.dart'; + +class SaveResult { + final String? path; + final bool canceled; + + const SaveResult({this.path, required this.canceled}); +} + +Future saveBytes( + Uint8List bytes, + String filename, { + String? mimeType, +}) async { + final safeName = filename.trim().isEmpty ? 'export.bin' : filename.trim(); + final location = await getSaveLocation(suggestedName: safeName); + if (location == null) { + return const SaveResult(canceled: true); + } + final file = XFile.fromData( + bytes, + mimeType: mimeType ?? 'application/octet-stream', + name: safeName, + ); + await file.saveTo(location.path); + return SaveResult(path: location.path, canceled: false); +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index cb1e7e6..c77e496 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -4,10 +4,10 @@ project(runner LANGUAGES CXX) # The name of the executable created for the application. Change this to change # the on-disk name of your application. -set(BINARY_NAME "mileograph_flutter") +set(BINARY_NAME "Mileograph") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.example.mileograph_flutter") +set(APPLICATION_ID "com.petegregoryy.mileograph_flutter") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 4b6d98a..90c6352 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,12 +7,16 @@ #include "generated_plugin_registrant.h" #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) dynamic_color_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin"); dynamic_color_plugin_register_with_registrar(dynamic_color_registrar); + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index e19acfc..868463a 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_color + file_selector_linux flutter_secure_storage_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 6fe77f3..4e35fa8 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,12 +6,14 @@ import FlutterMacOS import Foundation import dynamic_color +import file_selector_macos import flutter_secure_storage_darwin import path_provider_foundation import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index f16bc09..03a531f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + url: "https://pub.dev" + source: hosted + version: "0.3.5+1" crypto: dependency: transitive description: @@ -121,6 +129,70 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: "5f1d15a7f17115038f433d1b0ea57513cc9e29a9d5338d166cb0bef3fa90a7a0" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "1ce58b609289551f8ec07265476720e77d19764339cc1d8e4df3c4d34dac6499" + url: "https://pub.dev" + source: hosted + version: "0.5.1+17" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: fe9f52123af16bba4ad65bd7e03defbbb4b172a38a8e6aaa2a869a0c56a5f5fb + url: "https://pub.dev" + source: hosted + version: "0.5.3+2" + file_selector_linux: + dependency: "direct main" + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: "direct main" + description: + name: file_selector_macos + sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c" + url: "https://pub.dev" + source: hosted + version: "0.9.4+4" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: "direct main" + description: + name: file_selector_web + sha256: c4c0ea4224d97a60a7067eca0c8fd419e708ff830e0c83b11a48faf566cec3e7 + url: "https://pub.dev" + source: hosted + version: "0.9.4+2" + file_selector_windows: + dependency: "direct main" + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" flutter: dependency: "direct main" description: flutter @@ -591,4 +663,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.8.1 <4.0.0" - flutter: ">=3.29.0" + flutter: ">=3.32.0" diff --git a/pubspec.yaml b/pubspec.yaml index 260dc2f..a7ffcc1 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.7.4+12 +version: 0.7.5+13 environment: sdk: ^3.8.1 @@ -37,6 +37,11 @@ dependencies: dynamic_color: ^1.6.6 flutter_secure_storage: ^10.0.0 collection: ^1.18.0 + file_selector: ^1.0.3 + file_selector_linux: ^0.9.3 + file_selector_macos: ^0.9.4 + file_selector_windows: ^0.9.3 + file_selector_web: ^0.9.4 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 76514d8..7defa1f 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,11 +7,14 @@ #include "generated_plugin_registrant.h" #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { DynamicColorPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index a1b5170..9657d9c 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_color + file_selector_windows flutter_secure_storage_windows )