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

This commit is contained in:
2026-01-22 23:17:15 +00:00
parent 559f79b805
commit d8312a3f1b
19 changed files with 865 additions and 63 deletions

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.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/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart';
@@ -136,11 +137,10 @@ class _LeaderboardPanelState extends State<LeaderboardPanel> {
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8, spacing: 8,
children: [ children: [
Text( AnimatedCountText(
distanceUnits.format( value: leaderboard[index].mileage,
leaderboard[index].mileage, formatter: (val) =>
decimals: 1, distanceUnits.format(val, decimals: 1),
),
style: textTheme.labelLarge?.copyWith( style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; 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/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -78,11 +79,10 @@ class TopTractionPanel extends StatelessWidget {
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
), ),
), ),
trailing: Text( trailing: AnimatedCountText(
distanceUnits.format( value: (locos[index].mileage ?? 0).toDouble(),
locos[index].mileage ?? 0, formatter: (val) =>
decimals: 1, distanceUnits.format(val, decimals: 1),
),
style: textTheme.labelLarge?.copyWith( style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),

View File

@@ -449,6 +449,23 @@ class _BadgesPageState extends State<BadgesPage> {
ClassClearanceProgress progress, ClassClearanceProgress progress,
) { ) {
final pct = progress.percentComplete.clamp(0, 100); 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( return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0), margin: const EdgeInsets.symmetric(vertical: 4.0),
child: Padding( child: Padding(
@@ -471,9 +488,55 @@ class _BadgesPageState extends State<BadgesPage> {
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
LinearProgressIndicator( if (showActive)
value: progress.total == 0 ? 0 : pct / 100, Padding(
minHeight: 6, 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) if (progress.total > 0)
Padding( Padding(

View File

@@ -1,9 +1,13 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:mileograph_flutter/components/dashboard/latest_loco_changes_panel.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/leaderboard_panel.dart';
import 'package:mileograph_flutter/components/dashboard/top_traction_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/objects/objects.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
@@ -19,6 +23,18 @@ class Dashboard extends StatefulWidget {
class _DashboardState extends State<Dashboard> { class _DashboardState extends State<Dashboard> {
bool _showAllOnThisDay = false; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -31,16 +47,11 @@ class _DashboardState extends State<Dashboard> {
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
await data.fetchHomepageStats(); await _refreshDashboardData(force: true);
await Future.wait([
data.fetchOnThisDay(),
data.fetchTripDetails(),
data.fetchHadTraction(),
data.fetchLatestLocoChanges(),
]);
}, },
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
_handleRouteFocus();
const spacing = 16.0; const spacing = 16.0;
final maxWidth = constraints.maxWidth; final maxWidth = constraints.maxWidth;
return Stack( return Stack(
@@ -97,9 +108,6 @@ class _DashboardState extends State<Dashboard> {
final totalMileage = stats?.totalMileage ?? 0; final totalMileage = stats?.totalMileage ?? 0;
final currentYearMileage = data.getMileageForCurrentYear(); final currentYearMileage = data.getMileageForCurrentYear();
final legCount = stats?.legCount ?? data.trips.length; final legCount = stats?.legCount ?? data.trips.length;
final progress = totalMileage == 0
? 0.0
: (currentYearMileage / totalMileage).clamp(0, 1).toDouble();
return Card( return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@@ -125,62 +133,248 @@ class _DashboardState extends State<Dashboard> {
spacing: 12, spacing: 12,
runSpacing: 12, runSpacing: 12,
children: [ children: [
_metricTile( _animatedMetricTile(
context, context,
label: 'Total mileage', label: 'Total mileage',
value: distanceUnits.format(totalMileage, decimals: 1), value: totalMileage.toDouble(),
formatter: (val) =>
distanceUnits.format(val, decimals: 1),
icon: Icons.route, icon: Icons.route,
color: colorScheme.onPrimaryContainer, color: colorScheme.onPrimaryContainer,
), ),
_metricTile( _animatedMetricTile(
context, context,
label: 'This year', label: 'This year',
value: distanceUnits.format(currentYearMileage, decimals: 1), value: currentYearMileage.toDouble(),
formatter: (val) =>
distanceUnits.format(val, decimals: 1),
icon: Icons.calendar_today, icon: Icons.calendar_today,
color: colorScheme.onPrimaryContainer, color: colorScheme.onPrimaryContainer,
), ),
_metricTile( _animatedMetricTile(
context, context,
label: 'Entries logged', label: 'Entries logged',
value: legCount.toString(), value: legCount.toDouble(),
formatter: (val) => val.round().toString(),
icon: Icons.format_list_bulleted, icon: Icons.format_list_bulleted,
color: colorScheme.onPrimaryContainer, color: colorScheme.onPrimaryContainer,
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ClipRRect( _buildClassClearanceCarousel(context, data, colorScheme),
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),
),
),
], ],
), ),
), ),
); );
} }
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, { BuildContext context, {
required String label, required String label,
required String value, required double value,
required String Function(double) formatter,
required IconData icon, required IconData icon,
required Color color, required Color color,
}) { }) {
@@ -207,8 +401,9 @@ class _DashboardState extends State<Dashboard> {
letterSpacing: 0.4, letterSpacing: 0.4,
), ),
), ),
Text( AnimatedCountText(
value, value: value,
formatter: formatter,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: color, color: color,
@@ -555,8 +750,10 @@ class _DashboardState extends State<Dashboard> {
style: Theme.of(context).textTheme.titleSmall style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w700), ?.copyWith(fontWeight: FontWeight.w700),
), ),
Text( AnimatedCountText(
distanceUnits.format(trip.tripMileage, decimals: 1), value: trip.tripMileage,
formatter: (val) =>
distanceUnits.format(val, decimals: 1),
style: Theme.of(context).textTheme.labelMedium, style: Theme.of(context).textTheme.labelMedium,
), ),
], ],
@@ -573,4 +770,31 @@ class _DashboardState extends State<Dashboard> {
String _formatTime(DateTime date) { String _formatTime(DateTime date) {
return DateFormat('HH:mm').format(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;
}
}
} }

View File

@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:provider/provider.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/api_service.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/utils/download_helper.dart';
class ProfilePage extends StatefulWidget { class ProfilePage extends StatefulWidget {
const ProfilePage({super.key}); const ProfilePage({super.key});
@@ -31,6 +34,10 @@ class _ProfilePageState extends State<ProfilePage> {
bool _showAccountSettings = false; bool _showAccountSettings = false;
bool _changingPassword = false; bool _changingPassword = false;
static const List<String> _visibilityOptions = ['private', 'friends', 'public']; static const List<String> _visibilityOptions = ['private', 'friends', 'public'];
static const List<String> _exportFormats = ['xlsx', 'json', 'csv'];
bool _exporting = false;
String _exportFormat = 'xlsx';
String? _exportError;
UserSummary? _selectedUser; UserSummary? _selectedUser;
Friendship? _status; Friendship? _status;
@@ -71,6 +78,54 @@ class _ProfilePageState extends State<ProfilePage> {
super.dispose(); super.dispose();
} }
Future<void> _exportLogEntries() async {
if (_exporting) return;
setState(() {
_exporting = true;
_exportError = null;
});
try {
final data = context.read<DataService>();
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<void> _searchUsers() async { Future<void> _searchUsers() async {
final query = _searchController.text.trim(); final query = _searchController.text.trim();
if (query.isEmpty) { if (query.isEmpty) {
@@ -829,6 +884,8 @@ class _ProfilePageState extends State<ProfilePage> {
firstChild: Column( firstChild: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildExportSection(theme),
const SizedBox(height: 12),
if (showPrivacySpinner) if (showPrivacySpinner)
const Padding( const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0), padding: EdgeInsets.symmetric(vertical: 8.0),
@@ -1023,6 +1080,60 @@ class _ProfilePageState extends State<ProfilePage> {
); );
} }
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<String>(
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) { UserSummary? _otherUser(Friendship friendship, String? currentUserId) {
final selfId = currentUserId ?? ''; final selfId = currentUserId ?? '';
if (friendship.requester?.userId == selfId) return friendship.addressee; if (friendship.requester?.userId == selfId) return friendship.addressee;

View File

@@ -141,6 +141,7 @@ class _StatsPageState extends State<StatsPage> {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildTypeCountsSection(context, year),
_buildSection<StatsClassMileage>( _buildSection<StatsClassMileage>(
context, context,
title: 'Top classes', title: 'Top classes',
@@ -201,6 +202,83 @@ class _StatsPageState extends State<StatsPage> {
); );
} }
List<MapEntry<String, int>> _sortedTypeCounts(Map<String, int> 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<String, int> 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<T>( Widget _buildSection<T>(
BuildContext context, { BuildContext context, {
required String title, required String title,

View File

@@ -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<AnimatedCountText> createState() => _AnimatedCountTextState();
}
class _AnimatedCountTextState extends State<AnimatedCountText>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _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<double>(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,
);
}
}

View File

@@ -519,6 +519,8 @@ class StatsAbout {
final networkByYear = <int, List<StatsNetworkMileage>>{}; final networkByYear = <int, List<StatsNetworkMileage>>{};
final stationByYear = <int, List<StatsStationVisits>>{}; final stationByYear = <int, List<StatsStationVisits>>{};
final winnersByYear = <int, int>{}; final winnersByYear = <int, int>{};
final winnerTypeCountsByYear = <int, Map<String, int>>{};
final totalTypeCountsByYear = <int, Map<String, int>>{};
void addYearMileage(dynamic entry) { void addYearMileage(dynamic entry) {
if (entry is Map<String, dynamic>) { if (entry is Map<String, dynamic>) {
@@ -589,6 +591,16 @@ class StatsAbout {
} }
} }
Map<String, int> 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<String, int>.fromEntries(entries);
}
return const {};
}
parseYearMap<List<StatsClassMileage>>( parseYearMap<List<StatsClassMileage>>(
json['top_classes'], json['top_classes'],
classByYear, classByYear,
@@ -610,6 +622,19 @@ class StatsAbout {
if (year == null) return; if (year == null) return;
if (value is List) { if (value is List) {
winnersByYear[year] = value.length; 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, ...networkByYear.keys,
...stationByYear.keys, ...stationByYear.keys,
...winnersByYear.keys, ...winnersByYear.keys,
...winnerTypeCountsByYear.keys,
...totalTypeCountsByYear.keys,
}..removeWhere((year) => year == 0); }..removeWhere((year) => year == 0);
final yearMap = <int, StatsYear>{}; final yearMap = <int, StatsYear>{};
@@ -631,6 +658,8 @@ class StatsAbout {
topNetworks: networkByYear[year] ?? const [], topNetworks: networkByYear[year] ?? const [],
topStations: stationByYear[year] ?? const [], topStations: stationByYear[year] ?? const [],
winnerCount: winnersByYear[year] ?? 0, winnerCount: winnersByYear[year] ?? 0,
winnerTypeCounts: winnerTypeCountsByYear[year] ?? const {},
totalTypeCounts: totalTypeCountsByYear[year] ?? const {},
); );
} }
@@ -651,6 +680,8 @@ class StatsYear {
final List<StatsNetworkMileage> topNetworks; final List<StatsNetworkMileage> topNetworks;
final List<StatsStationVisits> topStations; final List<StatsStationVisits> topStations;
final int winnerCount; final int winnerCount;
final Map<String, int> winnerTypeCounts;
final Map<String, int> totalTypeCounts;
StatsYear({ StatsYear({
required this.year, required this.year,
@@ -659,6 +690,8 @@ class StatsYear {
required this.topNetworks, required this.topNetworks,
required this.topStations, required this.topStations,
required this.winnerCount, required this.winnerCount,
required this.winnerTypeCounts,
required this.totalTypeCounts,
}); });
} }
@@ -1779,12 +1812,18 @@ class ClassClearanceProgress {
final int completed; final int completed;
final int total; final int total;
final double percentComplete; final double percentComplete;
final int activeCompleted;
final int activeTotal;
final double activePercent;
ClassClearanceProgress({ ClassClearanceProgress({
required this.className, required this.className,
required this.completed, required this.completed,
required this.total, required this.total,
required this.percentComplete, required this.percentComplete,
required this.activeCompleted,
required this.activeTotal,
required this.activePercent,
}); });
factory ClassClearanceProgress.fromJson(Map<String, dynamic> json) { factory ClassClearanceProgress.fromJson(Map<String, dynamic> json) {
@@ -1802,11 +1841,34 @@ class ClassClearanceProgress {
if (percent == 0 && total > 0) { if (percent == 0 && total > 0) {
percent = (completed / total) * 100; 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( return ClassClearanceProgress(
className: name.isNotEmpty ? name : 'Class', className: name.isNotEmpty ? name : 'Class',
completed: completed, completed: completed,
total: total, total: total,
percentComplete: percent, percentComplete: percent,
activeCompleted: activeCompleted,
activeTotal: activeTotal,
activePercent: activePercent,
); );
} }
} }

View File

@@ -55,6 +55,40 @@ class ApiService {
return _processResponse(response); return _processResponse(response);
} }
Future<ApiBinaryResponse> getBytes(
String endpoint, {
Map<String, String>? 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<dynamic> post( Future<dynamic> post(
String endpoint, String endpoint,
dynamic data, { dynamic data, {
@@ -176,6 +210,20 @@ class ApiService {
} }
return body.toString(); 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 { class ApiException implements Exception {
@@ -192,3 +240,17 @@ class ApiException implements Exception {
@override @override
String toString() => 'API error $statusCode: $message'; String toString() => 'API error $statusCode: $message';
} }
class ApiBinaryResponse {
final List<int> bytes;
final int statusCode;
final String? contentType;
final String? filename;
ApiBinaryResponse({
required this.bytes,
required this.statusCode,
this.contentType,
this.filename,
});
}

View File

@@ -52,12 +52,16 @@ extension DataServiceBadges on DataService {
int offset = 0, int offset = 0,
int limit = 20, int limit = 20,
bool append = false, bool append = false,
bool onlyIncomplete = false,
}) async { }) async {
_isClassClearanceProgressLoading = true; _isClassClearanceProgressLoading = true;
if (!append) _classClearanceProgress = []; if (!append) _classClearanceProgress = [];
try { try {
final json = final onlyIncompleteParam =
await api.get('/badge/completion/class?limit=$limit&offset=$offset'); onlyIncomplete ? '&only_incomplete=true' : '';
final json = await api.get(
'/badge/completion/class?limit=$limit&offset=$offset$onlyIncompleteParam',
);
List<dynamic>? list; List<dynamic>? list;
if (json is List) { if (json is List) {
list = json; list = json;

View File

@@ -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<SaveResult> 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);
}

View File

@@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change # The name of the executable created for the application. Change this to change
# the on-disk name of your application. # 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: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # 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 # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.

View File

@@ -7,12 +7,16 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <dynamic_color/dynamic_color_plugin.h> #include <dynamic_color/dynamic_color_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h> #include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) dynamic_color_registrar = g_autoptr(FlPluginRegistrar) dynamic_color_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin");
dynamic_color_plugin_register_with_registrar(dynamic_color_registrar); 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 = g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
dynamic_color dynamic_color
file_selector_linux
flutter_secure_storage_linux flutter_secure_storage_linux
) )

View File

@@ -6,12 +6,14 @@ import FlutterMacOS
import Foundation import Foundation
import dynamic_color import dynamic_color
import file_selector_macos
import flutter_secure_storage_darwin import flutter_secure_storage_darwin
import path_provider_foundation import path_provider_foundation
import shared_preferences_foundation import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))

View File

@@ -73,6 +73,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" 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: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -121,6 +129,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" 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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -591,4 +663,4 @@ packages:
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.8.1 <4.0.0" dart: ">=3.8.1 <4.0.0"
flutter: ">=3.29.0" flutter: ">=3.32.0"

View File

@@ -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 # 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 # 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. # 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: environment:
sdk: ^3.8.1 sdk: ^3.8.1
@@ -37,6 +37,11 @@ dependencies:
dynamic_color: ^1.6.6 dynamic_color: ^1.6.6
flutter_secure_storage: ^10.0.0 flutter_secure_storage: ^10.0.0
collection: ^1.18.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. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.

View File

@@ -7,11 +7,14 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <dynamic_color/dynamic_color_plugin_c_api.h> #include <dynamic_color/dynamic_color_plugin_c_api.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
DynamicColorPluginCApiRegisterWithRegistrar( DynamicColorPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
} }

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
dynamic_color dynamic_color
file_selector_windows
flutter_secure_storage_windows flutter_secure_storage_windows
) )