add stats page
All checks were successful
All checks were successful
This commit is contained in:
@@ -20,6 +20,7 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
base_version: ${{ steps.meta.outputs.base }}
|
base_version: ${{ steps.meta.outputs.base }}
|
||||||
release_tag: ${{ steps.meta.outputs.release_tag }}
|
release_tag: ${{ steps.meta.outputs.release_tag }}
|
||||||
|
dev_suffix: ${{ steps.meta.outputs.dev_suffix }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -29,12 +30,24 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml)
|
RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml)
|
||||||
BASE_VERSION=${RAW_VERSION%%+*}
|
BASE_VERSION=${RAW_VERSION%%+*}
|
||||||
TAG="v${BASE_VERSION}"
|
VERSION="${BASE_VERSION}"
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
DEV_SUFFIX=""
|
||||||
|
|
||||||
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
|
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
|
||||||
TAG="v${BASE_VERSION}-dev"
|
DEV_ITER="${GITHUB_RUN_NUMBER:-}"
|
||||||
|
if [ -z "$DEV_ITER" ]; then
|
||||||
|
DEV_ITER=$(git rev-list --count HEAD)
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEV_SUFFIX="-dev.${DEV_ITER}"
|
||||||
|
VERSION="${BASE_VERSION}${DEV_SUFFIX}"
|
||||||
|
TAG="v${VERSION}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
|
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "release_tag=${TAG}" >> "$GITHUB_OUTPUT"
|
echo "release_tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "dev_suffix=${DEV_SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Fail if release already exists
|
- name: Fail if release already exists
|
||||||
env:
|
env:
|
||||||
@@ -268,11 +281,19 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
BASE="${{ needs.meta.outputs.base_version }}"
|
BASE="${{ needs.meta.outputs.base_version }}"
|
||||||
TAG="${{ needs.meta.outputs.release_tag }}"
|
TAG="${{ needs.meta.outputs.release_tag }}"
|
||||||
|
DEV_SUFFIX="${{ needs.meta.outputs.dev_suffix }}"
|
||||||
|
if [ -z "$DEV_SUFFIX" ]; then
|
||||||
|
echo "dev_suffix is empty; expected '-dev.<n>'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
mv "artifacts/mileograph-${BASE}.apk" "artifacts/mileograph-${BASE}-dev.apk"
|
VERSION="${BASE}${DEV_SUFFIX}"
|
||||||
|
APK_NAME="mileograph-${VERSION}.apk"
|
||||||
|
|
||||||
|
mv "artifacts/mileograph-${BASE}.apk" "artifacts/${APK_NAME}"
|
||||||
|
|
||||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
echo "apk=artifacts/mileograph-${BASE}-dev.apk" >> "$GITHUB_OUTPUT"
|
echo "apk=artifacts/${APK_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Create prerelease on Gitea
|
- name: Create prerelease on Gitea
|
||||||
if: ${{ github.ref == 'refs/heads/dev' }}
|
if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/profile.dart';
|
import 'package:mileograph_flutter/components/pages/profile.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/settings.dart';
|
import 'package:mileograph_flutter/components/pages/settings.dart';
|
||||||
|
import 'package:mileograph_flutter/components/pages/stats.dart';
|
||||||
|
|
||||||
class MorePage extends StatelessWidget {
|
class MorePage extends StatelessWidget {
|
||||||
const MorePage({super.key});
|
const MorePage({super.key});
|
||||||
@@ -18,12 +19,18 @@ class MorePage extends StatelessWidget {
|
|||||||
case '/profile':
|
case '/profile':
|
||||||
page = const ProfilePage();
|
page = const ProfilePage();
|
||||||
break;
|
break;
|
||||||
|
case '/stats':
|
||||||
|
page = const StatsPage();
|
||||||
|
break;
|
||||||
case '/more/settings':
|
case '/more/settings':
|
||||||
page = const SettingsPage();
|
page = const SettingsPage();
|
||||||
break;
|
break;
|
||||||
case '/more/profile':
|
case '/more/profile':
|
||||||
page = const ProfilePage();
|
page = const ProfilePage();
|
||||||
break;
|
break;
|
||||||
|
case '/more/stats':
|
||||||
|
page = const StatsPage();
|
||||||
|
break;
|
||||||
case '/':
|
case '/':
|
||||||
default:
|
default:
|
||||||
page = _MoreHome();
|
page = _MoreHome();
|
||||||
@@ -54,6 +61,12 @@ class _MoreHome extends StatelessWidget {
|
|||||||
onTap: () => Navigator.of(context).pushNamed('/more/profile'),
|
onTap: () => Navigator.of(context).pushNamed('/more/profile'),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.bar_chart),
|
||||||
|
title: const Text('Stats'),
|
||||||
|
onTap: () => Navigator.of(context).pushNamed('/more/stats'),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.settings),
|
leading: const Icon(Icons.settings),
|
||||||
title: const Text('Settings'),
|
title: const Text('Settings'),
|
||||||
|
|||||||
216
lib/components/pages/stats.dart
Normal file
216
lib/components/pages/stats.dart
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
|
import 'package:mileograph_flutter/services/data_service.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class StatsPage extends StatefulWidget {
|
||||||
|
const StatsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatsPage> createState() => _StatsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatsPageState extends State<StatsPage> {
|
||||||
|
final NumberFormat _mileageFormat = NumberFormat('#,##0.##');
|
||||||
|
final NumberFormat _countFormat = NumberFormat.decimalPattern();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_loadStats();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadStats({bool force = false}) {
|
||||||
|
return context.read<DataService>().fetchAboutStats(force: force);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final data = context.watch<DataService>();
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Stats')),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: () => _loadStats(force: true),
|
||||||
|
child: _buildContent(data),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(DataService data) {
|
||||||
|
final stats = data.aboutStats;
|
||||||
|
final loading = data.isAboutStatsLoading;
|
||||||
|
|
||||||
|
if (loading && stats == null) {
|
||||||
|
return ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
children: const [
|
||||||
|
SizedBox(height: 140),
|
||||||
|
Center(child: CircularProgressIndicator()),
|
||||||
|
SizedBox(height: 140),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats == null || stats.sortedYears.isEmpty) {
|
||||||
|
return ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
const Center(child: Text('No stats available yet.')),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Center(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => _loadStats(force: true),
|
||||||
|
child: const Text('Retry'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final years = stats.sortedYears;
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: years.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: index == years.length - 1 ? 0 : 12),
|
||||||
|
child: _buildYearCard(context, years[index]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildYearCard(BuildContext context, StatsYear year) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
year.year.toString(),
|
||||||
|
style: theme.textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
alignment: WrapAlignment.end,
|
||||||
|
children: [
|
||||||
|
_buildInfoChip(
|
||||||
|
context,
|
||||||
|
label: 'Mileage',
|
||||||
|
value: '${_mileageFormat.format(year.mileage)} mi',
|
||||||
|
),
|
||||||
|
_buildInfoChip(
|
||||||
|
context,
|
||||||
|
label: 'Winners',
|
||||||
|
value: _countFormat.format(year.winnerCount),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildSection<StatsClassMileage>(
|
||||||
|
context,
|
||||||
|
title: 'Top classes',
|
||||||
|
items: year.topClasses,
|
||||||
|
emptyLabel: 'No class data',
|
||||||
|
itemBuilder: (item, index) => ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
title: Text(item.locoClass),
|
||||||
|
trailing: Text('${_mileageFormat.format(item.mileage)} mi'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildSection<StatsNetworkMileage>(
|
||||||
|
context,
|
||||||
|
title: 'Top networks',
|
||||||
|
items: year.topNetworks,
|
||||||
|
emptyLabel: 'No network data',
|
||||||
|
itemBuilder: (item, index) => ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
title: Text(item.network),
|
||||||
|
trailing: Text('${_mileageFormat.format(item.mileage)} mi'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildSection<StatsStationVisits>(
|
||||||
|
context,
|
||||||
|
title: 'Top stations',
|
||||||
|
items: year.topStations,
|
||||||
|
emptyLabel: 'No station data',
|
||||||
|
itemBuilder: (item, index) => ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
title: Text(item.station),
|
||||||
|
trailing: Text(
|
||||||
|
'${_countFormat.format(item.visits)} visit${item.visits == 1 ? '' : 's'}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoChip(BuildContext context,
|
||||||
|
{required String label, required String value}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Chip(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
label: Text(
|
||||||
|
'$label: $value',
|
||||||
|
style: theme.textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSection<T>(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required List<T> items,
|
||||||
|
required Widget Function(T item, int index) itemBuilder,
|
||||||
|
String emptyLabel = 'No data',
|
||||||
|
}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: ExpansionTile(
|
||||||
|
tilePadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
childrenPadding:
|
||||||
|
const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
children: items.isEmpty
|
||||||
|
? [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
emptyLabel,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: items
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map((entry) => itemBuilder(entry.value, entry.key))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -168,6 +168,208 @@ class YearlyMileage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class StatsAbout {
|
||||||
|
final Map<int, StatsYear> years;
|
||||||
|
|
||||||
|
StatsAbout({required this.years});
|
||||||
|
|
||||||
|
factory StatsAbout.fromJson(Map<String, dynamic> json) {
|
||||||
|
final mileageByYear = <int, double>{};
|
||||||
|
final classByYear = <int, List<StatsClassMileage>>{};
|
||||||
|
final networkByYear = <int, List<StatsNetworkMileage>>{};
|
||||||
|
final stationByYear = <int, List<StatsStationVisits>>{};
|
||||||
|
final winnersByYear = <int, int>{};
|
||||||
|
|
||||||
|
void addYearMileage(dynamic entry) {
|
||||||
|
if (entry is Map<String, dynamic>) {
|
||||||
|
final year = entry['year'] is int
|
||||||
|
? entry['year'] as int
|
||||||
|
: int.tryParse('${entry['year']}');
|
||||||
|
if (year != null) {
|
||||||
|
mileageByYear[year] = _asDouble(entry['mileage']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json['year_mileages'] is List) {
|
||||||
|
for (final entry in json['year_mileages']) {
|
||||||
|
if (entry is Map<String, dynamic>) {
|
||||||
|
addYearMileage(entry);
|
||||||
|
} else if (entry is Map) {
|
||||||
|
addYearMileage(entry
|
||||||
|
.map((key, value) => MapEntry(key.toString(), value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<StatsClassMileage> parseClassList(dynamic value) {
|
||||||
|
if (value is List) {
|
||||||
|
return value
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => StatsClassMileage.fromJson(
|
||||||
|
e.map((key, value) => MapEntry(key.toString(), value))))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<StatsNetworkMileage> parseNetworkList(dynamic value) {
|
||||||
|
if (value is List) {
|
||||||
|
return value
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => StatsNetworkMileage.fromJson(
|
||||||
|
e.map((key, value) => MapEntry(key.toString(), value))))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<StatsStationVisits> parseStationList(dynamic value) {
|
||||||
|
if (value is List) {
|
||||||
|
return value
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => StatsStationVisits.fromJson(
|
||||||
|
e.map((key, value) => MapEntry(key.toString(), value))))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
void parseYearMap<T>(
|
||||||
|
dynamic source,
|
||||||
|
Map<int, T> target,
|
||||||
|
T Function(dynamic value) mapper,
|
||||||
|
) {
|
||||||
|
if (source is Map) {
|
||||||
|
source.forEach((key, value) {
|
||||||
|
final year = int.tryParse(key.toString());
|
||||||
|
if (year == null) return;
|
||||||
|
target[year] = mapper(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseYearMap<List<StatsClassMileage>>(
|
||||||
|
json['top_classes'],
|
||||||
|
classByYear,
|
||||||
|
parseClassList,
|
||||||
|
);
|
||||||
|
parseYearMap<List<StatsNetworkMileage>>(
|
||||||
|
json['top_networks'],
|
||||||
|
networkByYear,
|
||||||
|
parseNetworkList,
|
||||||
|
);
|
||||||
|
parseYearMap<List<StatsStationVisits>>(
|
||||||
|
json['top_stations'],
|
||||||
|
stationByYear,
|
||||||
|
parseStationList,
|
||||||
|
);
|
||||||
|
if (json['year_winners'] is Map) {
|
||||||
|
(json['year_winners'] as Map).forEach((key, value) {
|
||||||
|
final year = int.tryParse(key.toString());
|
||||||
|
if (year == null) return;
|
||||||
|
if (value is List) {
|
||||||
|
winnersByYear[year] = value.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final years = <int>{
|
||||||
|
...mileageByYear.keys,
|
||||||
|
...classByYear.keys,
|
||||||
|
...networkByYear.keys,
|
||||||
|
...stationByYear.keys,
|
||||||
|
...winnersByYear.keys,
|
||||||
|
}..removeWhere((year) => year == 0);
|
||||||
|
|
||||||
|
final yearMap = <int, StatsYear>{};
|
||||||
|
for (final year in years) {
|
||||||
|
yearMap[year] = StatsYear(
|
||||||
|
year: year,
|
||||||
|
mileage: mileageByYear[year] ?? 0,
|
||||||
|
topClasses: classByYear[year] ?? const [],
|
||||||
|
topNetworks: networkByYear[year] ?? const [],
|
||||||
|
topStations: stationByYear[year] ?? const [],
|
||||||
|
winnerCount: winnersByYear[year] ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return StatsAbout(years: yearMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<StatsYear> get sortedYears {
|
||||||
|
final list = years.values.toList();
|
||||||
|
list.sort((a, b) => b.year.compareTo(a.year));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatsYear {
|
||||||
|
final int year;
|
||||||
|
final double mileage;
|
||||||
|
final List<StatsClassMileage> topClasses;
|
||||||
|
final List<StatsNetworkMileage> topNetworks;
|
||||||
|
final List<StatsStationVisits> topStations;
|
||||||
|
final int winnerCount;
|
||||||
|
|
||||||
|
StatsYear({
|
||||||
|
required this.year,
|
||||||
|
required this.mileage,
|
||||||
|
required this.topClasses,
|
||||||
|
required this.topNetworks,
|
||||||
|
required this.topStations,
|
||||||
|
required this.winnerCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatsClassMileage {
|
||||||
|
final String locoClass;
|
||||||
|
final double mileage;
|
||||||
|
|
||||||
|
StatsClassMileage({
|
||||||
|
required this.locoClass,
|
||||||
|
required this.mileage,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StatsClassMileage.fromJson(Map<String, dynamic> json) =>
|
||||||
|
StatsClassMileage(
|
||||||
|
locoClass: _asString(json['loco_class'], 'Unknown'),
|
||||||
|
mileage: _asDouble(json['mileage']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatsNetworkMileage {
|
||||||
|
final String network;
|
||||||
|
final double mileage;
|
||||||
|
|
||||||
|
StatsNetworkMileage({
|
||||||
|
required this.network,
|
||||||
|
required this.mileage,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StatsNetworkMileage.fromJson(Map<String, dynamic> json) =>
|
||||||
|
StatsNetworkMileage(
|
||||||
|
network: _asString(json['network'], 'Unknown'),
|
||||||
|
mileage: _asDouble(json['mileage']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatsStationVisits {
|
||||||
|
final String station;
|
||||||
|
final int visits;
|
||||||
|
|
||||||
|
StatsStationVisits({
|
||||||
|
required this.station,
|
||||||
|
required this.visits,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StatsStationVisits.fromJson(Map<String, dynamic> json) =>
|
||||||
|
StatsStationVisits(
|
||||||
|
station: _asString(json['station'], 'Unknown'),
|
||||||
|
visits: _asInt(json['visits']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class Loco {
|
class Loco {
|
||||||
final int id;
|
final int id;
|
||||||
final String type, number, locoClass;
|
final String type, number, locoClass;
|
||||||
|
|||||||
@@ -128,7 +128,12 @@ class ApiService {
|
|||||||
await _onUnauthorized!();
|
await _onUnauthorized!();
|
||||||
}
|
}
|
||||||
|
|
||||||
throw Exception('API error ${res.statusCode}: $body');
|
final message = _extractErrorMessage(body);
|
||||||
|
throw ApiException(
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
message: message,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
dynamic _decodeBody(http.Response res) {
|
dynamic _decodeBody(http.Response res) {
|
||||||
@@ -149,4 +154,41 @@ class ApiService {
|
|||||||
return res.body;
|
return res.body;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _extractErrorMessage(dynamic body) {
|
||||||
|
if (body == null) return 'No response body';
|
||||||
|
if (body is String) return body;
|
||||||
|
if (body is Map<String, dynamic>) {
|
||||||
|
for (final key in ['message', 'error', 'detail', 'msg']) {
|
||||||
|
final val = body[key];
|
||||||
|
if (val is String && val.trim().isNotEmpty) return val;
|
||||||
|
}
|
||||||
|
return body.toString();
|
||||||
|
}
|
||||||
|
if (body is List) {
|
||||||
|
final parts = body
|
||||||
|
.map((e) => e is Map
|
||||||
|
? _extractErrorMessage(Map<String, dynamic>.from(e))
|
||||||
|
: e.toString())
|
||||||
|
.where((e) => e.trim().isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (parts.isNotEmpty) return parts.join('; ');
|
||||||
|
}
|
||||||
|
return body.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiException implements Exception {
|
||||||
|
final int statusCode;
|
||||||
|
final String message;
|
||||||
|
final dynamic body;
|
||||||
|
|
||||||
|
ApiException({
|
||||||
|
required this.statusCode,
|
||||||
|
required this.message,
|
||||||
|
this.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'API error $statusCode: $message';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ part 'data_service_traction.dart';
|
|||||||
part 'data_service_trips.dart';
|
part 'data_service_trips.dart';
|
||||||
part 'data_service_notifications.dart';
|
part 'data_service_notifications.dart';
|
||||||
part 'data_service_badges.dart';
|
part 'data_service_badges.dart';
|
||||||
|
part 'data_service_stats.dart';
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ class DataService extends ChangeNotifier {
|
|||||||
// Homepage Data
|
// Homepage Data
|
||||||
HomepageStats? _homepageStats;
|
HomepageStats? _homepageStats;
|
||||||
HomepageStats? get homepageStats => _homepageStats;
|
HomepageStats? get homepageStats => _homepageStats;
|
||||||
|
StatsAbout? _aboutStats;
|
||||||
|
StatsAbout? get aboutStats => _aboutStats;
|
||||||
|
bool _isAboutStatsLoading = false;
|
||||||
|
bool get isAboutStatsLoading => _isAboutStatsLoading;
|
||||||
|
|
||||||
// Legs Data
|
// Legs Data
|
||||||
List<Leg> _legs = [];
|
List<Leg> _legs = [];
|
||||||
|
|||||||
28
lib/services/data_service/data_service_stats.dart
Normal file
28
lib/services/data_service/data_service_stats.dart
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
part of 'data_service.dart';
|
||||||
|
|
||||||
|
extension DataServiceStats on DataService {
|
||||||
|
Future<void> fetchAboutStats({bool force = false}) async {
|
||||||
|
if (_isAboutStatsLoading) return;
|
||||||
|
if (!force && _aboutStats != null) return;
|
||||||
|
_isAboutStatsLoading = true;
|
||||||
|
_notifyAsync();
|
||||||
|
try {
|
||||||
|
final json = await api.get('/stats/about');
|
||||||
|
if (json is Map<String, dynamic>) {
|
||||||
|
_aboutStats = StatsAbout.fromJson(json);
|
||||||
|
} else if (json is Map) {
|
||||||
|
_aboutStats = StatsAbout.fromJson(
|
||||||
|
json.map((key, value) => MapEntry(key.toString(), value)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw Exception('Unexpected stats response: $json');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch about stats: $e');
|
||||||
|
_aboutStats = null;
|
||||||
|
} finally {
|
||||||
|
_isAboutStatsLoading = false;
|
||||||
|
_notifyAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import 'package:mileograph_flutter/components/pages/new_entry.dart';
|
|||||||
import 'package:mileograph_flutter/components/pages/new_traction.dart';
|
import 'package:mileograph_flutter/components/pages/new_traction.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/profile.dart';
|
import 'package:mileograph_flutter/components/pages/profile.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/settings.dart';
|
import 'package:mileograph_flutter/components/pages/settings.dart';
|
||||||
|
import 'package:mileograph_flutter/components/pages/stats.dart';
|
||||||
import 'package:mileograph_flutter/components/pages/traction.dart';
|
import 'package:mileograph_flutter/components/pages/traction.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';
|
||||||
@@ -212,6 +213,10 @@ class _MyAppState extends State<MyApp> {
|
|||||||
path: '/more/profile',
|
path: '/more/profile',
|
||||||
builder: (context, state) => const ProfilePage(),
|
builder: (context, state) => const ProfilePage(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/more/stats',
|
||||||
|
builder: (context, state) => const StatsPage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/more/settings',
|
path: '/more/settings',
|
||||||
builder: (context, state) => const SettingsPage(),
|
builder: (context, state) => const SettingsPage(),
|
||||||
|
|||||||
Reference in New Issue
Block a user