Compare commits

...

2 Commits

Author SHA1 Message Date
ff38c3f838 fix leaderboard formatting, save shared users to drafts, display shared legs
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m59s
Release / web-build (push) Successful in 6m37s
Release / android-build (push) Successful in 18m3s
Release / release-master (push) Successful in 23s
Release / release-dev (push) Successful in 25s
2026-01-03 14:14:31 +00:00
69bd6f688a Add friends leaderboard, reverse button in add page
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 7m11s
Release / web-build (push) Successful in 5m3s
Release / android-build (push) Successful in 18m23s
Release / release-master (push) Successful in 22s
Release / release-dev (push) Successful in 25s
2026-01-03 13:22:43 +00:00
10 changed files with 453 additions and 81 deletions

View File

@@ -366,6 +366,16 @@ class _RouteCalculatorState extends State<RouteCalculator> {
spacing: 12, spacing: 12,
runSpacing: 8, runSpacing: 8,
children: [ children: [
ElevatedButton.icon(
icon: const Icon(Icons.swap_horiz),
label: const Text('Reverse route'),
onPressed: () async {
setState(() {
data.stations = data.stations.reversed.toList();
});
await _calculateRoute(data.stations);
},
),
ElevatedButton.icon( ElevatedButton.icon(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('Add Station'), label: const Text('Add Station'),

View File

@@ -1,19 +1,31 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.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';
enum _LeaderboardScope { global, friends }
class LeaderboardPanel extends StatelessWidget { class LeaderboardPanel extends StatefulWidget {
const LeaderboardPanel({super.key}); const LeaderboardPanel({super.key});
@override
State<LeaderboardPanel> createState() => _LeaderboardPanelState();
}
class _LeaderboardPanelState extends State<LeaderboardPanel> {
_LeaderboardScope _scope = _LeaderboardScope.global;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>(); final distanceUnits = context.watch<DistanceUnitService>();
final leaderboard = data.homepageStats?.leaderboard ?? []; final leaderboard = _scope == _LeaderboardScope.global
? (data.homepageStats?.leaderboard ?? [])
: data.friendsLeaderboard;
final loading = _scope == _LeaderboardScope.global
? data.isHomepageLoading
: data.isFriendsLeaderboardLoading;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
if (data.isHomepageLoading && leaderboard.isEmpty) { if (loading && leaderboard.isEmpty) {
return const Padding( return const Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
@@ -38,7 +50,8 @@ class LeaderboardPanel extends StatelessWidget {
), ),
), ),
), ),
if (leaderboard.isNotEmpty) if (leaderboard.isNotEmpty &&
MediaQuery.of(context).size.width > 600)
Container( Container(
padding: padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4), const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -54,6 +67,40 @@ class LeaderboardPanel extends StatelessWidget {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Center(
child: SegmentedButton<_LeaderboardScope>(
segments: const [
ButtonSegment(
value: _LeaderboardScope.global,
label: Text('Global'),
),
ButtonSegment(
value: _LeaderboardScope.friends,
label: Text('Friends'),
),
],
selected: {_scope},
onSelectionChanged: (vals) async {
if (vals.isEmpty) return;
final selected = vals.first;
setState(() => _scope = selected);
if (selected == _LeaderboardScope.friends &&
data.friendsLeaderboard.isEmpty &&
!data.isFriendsLeaderboardLoading) {
await data.fetchFriendsLeaderboard();
} else if (selected == _LeaderboardScope.global &&
(data.homepageStats?.leaderboard.isEmpty ?? true) &&
!data.isHomepageLoading) {
await data.fetchHomepageStats();
}
},
style: SegmentedButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
),
),
const SizedBox(height: 8),
if (leaderboard.isEmpty) if (leaderboard.isEmpty)
const Padding( const Padding(
padding: EdgeInsets.all(8.0), padding: EdgeInsets.all(8.0),

View File

@@ -28,6 +28,8 @@ class _LegCardState extends State<LegCard> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final leg = widget.leg; final leg = widget.leg;
final isShared = leg.legShareId != null && leg.legShareId!.isNotEmpty; final isShared = leg.legShareId != null && leg.legShareId!.isNotEmpty;
final sharedFrom = leg.sharedFrom;
final sharedTo = leg.sharedTo;
final distanceUnits = context.watch<DistanceUnitService>(); final distanceUnits = context.watch<DistanceUnitService>();
final routeSegments = _parseRouteSegments(leg.route); final routeSegments = _parseRouteSegments(leg.route);
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
@@ -198,16 +200,9 @@ class _LegCardState extends State<LegCard> {
], ],
], ],
), ),
if (isShared) ...[ if (isShared || sharedFrom != null || (sharedTo.isNotEmpty)) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Tooltip( _SharedIcons(sharedFrom: sharedFrom, sharedTo: sharedTo, isShared: isShared),
message: 'Shared entry',
child: Icon(
Icons.share,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
),
], ],
if (widget.showEditButton) ...[ if (widget.showEditButton) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -461,3 +456,61 @@ class _LegCardState extends State<LegCard> {
return route.map((e) => e.toString()).where((e) => e.trim().isNotEmpty).toList(); return route.map((e) => e.toString()).where((e) => e.trim().isNotEmpty).toList();
} }
} }
class _SharedIcons extends StatelessWidget {
const _SharedIcons({
required this.sharedFrom,
required this.sharedTo,
required this.isShared,
});
final LegShareMeta? sharedFrom;
final List<LegShareMeta> sharedTo;
final bool isShared;
@override
Widget build(BuildContext context) {
final icons = <Widget>[];
if (isShared || sharedFrom != null) {
final fromName = sharedFrom?.sharedFromDisplay ?? '';
icons.add(
Tooltip(
message: fromName.isNotEmpty ? 'Shared from $fromName' : 'Shared entry',
child: Icon(
Icons.share,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
),
);
}
if (sharedTo.isNotEmpty) {
final names = sharedTo
.map((s) => s.sharedToDisplay)
.where((name) => name.isNotEmpty)
.toList();
final tooltip = names.isEmpty
? 'Shared to others'
: 'Shared to: ${names.join(', ')}';
icons.add(
Tooltip(
message: tooltip,
child: Icon(
Icons.group,
size: 18,
color: Theme.of(context).colorScheme.tertiary,
),
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < icons.length; i++) ...[
if (i > 0) const SizedBox(width: 6),
icons[i],
],
],
);
}
}

View File

@@ -9,10 +9,19 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
if (_activeDraftId != null && !_draftChangedFromBaseline()) { if (_activeDraftId != null && !_draftChangedFromBaseline()) {
return true; return true;
} }
final currentSnapshot = _currentSubmissionSnapshot();
if (_lastSubmittedSnapshot != null &&
_snapshotEquality.equals(_lastSubmittedSnapshot, currentSnapshot)) {
return true;
}
final choice = await _promptSaveDraft(); final choice = await _promptSaveDraft();
if (choice == _ExitChoice.cancel) return false; if (choice == _ExitChoice.cancel) return false;
if (choice == _ExitChoice.save) { if (choice == _ExitChoice.save) {
try {
await _saveDraftEntry(draftId: _activeDraftId); await _saveDraftEntry(draftId: _activeDraftId);
} catch (_) {
return true;
}
} else if (choice == _ExitChoice.discard) { } else if (choice == _ExitChoice.discard) {
// Delay reset to avoid setState during the dialog/build phase. // Delay reset to avoid setState during the dialog/build phase.
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
@@ -54,6 +63,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
Future<_ExitChoice> _promptSaveDraft() async { Future<_ExitChoice> _promptSaveDraft() async {
if (!mounted) return _ExitChoice.cancel; if (!mounted) return _ExitChoice.cancel;
try {
final result = await showDialog<_ExitChoice>( final result = await showDialog<_ExitChoice>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
@@ -79,7 +89,11 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
], ],
), ),
); );
if (!mounted) return _ExitChoice.cancel;
return result ?? _ExitChoice.cancel; return result ?? _ExitChoice.cancel;
} catch (_) {
return _ExitChoice.cancel;
}
} }
Future<void> _openDrafts() async { Future<void> _openDrafts() async {
@@ -174,6 +188,14 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
"distance": _routeResult!.distance, "distance": _routeResult!.distance,
}, },
"tractionItems": _serializeTractionItems(), "tractionItems": _serializeTractionItems(),
"shareUserIds": _shareUserIds.toList(),
"shareUsers": _shareUsers
.map((u) => {
"user_id": u.userId,
"username": u.username,
"full_name": u.fullName,
})
.toList(),
}; };
await prefs.setString(_kDraftPrefsKey, jsonEncode(draft)); await prefs.setString(_kDraftPrefsKey, jsonEncode(draft));
} }
@@ -449,6 +471,19 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
..clear() ..clear()
..add(_TractionItem.marker()); ..add(_TractionItem.marker());
} }
final shareIdsRaw = data['shareUserIds'];
final shareUsersRaw = data['shareUsers'];
_shareUserIds = shareIdsRaw is List
? shareIdsRaw.map((e) => e.toString()).toSet()
: {};
_shareUsers = shareUsersRaw is List
? shareUsersRaw
.whereType<Map>()
.map((e) => UserSummary.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList()
: [];
_lastSubmittedSnapshot = null; _lastSubmittedSnapshot = null;
final idRaw = data['id']; final idRaw = data['id'];
if (idRaw != null) { if (idRaw != null) {

View File

@@ -58,6 +58,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
LegShareData? _activeLegShare; LegShareData? _activeLegShare;
String? _sharedFromUser; String? _sharedFromUser;
int? _shareNotificationId; int? _shareNotificationId;
bool _routeReversedFlag = false;
bool get _isEditing => widget.editLegId != null; bool get _isEditing => widget.editLegId != null;
bool get _draftPersistenceEnabled => bool get _draftPersistenceEnabled =>
@@ -147,6 +148,48 @@ class _NewEntryPageState extends State<NewEntryPage> {
} }
} }
void _reverseRouteAndEndpoints() {
setState(() {
// Swap start/end and origin/destination fields
final startText = _startController.text;
final endText = _endController.text;
_startController.text = endText;
_endController.text = startText;
final originText = _originController.text;
final destText = _destinationController.text;
_originController.text = destText;
_destinationController.text = originText;
// Reverse route result if present
if (_routeResult != null) {
final reversedInput = _routeResult!.inputRoute.reversed.toList();
final reversedCalc = _routeResult!.calculatedRoute.reversed.toList();
final reversedCosts = _routeResult!.costs.reversed.toList();
_routeResult = RouteResult(
inputRoute: reversedInput,
calculatedRoute: reversedCalc,
costs: reversedCosts,
distance: _routeResult!.distance,
);
final units = _distanceUnits(context);
_mileageController.text = _formatDistance(
units,
_routeResult!.distance,
decimals: 2,
includeUnit: false,
);
_useManualMileage = false;
} else if (_useManualMileage &&
_mileageController.text.trim().isNotEmpty) {
// keep manual mileage, just swap endpoints
}
_routeReversedFlag = !_routeReversedFlag;
});
_saveDraft();
_scheduleMatchUpdate();
}
double _milesFromInputWithUnit(DistanceUnit unit) { double _milesFromInputWithUnit(DistanceUnit unit) {
return DistanceFormatter(unit) return DistanceFormatter(unit)
.parseInputMiles(_mileageController.text.trim()) ?? .parseInputMiles(_mileageController.text.trim()) ??
@@ -1391,17 +1434,53 @@ class _NewEntryPageState extends State<NewEntryPage> {
_buildTractionList(), _buildTractionList(),
], minHeight: balancedHeight); ], minHeight: balancedHeight);
final routeStart = _routeResult?.calculatedRoute.isNotEmpty == true
? _routeResult!.calculatedRoute.first
: (_routeResult?.inputRoute.isNotEmpty == true
? _routeResult!.inputRoute.first
: _startController.text.trim());
final routeEnd = _routeResult?.calculatedRoute.isNotEmpty == true
? _routeResult!.calculatedRoute.last
: (_routeResult?.inputRoute.isNotEmpty == true
? _routeResult!.inputRoute.last
: _endController.text.trim());
final mileagePanel = _section( final mileagePanel = _section(
'Mileage', 'Your Journey',
[ [
if (!_useManualMileage) if (!_useManualMileage)
Align( Wrap(
alignment: Alignment.centerLeft, spacing: 12,
child: ElevatedButton.icon( runSpacing: 8,
children: [
ElevatedButton.icon(
onPressed: _openCalculator, onPressed: _openCalculator,
icon: const Icon(Icons.calculate, size: 18), icon: const Icon(Icons.calculate, size: 18),
label: const Text('Open mileage calculator'), label: const Text('Open mileage calculator'),
), ),
OutlinedButton.icon(
onPressed: _reverseRouteAndEndpoints,
icon: const Icon(Icons.swap_horiz),
label: const Text('Reverse route'),
),
],
)
else
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: _reverseRouteAndEndpoints,
icon: const Icon(Icons.swap_horiz),
label: const Text('Reverse route'),
),
),
if (routeStart.isNotEmpty || routeEnd.isNotEmpty)
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Route'),
subtitle: Text(
'${routeStart.isEmpty ? 'Start' : routeStart}${routeEnd.isEmpty ? 'End' : routeEnd}',
),
), ),
if (_useManualMileage) if (_useManualMileage)
TextFormField( TextFormField(
@@ -1467,22 +1546,25 @@ class _NewEntryPageState extends State<NewEntryPage> {
children: [ children: [
entryPanel, entryPanel,
const SizedBox(height: 16), const SizedBox(height: 16),
if (twoCol) ...[
trainPanel, trainPanel,
const SizedBox(height: 16), const SizedBox(height: 16),
twoCol Row(
? Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded(child: tractionPanel), Expanded(child: tractionPanel),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded(child: mileagePanel), Expanded(child: mileagePanel),
], ],
) ),
: Column( ] else
Column(
children: [ children: [
tractionPanel,
const SizedBox(height: 16),
mileagePanel, mileagePanel,
const SizedBox(height: 16),
trainPanel,
const SizedBox(height: 16),
tractionPanel,
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),

View File

@@ -71,12 +71,14 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
final beginDelay = _parseDelayMinutes(_beginDelayController.text); final beginDelay = _parseDelayMinutes(_beginDelayController.text);
final endDelay = final endDelay =
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0; _hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
final snapshot = _buildSubmissionSnapshot( final snapshot = _currentSubmissionSnapshot(
routeStations: routeStations, routeStations: routeStations,
startVal: startVal, startVal: startVal,
endVal: endVal, endVal: endVal,
mileageVal: mileageVal, mileageVal: mileageVal,
tractionPayload: tractionPayload, tractionPayload: tractionPayload,
beginDelay: beginDelay,
endDelay: endDelay,
); );
if (_lastSubmittedSnapshot != null && if (_lastSubmittedSnapshot != null &&
_snapshotEquality.equals(_lastSubmittedSnapshot, snapshot)) { _snapshotEquality.equals(_lastSubmittedSnapshot, snapshot)) {
@@ -161,16 +163,35 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
} }
} }
Map<String, dynamic> _buildSubmissionSnapshot({ Map<String, dynamic> _currentSubmissionSnapshot({
required List<String> routeStations, List<String>? routeStations,
required String startVal, String? startVal,
required String endVal, String? endVal,
required double mileageVal, double? mileageVal,
required List<Map<String, dynamic>> tractionPayload, List<Map<String, dynamic>>? tractionPayload,
int? beginDelay,
int? endDelay,
}) { }) {
final beginDelay = _parseDelayMinutes(_beginDelayController.text); final stations = routeStations ?? (_routeResult?.calculatedRoute ?? []);
final endDelay = final start = startVal ??
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0; (_useManualMileage
? _startController.text.trim()
: (stations.isNotEmpty ? stations.first : ''));
final end = endVal ??
(_useManualMileage
? _endController.text.trim()
: (stations.isNotEmpty ? stations.last : ''));
final mileage = mileageVal ??
(_useManualMileage
? (_distanceUnits(context)
.milesFromInput(_mileageController.text.trim()) ??
0)
: (_routeResult?.distance ?? 0));
final traction = tractionPayload ?? _buildTractionPayload();
final begin = beginDelay ?? _parseDelayMinutes(_beginDelayController.text);
final endDelayVal =
endDelay ?? (_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0);
return { return {
"legId": widget.editLegId, "legId": widget.editLegId,
"useManualMileage": _useManualMileage, "useManualMileage": _useManualMileage,
@@ -182,20 +203,20 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
"hasOriginTime": _hasOriginTime, "hasOriginTime": _hasOriginTime,
"legDestinationTime": _destinationDateTime?.toIso8601String(), "legDestinationTime": _destinationDateTime?.toIso8601String(),
"hasDestinationTime": _hasDestinationTime, "hasDestinationTime": _hasDestinationTime,
"start": startVal, "start": start,
"end": endVal, "end": end,
"origin": _originController.text.trim(), "origin": _originController.text.trim(),
"destination": _destinationController.text.trim(), "destination": _destinationController.text.trim(),
"routeStations": routeStations, "routeStations": stations,
"mileage": mileageVal, "mileage": mileage,
"network": _networkController.text.trim(), "network": _networkController.text.trim(),
"notes": _notesController.text.trim(), "notes": _notesController.text.trim(),
"headcode": _headcodeController.text.trim(), "headcode": _headcodeController.text.trim(),
"beginDelay": beginDelay, "beginDelay": begin,
"endDelay": endDelay, "endDelay": endDelayVal,
"legShareId": _activeLegShare?.id, "legShareId": _activeLegShare?.id,
"shareUserIds": _shareUserIds.toList(), "shareUserIds": _shareUserIds.toList(),
"locos": tractionPayload, "locos": traction,
"routeResult": _routeResult == null "routeResult": _routeResult == null
? null ? null
: { : {

View File

@@ -1061,6 +1061,8 @@ class Leg {
final String origin, destination; final String origin, destination;
final List<String> route; final List<String> route;
final String? legShareId; final String? legShareId;
final LegShareMeta? sharedFrom;
final List<LegShareMeta> sharedTo;
final DateTime beginTime; final DateTime beginTime;
final DateTime? endTime; final DateTime? endTime;
final DateTime? originTime; final DateTime? originTime;
@@ -1092,6 +1094,8 @@ class Leg {
this.origin = '', this.origin = '',
this.destination = '', this.destination = '',
this.legShareId, this.legShareId,
this.sharedFrom,
this.sharedTo = const [],
}); });
factory Leg.fromJson(Map<String, dynamic> json) { factory Leg.fromJson(Map<String, dynamic> json) {
@@ -1099,6 +1103,25 @@ class Leg {
final parsedEndTime = (endTimeRaw == null || '$endTimeRaw'.isEmpty) final parsedEndTime = (endTimeRaw == null || '$endTimeRaw'.isEmpty)
? null ? null
: _asDateTime(endTimeRaw); : _asDateTime(endTimeRaw);
LegShareMeta? sharedFrom;
final sharedFromJson = json['shared_from'];
if (sharedFromJson is Map) {
sharedFrom = LegShareMeta.fromJson(
sharedFromJson.map((k, v) => MapEntry(k.toString(), v)),
);
}
List<LegShareMeta> sharedTo = const [];
final sharedToJson = json['shared_to'];
if (sharedToJson is List) {
sharedTo = sharedToJson
.whereType<Map>()
.map(
(e) => LegShareMeta.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
),
)
.toList();
}
return Leg( return Leg(
id: _asInt(json['leg_id']), id: _asInt(json['leg_id']),
tripId: _asInt(json['leg_trip']), tripId: _asInt(json['leg_trip']),
@@ -1133,10 +1156,73 @@ class Leg {
origin: _asString(json['leg_origin']), origin: _asString(json['leg_origin']),
destination: _asString(json['leg_destination']), destination: _asString(json['leg_destination']),
legShareId: _asString(json['leg_share_id']), legShareId: _asString(json['leg_share_id']),
sharedFrom: sharedFrom,
sharedTo: sharedTo,
); );
} }
} }
class LegShareMeta {
final int? legShareId;
final int? legId;
final String sharedToUserId;
final String sharedByUserId;
final String status;
final DateTime? respondedAt;
final bool? acceptedEdits;
final DateTime? sharedAt;
final String sharedToUsername;
final String sharedToFullName;
final String sharedByUsername;
final String sharedByFullName;
LegShareMeta({
this.legShareId,
this.legId,
required this.sharedToUserId,
required this.sharedByUserId,
required this.status,
this.respondedAt,
this.acceptedEdits,
this.sharedAt,
this.sharedToUsername = '',
this.sharedToFullName = '',
this.sharedByUsername = '',
this.sharedByFullName = '',
});
factory LegShareMeta.fromJson(Map<String, dynamic> json) {
DateTime? parseDate(dynamic raw) {
if (raw == null) return null;
if (raw is DateTime) return raw;
return DateTime.tryParse(raw.toString());
}
return LegShareMeta(
legShareId: _asInt(json['leg_share_id']),
legId: _asInt(json['leg_id']),
sharedToUserId: _asString(json['shared_to_user_id']),
sharedByUserId: _asString(json['shared_by_user_id']),
status: _asString(json['status'], 'pending'),
respondedAt: parseDate(json['responded_at']),
acceptedEdits: json['accepted_edits'] == null
? null
: _asBool(json['accepted_edits'], false),
sharedAt: parseDate(json['shared_at']),
sharedToUsername: _asString(json['shared_to_username']),
sharedToFullName: _asString(json['shared_to_full_name']),
sharedByUsername: _asString(json['shared_by_username']),
sharedByFullName: _asString(json['shared_by_full_name']),
);
}
String get sharedFromDisplay =>
sharedByFullName.isNotEmpty ? sharedByFullName : sharedByUsername;
String get sharedToDisplay =>
sharedToFullName.isNotEmpty ? sharedToFullName : sharedToUsername;
}
class LegShareData { class LegShareData {
final String id; final String id;
final Leg entry; final Leg entry;

View File

@@ -108,6 +108,10 @@ class DataService extends ChangeNotifier {
bool get isHomepageLoading => _isHomepageLoading; bool get isHomepageLoading => _isHomepageLoading;
bool _isOnThisDayLoading = false; bool _isOnThisDayLoading = false;
bool get isOnThisDayLoading => _isOnThisDayLoading; bool get isOnThisDayLoading => _isOnThisDayLoading;
List<LeaderboardEntry> _friendsLeaderboard = [];
List<LeaderboardEntry> get friendsLeaderboard => _friendsLeaderboard;
bool _isFriendsLeaderboardLoading = false;
bool get isFriendsLeaderboardLoading => _isFriendsLeaderboardLoading;
// Notifications // Notifications
List<UserNotification> _notifications = []; List<UserNotification> _notifications = [];

View File

@@ -25,4 +25,38 @@ extension DataServiceStats on DataService {
_notifyAsync(); _notifyAsync();
} }
} }
Future<void> fetchFriendsLeaderboard() async {
if (_isFriendsLeaderboardLoading) return;
_isFriendsLeaderboardLoading = true;
_notifyAsync();
try {
final json = await api.get('/stats/leaderboard/friends');
List<dynamic>? list;
if (json is List) {
list = json;
} else if (json is Map) {
for (final key in ['leaderboard', 'data', 'items', 'results']) {
final value = json[key];
if (value is List) {
list = value;
break;
}
}
}
_friendsLeaderboard = list
?.whereType<Map>()
.map((e) => LeaderboardEntry.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList() ??
const [];
} catch (e) {
debugPrint('Failed to fetch friends leaderboard: $e');
_friendsLeaderboard = [];
} finally {
_isFriendsLeaderboardLoading = false;
_notifyAsync();
}
}
} }

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.6.0+3 version: 0.6.2+5
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1