29 Commits

Author SHA1 Message Date
8116cfe7b1 fix secure storage and release pipeline
All checks were successful
Release / meta (push) Successful in 19s
Release / android-build (push) Successful in 5m53s
Release / linux-build (push) Successful in 8m3s
Release / release-master (push) Successful in 2s
Release / release-dev (push) Successful in 32s
2025-12-14 08:39:22 +00:00
4d483495fc add secure storage (not working)
Some checks failed
Release / meta (push) Successful in 2s
Release / android-build (push) Successful in 6m25s
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
Release / linux-build (push) Failing after 2m16s
2025-12-12 09:58:52 +00:00
292163bda6 Fix trips on entry page, correct order of trips on dashboard and trip page, move to prod api
Some checks failed
Release / meta (push) Successful in 17s
Release / linux-build (push) Successful in 1m33s
Release / android-build (push) Successful in 15m28s
Release / release-dev (push) Failing after 4s
Release / release-master (push) Successful in 29s
2025-12-12 09:17:18 +00:00
53eaf0b4af Update .gitea/workflows/release.yml
Some checks failed
Release / meta (push) Successful in 17s
Release / linux-build (push) Successful in 1m35s
Release / android-build (push) Successful in 15m21s
Release / release-dev (push) Failing after 4s
Release / release-master (push) Successful in 24s
2025-12-12 08:17:59 +00:00
b76ed0499c Update .gitea/workflows/release.yml
Some checks failed
Release / meta (push) Successful in 12s
Release / linux-build (push) Successful in 1m12s
Release / android-build (push) Successful in 10m59s
Release / release-master (push) Successful in 3s
Release / release-dev (push) Successful in 19s
Release / windows-build (push) Has been cancelled
2025-12-11 20:45:20 +00:00
84d50d5a90 set trip to null, not 0
Some checks failed
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 1m13s
Release / android-build (push) Successful in 11m2s
Release / release-master (push) Successful in 3s
Release / release-dev (push) Successful in 14s
Release / windows-build (push) Has been cancelled
2025-12-11 20:15:06 +00:00
26b1a4878f Update .gitea/workflows/release.yml
Some checks failed
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 1m14s
Release / android-build (push) Successful in 11m54s
Release / release-master (push) Successful in 2s
Release / release-dev (push) Failing after 13s
Release / windows-build (push) Has been cancelled
2025-12-11 19:59:06 +00:00
5f1a542d1e Update .gitea/workflows/release.yml
Some checks failed
Release / meta (push) Successful in 1m20s
Release / linux-build (push) Successful in 1m16s
Release / android-build (push) Successful in 16m3s
Release / release-dev (push) Failing after 5s
Release / release-master (push) Successful in 26s
Release / windows-build (push) Has been cancelled
2025-12-11 16:08:08 +00:00
e8c244a940 Update .gitea/workflows/release.yml
Some checks failed
Release / meta (push) Successful in 2s
Release / linux-build (push) Successful in 1m35s
Release / android-build (push) Successful in 5m54s
Release / release-dev (push) Failing after 5s
Release / release-master (push) Successful in 3s
Release / windows-build (push) Has been cancelled
2025-12-11 15:08:24 +00:00
6db12d464d Update .gitea/workflows/release.yml
All checks were successful
Release / meta (push) Successful in 21s
Release / android-build (push) Successful in 10s
2025-12-11 15:07:41 +00:00
54d3577fd3 Update .gitea/workflows/release.yml
Some checks failed
Release / meta (push) Successful in 2s
Release / linux-build (push) Successful in 1m27s
Release / android-build (push) Successful in 5m46s
Release / release-master (push) Has been skipped
Release / release-dev (push) Failing after 6s
Release / windows-build (push) Has been cancelled
2025-12-11 14:31:00 +00:00
8cf59a5c10 Update .gitea/workflows/release.yml
Some checks failed
Release / meta (push) Successful in 22s
Release / android-build (push) Failing after 13s
2025-12-11 14:30:06 +00:00
0b7bae3eed Update .gitea/workflows/release.yml 2025-12-11 14:28:58 +00:00
a5c5d50f5a Update .gitea/workflows/release.yml
Some checks failed
Release / meta (push) Successful in 3s
Release / linux-build (push) Successful in 1m34s
Release / android-build (push) Successful in 6m46s
Release / windows-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2025-12-11 13:41:51 +00:00
8924d25dd9 Update .gitea/workflows/release.yml
Some checks failed
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 1m57s
Release / android-build (push) Successful in 6m33s
Release / windows-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2025-12-11 13:30:35 +00:00
a70a298f80 Update .gitea/workflows/release.yml
Some checks failed
Release / meta (push) Successful in 2s
Release / linux-build (push) Successful in 1m29s
Release / windows-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / android-build (push) Has been cancelled
2025-12-11 13:24:56 +00:00
c9c20472ea Update .gitea/workflows/release.yml
Some checks failed
Release / meta (push) Successful in 2s
Release / linux-build (push) Successful in 1m30s
Release / android-build (push) Successful in 5m55s
Release / release-dev (push) Successful in 5s
Release / windows-build (push) Has been cancelled
Release / release-master (push) Has been cancelled
2025-12-11 13:10:03 +00:00
66bef3cbc6 Update .gitea/workflows/release.yml
Some checks failed
Release / meta (push) Successful in 22s
Release / linux-build (push) Successful in 1m26s
Release / android-build (push) Successful in 5m46s
Release / release-dev (push) Failing after 4s
Release / windows-build (push) Has been cancelled
Release / release-master (push) Has been cancelled
2025-12-11 12:56:12 +00:00
9fe3787bfb Update .gitea/workflows/release.yml
Some checks failed
Release / meta (push) Successful in 2s
Release / linux-build (push) Successful in 1m26s
Release / android-build (push) Successful in 5m33s
Release / release-dev (push) Failing after 11s
Release / windows-build (push) Has been cancelled
Release / release-master (push) Has been cancelled
2025-12-11 02:18:40 +00:00
60f87c3bfd Update .gitea/workflows/release.yml
Some checks failed
Release / meta (push) Successful in 22s
Release / linux-build (push) Successful in 1m27s
Release / android-build (push) Successful in 5m43s
Release / release-dev (push) Failing after 16s
Release / windows-build (push) Has been cancelled
Release / release-master (push) Has been cancelled
2025-12-11 02:10:34 +00:00
c8d962b770 update to v3 upload action
Some checks failed
Release / meta (push) Successful in 3s
Release / linux-build (push) Successful in 1m35s
Release / android-build (push) Successful in 4m50s
Release / windows-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2025-12-11 01:52:48 +00:00
40fb88a089 bypass check for flutter ownership
Some checks failed
Release / meta (push) Successful in 2s
Release / linux-build (push) Failing after 1m25s
Release / android-build (push) Failing after 5m4s
Release / windows-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2025-12-11 01:45:38 +00:00
0d896786ca Update .gitea/workflows/release.yml
Some checks failed
Release / meta (push) Successful in 2s
Release / android-build (push) Failing after 1m55s
Release / linux-build (push) Failing after 2m4s
Release / windows-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2025-12-11 01:34:38 +00:00
eaabade12a fix release pipeline 2025-12-11 01:31:39 +00:00
e34c689ed9 new pipeline
Some checks failed
Release / meta (push) Successful in 2s
Release / android-build (push) Failing after 15s
Release / linux-build (push) Failing after 34s
Release / windows-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2025-12-11 01:24:44 +00:00
40ee16d2d5 initial codex commit
Some checks failed
Release / build (push) Failing after 48s
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
Release / windows-build (push) Has been cancelled
2025-12-11 01:08:30 +00:00
e6d7e71a36 Update .gitea/workflows/release.yml
Some checks failed
Release / build (push) Failing after 2m24s
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
Release / windows-build (push) Has been cancelled
2025-12-11 01:04:40 +00:00
b28208914c fix yaml 2025-12-11 00:58:34 +00:00
90fe9cae70 add pipeline 2025-12-10 22:44:32 +00:00
25 changed files with 3506 additions and 293 deletions

View File

@@ -0,0 +1,284 @@
name: Release
on:
push:
branches:
- master
- dev
env:
JAVA_VERSION: "17"
ANDROID_SDK_ROOT: "${{ github.workspace }}/android-sdk"
FLUTTER_CHANNEL: "stable"
BUILD_WINDOWS: "false" # set to "true" when you actually want Windows builds
GITEA_BASE_URL: https://git.tgj.services
jobs:
meta:
runs-on: ubuntu-latest
outputs:
base_version: ${{ steps.meta.outputs.base }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Determine version
id: meta
run: |
RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml)
BASE_VERSION=${RAW_VERSION%%+*}
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
android-build:
runs-on: ubuntu-latest
needs: meta
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install OS deps (Android)
run: |
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
SUDO=""
fi
$SUDO apt-get update
$SUDO apt-get install -y unzip xz-utils zip libstdc++6 liblzma-dev curl jq
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}
- name: Install Android SDK
run: |
mkdir -p "$ANDROID_SDK_ROOT"/cmdline-tools
curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -o /tmp/cli-tools.zip
unzip -q /tmp/cli-tools.zip -d "$ANDROID_SDK_ROOT"/cmdline-tools
mv "$ANDROID_SDK_ROOT"/cmdline-tools/cmdline-tools "$ANDROID_SDK_ROOT"/cmdline-tools/latest
# Accept licences (ignore SIGPIPE exit 141)
yes | "$ANDROID_SDK_ROOT"/cmdline-tools/latest/bin/sdkmanager --sdk_root="$ANDROID_SDK_ROOT" --licenses || true
# Install required packages (also ignore SIGPIPE)
yes | "$ANDROID_SDK_ROOT"/cmdline-tools/latest/bin/sdkmanager --sdk_root="$ANDROID_SDK_ROOT" \
"platform-tools" "platforms;android-33" "build-tools;33.0.2" || true
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH"
echo "$ANDROID_SDK_ROOT/build-tools/33.0.2" >> "$GITHUB_PATH"
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: ${{ env.FLUTTER_CHANNEL }}
- name: Allow all git directories (CI)
run: git config --global --add safe.directory '*'
- name: Flutter dependencies
run: flutter pub get
- name: Build APK (release)
run: |
flutter build apk --release
cp build/app/outputs/flutter-apk/app-release.apk app-release.apk
- name: Upload Android APK artifact
uses: actions/upload-artifact@v3
with:
name: android-apk
path: app-release.apk
linux-build:
runs-on: ubuntu-latest
needs: meta
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install OS deps (Linux desktop)
run: |
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
SUDO=""
fi
$SUDO apt-get update
$SUDO apt-get install -y unzip xz-utils zip libstdc++6 libglu1-mesa clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev liblzma-dev curl jq
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: ${{ env.FLUTTER_CHANNEL }}
- name: Allow all git directories (CI)
run: git config --global --add safe.directory '*'
- name: Flutter dependencies
run: flutter pub get
- name: Enable Linux desktop
run: flutter config --enable-linux-desktop
- name: Build Linux binary (release)
run: |
flutter build linux --release
tar -C build/linux/x64/release/bundle -czf app-linux-x64.tar.gz .
- name: Upload Linux artifact
uses: actions/upload-artifact@v3
with:
name: linux-bundle
path: app-linux-x64.tar.gz
release-dev:
runs-on: ubuntu-latest
needs:
- meta
- android-build
- linux-build
steps:
- name: Install jq
run: |
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
SUDO=""
fi
$SUDO apt-get update
$SUDO apt-get install -y jq
- name: Download Android APK
if: ${{ github.ref == 'refs/heads/dev' }}
uses: actions/download-artifact@v3
with:
name: android-apk
path: artifacts
- name: Download Linux bundle
if: ${{ github.ref == 'refs/heads/dev' }}
uses: actions/download-artifact@v3
with:
name: linux-bundle
path: artifacts
- name: Download Windows bundle (optional)
if: ${{ github.ref == 'refs/heads/dev' && env.BUILD_WINDOWS == 'true' }}
uses: actions/download-artifact@v3
with:
name: windows-zip
path: artifacts
- name: Prepare artefacts and tag
if: ${{ github.ref == 'refs/heads/dev' }}
id: bundle
run: |
BASE="${{ needs.meta.outputs.base_version }}"
TAG="v${BASE}-dev"
mv artifacts/app-release.apk "artifacts/app-${BASE}-dev.apk"
mv artifacts/app-linux-x64.tar.gz "artifacts/app-linux-x64-${BASE}-dev.tar.gz"
if [ -f artifacts/app-windows-x64.zip ]; then
mv artifacts/app-windows-x64.zip "artifacts/app-windows-x64-${BASE}-dev.zip"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "files=artifacts/*" >> "$GITHUB_OUTPUT"
- name: Create prerelease on Gitea
if: ${{ github.ref == 'refs/heads/dev' }}
uses: ncipollo/release-action@v1
with:
tag: v${{ needs.meta.outputs.base_version }}-dev.${{ github.run_number }}
name: v${{ needs.meta.outputs.base_version }}-dev build ${{ github.run_number }}
prerelease: true
commit: ${{ github.sha }}
token: ${{ secrets.GITEA_TOKEN }}
# NOTE: no `artifacts:` here
- name: Attach artefacts to Gitea release
if: ${{ github.ref == 'refs/heads/dev' }}
run: |
set -euo pipefail
BASE="${{ needs.meta.outputs.base_version }}"
TAG="v${BASE}-dev.${{ github.run_number }}"
# 1. Find release ID by tag
RELEASE_JSON=$(curl -sS \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/tags/${TAG}")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
echo "Release ID: $RELEASE_ID"
# 2. Upload each artefact with multipart/form-data
for f in artifacts/*; do
[ -f "$f" ] || continue
NAME=$(basename "$f")
echo "Uploading $NAME"
curl -sS -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@${f}" \
-F "name=${NAME}" \
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null
done
release-master:
runs-on: ubuntu-latest
needs:
- meta
- android-build
- linux-build
steps:
- name: Download Android APK
if: ${{ github.ref == 'refs/heads/master' }}
uses: actions/download-artifact@v3
with:
name: android-apk
path: artifacts
- name: Download Linux bundle
if: ${{ github.ref == 'refs/heads/master' }}
uses: actions/download-artifact@v3
with:
name: linux-bundle
path: artifacts
- name: Download Windows bundle (optional)
if: ${{ github.ref == 'refs/heads/master' && env.BUILD_WINDOWS == 'true' }}
uses: actions/download-artifact@v3
with:
name: windows-zip
path: artifacts
- name: Prepare artefacts and tag
if: ${{ github.ref == 'refs/heads/master' }}
id: bundle
run: |
BASE="${{ needs.meta.outputs.base_version }}"
TAG="v${BASE}"
mv artifacts/app-release.apk "artifacts/app-${BASE}.apk"
mv artifacts/app-linux-x64.tar.gz "artifacts/app-linux-x64-${BASE}.tar.gz"
if [ -f artifacts/app-windows-x64.zip ]; then
mv artifacts/app-windows-x64.zip "artifacts/app-windows-x64-${BASE}.zip"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "files=artifacts/*" >> "$GITHUB_OUTPUT"
- name: Create release on Gitea
if: ${{ github.ref == 'refs/heads/master' }}
uses: ncipollo/release-action@v1
with:
tag: ${{ steps.bundle.outputs.tag }}
name: ${{ steps.bundle.outputs.tag }}
prerelease: false
token: ${{ secrets.GITEA_TOKEN }}
artifacts: ${{ steps.bundle.outputs.files }}

View File

@@ -97,7 +97,10 @@ class _StationAutocompleteState extends State<StationAutocomplete> {
} }
class RouteCalculator extends StatefulWidget { class RouteCalculator extends StatefulWidget {
const RouteCalculator({super.key}); const RouteCalculator({super.key, this.onDistanceComputed, this.onApplyRoute});
final ValueChanged<double>? onDistanceComputed;
final ValueChanged<RouteResult>? onApplyRoute;
@override @override
State<RouteCalculator> createState() => _RouteCalculatorState(); State<RouteCalculator> createState() => _RouteCalculatorState();
@@ -143,11 +146,11 @@ class _RouteCalculatorState extends State<RouteCalculator> {
setState(() { setState(() {
_routeResult = RouteResult.fromJson(res); _routeResult = RouteResult.fromJson(res);
}); });
final distance = (_routeResult?.distance ?? 0);
widget.onDistanceComputed?.call(distance);
} else { } else {
setState(() { setState(() {
_errorMessage = _errorMessage = RouteError.fromJson(res["error_obj"][0]).msg;
RouteError.fromJson(res["error_obj"][0]).msg ??
'Unknown error occurred';
}); });
} }
} }
@@ -248,11 +251,21 @@ class _RouteCalculatorState extends State<RouteCalculator> {
style: TextStyle(color: Theme.of(context).colorScheme.error), style: TextStyle(color: Theme.of(context).colorScheme.error),
), ),
) )
else if (_routeResult != null) else if (_routeResult != null) ...[
RouteSummaryWidget( RouteSummaryWidget(
distance: _routeResult!.distance, distance: _routeResult!.distance,
onDetailsPressed: () => setState(() => _showDetails = true), onDetailsPressed: () => setState(() => _showDetails = true),
) ),
if (widget.onApplyRoute != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ElevatedButton.icon(
onPressed: () => widget.onApplyRoute!(_routeResult!),
icon: const Icon(Icons.check),
label: const Text('Apply to entry'),
),
),
]
else else
SizedBox.shrink(), SizedBox.shrink(),
const SizedBox(height: 10), const SizedBox(height: 10),

View File

@@ -4,8 +4,18 @@ import 'package:mileograph_flutter/services/dataService.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class LeaderboardPanel extends StatelessWidget { class LeaderboardPanel extends StatelessWidget {
const LeaderboardPanel({super.key});
@override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final leaderboard = data.homepageStats?.leaderboard ?? [];
if (data.isHomepageLoading && leaderboard.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
);
}
return Padding( return Padding(
padding: const EdgeInsets.all(10.0), padding: const EdgeInsets.all(10.0),
child: Card( child: Card(
@@ -20,19 +30,23 @@ class LeaderboardPanel extends StatelessWidget {
decoration: TextDecoration.underline, decoration: TextDecoration.underline,
), ),
), ),
if (leaderboard.isEmpty)
const Padding(
padding: EdgeInsets.all(16.0),
child: Text('No leaderboard data yet'),
)
else
Column( Column(
children: List.generate( children: List.generate(
data.homepageStats?.leaderboard.length ?? 0, leaderboard.length,
(index) { (index) {
final leaderboardEntry = final leaderboardEntry = leaderboard[index];
data.homepageStats!.leaderboard[index];
return Container( return Container(
width: double.infinity, width: double.infinity,
child: Container( margin: const EdgeInsets.symmetric(
margin: EdgeInsets.symmetric(horizontal: 0, vertical: 8), horizontal: 0, vertical: 8),
child: Padding( child: Padding(
padding: EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -41,12 +55,12 @@ class LeaderboardPanel extends StatelessWidget {
children: [ children: [
TextSpan( TextSpan(
text: '${index + 1}. ', text: '${index + 1}. ',
style: TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
TextSpan( TextSpan(
text: '${leaderboardEntry.userFullName}', text: leaderboardEntry.userFullName,
), ),
], ],
), ),
@@ -57,7 +71,6 @@ class LeaderboardPanel extends StatelessWidget {
], ],
), ),
), ),
),
); );
}, },
), ),

View File

@@ -4,8 +4,19 @@ import 'package:mileograph_flutter/services/dataService.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class TopTractionPanel extends StatelessWidget { class TopTractionPanel extends StatelessWidget {
const TopTractionPanel({super.key});
@override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final stats = data.homepageStats;
final locos = stats?.topLocos ?? [];
if (data.isHomepageLoading && locos.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
);
}
return Padding( return Padding(
padding: const EdgeInsets.all(10.0), padding: const EdgeInsets.all(10.0),
child: Card( child: Card(
@@ -20,18 +31,23 @@ class TopTractionPanel extends StatelessWidget {
decoration: TextDecoration.underline, decoration: TextDecoration.underline,
), ),
), ),
if (locos.isEmpty)
const Padding(
padding: EdgeInsets.all(16.0),
child: Text('No traction data yet'),
)
else
Column( Column(
children: List.generate( children: List.generate(
data.homepageStats?.topLocos.length ?? 0, locos.length,
(index) { (index) {
final loco = data.homepageStats!.topLocos[index]; final loco = locos[index];
return Container( return Container(
width: double.infinity, width: double.infinity,
child: Container( margin:
margin: EdgeInsets.symmetric(horizontal: 0, vertical: 8), const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
child: Padding( child: Padding(
padding: EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -43,7 +59,7 @@ class TopTractionPanel extends StatelessWidget {
children: [ children: [
TextSpan( TextSpan(
text: '${index + 1}. ', text: '${index + 1}. ',
style: TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -55,8 +71,9 @@ class TopTractionPanel extends StatelessWidget {
), ),
), ),
Text( Text(
'${loco.name}', loco.name ?? '',
style: TextStyle(fontStyle: FontStyle.italic), style:
const TextStyle(fontStyle: FontStyle.italic),
), ),
], ],
), ),
@@ -64,7 +81,6 @@ class TopTractionPanel extends StatelessWidget {
], ],
), ),
), ),
),
); );
}, },
), ),

View File

@@ -13,7 +13,6 @@ class LoginScreen extends StatelessWidget {
color: Theme.of(context).scaffoldBackgroundColor, color: Theme.of(context).scaffoldBackgroundColor,
child: Center( child: Center(
child: Column( child: Column(
spacing: 50,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text.rich( Text.rich(
@@ -44,6 +43,7 @@ class LoginScreen extends StatelessWidget {
), ),
), ),
), ),
const SizedBox(height: 50),
LoginPanel(), LoginPanel(),
], ],
), ),
@@ -115,7 +115,7 @@ class _LoginPanelContentState extends State<LoginPanelContent> {
bool _loggingIn = false; bool _loggingIn = false;
void login() async { Future<void> login() async {
final username = _usernameController.text; final username = _usernameController.text;
final password = _passwordController.text; final password = _passwordController.text;
@@ -126,19 +126,18 @@ class _LoginPanelContentState extends State<LoginPanelContent> {
}); });
try { try {
await auth.login(username, password); await auth.login(username, password);
print('Login successful'); if (!mounted) return;
setState(() { setState(() {
_loggingIn = false; _loggingIn = false;
}); });
} catch (e) { } catch (e) {
// Handle error if (!mounted) return;
print('Login failed: $e');
setState(() { setState(() {
_loggingIn = false; _loggingIn = false;
}); });
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text('Login failed: $e')),
).showSnackBar(SnackBar(content: Text('Login failed'))); );
} }
} }
@@ -163,7 +162,6 @@ class _LoginPanelContentState extends State<LoginPanelContent> {
return Column( return Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 50), padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 50),
@@ -172,6 +170,7 @@ class _LoginPanelContentState extends State<LoginPanelContent> {
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
), ),
), ),
const SizedBox(height: 8),
TextFormField( TextFormField(
controller: _usernameController, controller: _usernameController,
decoration: InputDecoration( decoration: InputDecoration(
@@ -180,6 +179,7 @@ class _LoginPanelContentState extends State<LoginPanelContent> {
), ),
onFieldSubmitted: (_) => login(), onFieldSubmitted: (_) => login(),
), ),
const SizedBox(height: 8),
TextFormField( TextFormField(
controller: _passwordController, controller: _passwordController,
obscureText: true, obscureText: true,
@@ -189,11 +189,12 @@ class _LoginPanelContentState extends State<LoginPanelContent> {
), ),
onFieldSubmitted: (_) => login(), onFieldSubmitted: (_) => login(),
), ),
const SizedBox(height: 12),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
spacing: 10,
children: [ children: [
FilledButton(onPressed: login, child: loginButtonContent), FilledButton(onPressed: login, child: loginButtonContent),
const SizedBox(width: 10),
ElevatedButton( ElevatedButton(
onPressed: widget.registerCb, onPressed: widget.registerCb,
child: Text("Register"), child: Text("Register"),
@@ -205,7 +206,7 @@ class _LoginPanelContentState extends State<LoginPanelContent> {
} }
} }
class RegisterPanelContent extends StatelessWidget { class RegisterPanelContent extends StatefulWidget {
const RegisterPanelContent({ const RegisterPanelContent({
super.key, super.key,
required this.onBack, required this.onBack,
@@ -213,20 +214,64 @@ class RegisterPanelContent extends StatelessWidget {
}); });
final VoidCallback onBack; final VoidCallback onBack;
final AuthService authService; final AuthService authService;
void register() {} @override
State<RegisterPanelContent> createState() => _RegisterPanelContentState();
}
class _RegisterPanelContentState extends State<RegisterPanelContent> {
final _usernameController = TextEditingController();
final _displayNameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _inviteController = TextEditingController();
bool _registering = false;
@override
void dispose() {
_usernameController.dispose();
_displayNameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_inviteController.dispose();
super.dispose();
}
Future<void> _register() async {
setState(() => _registering = true);
try {
await widget.authService.register(
username: _usernameController.text.trim(),
email: _emailController.text.trim(),
fullName: _displayNameController.text.trim(),
password: _passwordController.text,
inviteCode: _inviteController.text.trim(),
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Registration successful. Please log in.')),
);
widget.onBack();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Registration failed: $e')),
);
} finally {
if (mounted) setState(() => _registering = false);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [ children: [
Row( Row(
children: [ children: [
IconButton( IconButton(
icon: Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: onBack, onPressed: widget.onBack,
tooltip: 'Back to login', tooltip: 'Back to login',
), ),
Expanded( Expanded(
@@ -237,47 +282,64 @@ class RegisterPanelContent extends StatelessWidget {
), ),
), ),
), ),
// Spacer to balance the row visually const SizedBox(width: 48),
SizedBox(width: 48), // matches IconButton size
], ],
), ),
SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
decoration: InputDecoration( controller: _usernameController,
decoration: const InputDecoration(
border: OutlineInputBorder(), border: OutlineInputBorder(),
labelText: "Username", labelText: "Username",
), ),
), ),
const SizedBox(height: 8),
TextField( TextField(
decoration: InputDecoration( controller: _displayNameController,
decoration: const InputDecoration(
border: OutlineInputBorder(), border: OutlineInputBorder(),
labelText: "Display Name", labelText: "Display Name",
), ),
), ),
const SizedBox(height: 8),
TextField( TextField(
decoration: InputDecoration( controller: _emailController,
decoration: const InputDecoration(
border: OutlineInputBorder(), border: OutlineInputBorder(),
labelText: "Email", labelText: "Email",
), ),
), ),
const SizedBox(height: 8),
TextField( TextField(
controller: _passwordController,
obscureText: true, obscureText: true,
decoration: InputDecoration( decoration: const InputDecoration(
border: OutlineInputBorder(), border: OutlineInputBorder(),
labelText: "Password", labelText: "Password",
), ),
), ),
const SizedBox(height: 8),
TextField( TextField(
decoration: InputDecoration( controller: _inviteController,
decoration: const InputDecoration(
border: OutlineInputBorder(), border: OutlineInputBorder(),
labelText: "Invite Code", labelText: "Invite Code",
), ),
), ),
const SizedBox(height: 12),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
spacing: 10,
children: [ children: [
FilledButton(onPressed: register, child: Text("Register")), FilledButton(
onPressed: _registering ? null : _register,
child: _registering
? const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text("Register"),
),
], ],
), ),
], ],

View File

@@ -1,10 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/components/calculator/calculator.dart'; import 'package:mileograph_flutter/components/calculator/calculator.dart';
import 'package:provider/provider.dart';
import 'package:mileograph_flutter/services/dataService.dart';
class CalculatorPage extends StatelessWidget { class CalculatorPage extends StatelessWidget {
const CalculatorPage({super.key});
@override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return RouteCalculator(); return const RouteCalculator();
} }
} }

View File

@@ -1,98 +1,318 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/dashboard/leaderboardPanel.dart'; import 'package:mileograph_flutter/components/dashboard/leaderboardPanel.dart';
import 'package:mileograph_flutter/components/dashboard/topTractionPanel.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/dataService.dart'; import 'package:mileograph_flutter/services/dataService.dart';
import 'package:mileograph_flutter/components/dashboard/topTractionPanel.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class Dashboard extends StatelessWidget { class Dashboard extends StatelessWidget {
const Dashboard({super.key}); const Dashboard({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final auth = context.watch<AuthService>(); final auth = context.watch<AuthService>();
return DashboardHeader(auth: auth, data: data); final stats = data.homepageStats;
}
return RefreshIndicator(
onRefresh: () async {
await data.fetchHomepageStats();
await Future.wait([
data.fetchOnThisDay(),
data.fetchTripDetails(),
data.fetchHadTraction(),
]);
},
child: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth > 1100;
final metricChips = _buildMetricChips(
context,
totalMileage: stats?.totalMileage ?? 0,
currentYearMileage: data.getMileageForCurrentYear(),
trips: data.trips.length,
);
return ListView(
padding: const EdgeInsets.all(16),
children: [
_buildHeader(context, auth, stats, data.isHomepageLoading),
const SizedBox(height: 12),
Wrap(spacing: 12, runSpacing: 12, children: metricChips),
const SizedBox(height: 16),
isWide
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _buildMainColumn(context, data)),
const SizedBox(width: 16),
SizedBox(
width: 360,
child: _buildSidebar(context, data),
),
],
)
: Column(
children: [
_buildMainColumn(context, data),
const SizedBox(height: 16),
_buildSidebar(context, data),
],
),
],
);
},
),
);
} }
class DashboardHeader extends StatelessWidget { Widget _buildHeader(
const DashboardHeader({super.key, required this.auth, required this.data}); BuildContext context,
AuthService auth,
final AuthService auth; HomepageStats? stats,
final DataService data; bool loading,
) {
@override final greetingName =
Widget build(BuildContext context) { stats?.user?.full_name ?? auth.fullName ?? auth.username ?? 'there';
return Column( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Column(
mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Text('Dashboard', style: Theme.of(context).textTheme.labelMedium),
children: [ const SizedBox(height: 2),
Card( Text(
child: Padding( 'Welcome back, $greetingName',
padding: const EdgeInsets.all(8.0), style: Theme.of(context).textTheme.headlineSmall,
child: Column(
children: [
Text.rich(
TextSpan(
children: [
TextSpan(text: "Total Mileage: "),
TextSpan(
text:
data.homepageStats?.totalMileage
.toString() ??
"0",
), ),
], ],
), ),
), if (loading)
Text.rich( const Padding(
TextSpan( padding: EdgeInsets.only(right: 8.0),
children: [ child: SizedBox(
TextSpan(text: DateTime.now().year.toString()), height: 24,
TextSpan(text: " Mileage: "), width: 24,
TextSpan( child: CircularProgressIndicator(strokeWidth: 2),
text: data
.getMileageForCurrentYear()
.toString(),
),
],
),
),
],
),
),
),
Card(
child: Padding(
padding: EdgeInsets.all(8),
child: Column(
children: [
Text("Total Winners: 123"),
Text("Average mileage: 45.6"),
],
),
),
),
],
),
],
),
Expanded(
child: ListView(
scrollDirection: Axis.vertical,
children: [
TopTractionPanel(),
LeaderboardPanel(),
SizedBox(height: 80),
],
), ),
), ),
], ],
); );
} }
List<Widget> _buildMetricChips(
BuildContext context, {
required double totalMileage,
required double currentYearMileage,
required int trips,
}) {
final textTheme = Theme.of(context).textTheme;
Widget metricCard(String label, String value) {
return Card(
elevation: 1,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label.toUpperCase(),
style: textTheme.labelSmall?.copyWith(
letterSpacing: 0.7,
color: textTheme.bodySmall?.color?.withOpacity(0.7),
),
),
const SizedBox(height: 4),
Text(
value,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
),
);
}
return [
metricCard('Total mileage', '${totalMileage.toStringAsFixed(1)} mi'),
metricCard('This year', '${currentYearMileage.toStringAsFixed(1)} mi'),
metricCard('Trips logged', trips.toString()),
];
}
Widget _buildMainColumn(BuildContext context, DataService data) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildCard(
context,
title: 'On this day',
trailing: data.isOnThisDayLoading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
child: _buildLegList(
context,
data.onThisDay,
emptyMessage: 'No historical moves for today yet.',
),
),
const SizedBox(height: 12),
_buildQuickCalcCard(context),
const SizedBox(height: 12),
_buildTripsCard(context, data),
],
);
}
Widget _buildSidebar(BuildContext context, DataService data) {
return Column(
children: [
TopTractionPanel(),
const SizedBox(height: 12),
LeaderboardPanel(),
],
);
}
Widget _buildCard(
BuildContext context, {
required String title,
required Widget child,
Widget? trailing,
Widget? action,
}) {
return Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (action != null) action,
if (trailing != null) ...[
const SizedBox(width: 8),
trailing,
],
],
),
],
),
const SizedBox(height: 12),
child,
],
),
),
);
}
Widget _buildLegList(
BuildContext context,
List<Leg> legs, {
required String emptyMessage,
}) {
if (legs.isEmpty) {
return Text(emptyMessage, style: Theme.of(context).textTheme.bodyMedium);
}
return Column(
children: legs.take(5).map((leg) {
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: const Icon(Icons.train),
),
title: Text('${leg.start}${leg.end}'),
subtitle: Text(_formatDate(leg.beginTime)),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('${leg.mileage.toStringAsFixed(1)} mi'),
if (leg.headcode.isNotEmpty)
Text(
leg.headcode,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).hintColor,
),
),
],
),
);
}).toList(),
);
}
Widget _buildQuickCalcCard(BuildContext context) {
return _buildCard(
context,
title: 'Quick mileage calculator',
action: TextButton.icon(
onPressed: () => context.push('/calculator'),
icon: const Icon(Icons.open_in_new),
label: const Text('Open calculator'),
),
child: Text(
'Jump into the route calculator to quickly total a journey before saving it.',
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
Widget _buildTripsCard(BuildContext context, DataService data) {
final trips_unsorted = data.trips;
List trips = [];
if (trips_unsorted.isNotEmpty) {
trips = [...trips_unsorted]..sort((a, b) => b.tripId.compareTo(a.tripId));
}
return _buildCard(
context,
title: 'Trips',
action: TextButton(
onPressed: () => context.push('/trips'),
child: const Text('View all'),
),
child: trips.isEmpty
? Text(
'No trips logged yet. Add one from the Trips page.',
style: Theme.of(context).textTheme.bodyMedium,
)
: Column(
children: trips.take(5).map((trip) {
return ListTile(
contentPadding: EdgeInsets.zero,
title: Text(trip.tripName),
subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'),
trailing: const Icon(Icons.chevron_right),
);
}).toList(),
),
);
}
String _formatDate(DateTime? dt) {
if (dt == null) return '';
return '${dt.year.toString().padLeft(4, '0')}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
}
} }

View File

@@ -1,31 +1,300 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/dataService.dart';
import 'package:provider/provider.dart';
class LegsPage extends StatefulWidget {
const LegsPage({super.key});
class LegsPage extends StatelessWidget { @override
State<LegsPage> createState() => _LegsPageState();
}
class _LegsPageState extends State<LegsPage> {
int _sortDirection = 0;
DateTime? _startDate;
DateTime? _endDate;
bool _initialised = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialised) {
_initialised = true;
_refreshLegs();
}
}
Future<void> _refreshLegs() async {
final data = context.read<DataService>();
await data.fetchLegs(
sortDirection: _sortDirection,
dateRangeStart: _formatDate(_startDate),
dateRangeEnd: _formatDate(_endDate),
);
}
Future<void> _loadMore() async {
final data = context.read<DataService>();
await data.fetchLegs(
sortDirection: _sortDirection,
dateRangeStart: _formatDate(_startDate),
dateRangeEnd: _formatDate(_endDate),
offset: data.legs.length,
append: true,
);
}
double _pageMileage(List legs) {
return legs.fold<double>(
0,
(prev, leg) => prev + (leg.mileage as double? ?? 0),
);
}
Future<void> _pickDate({required bool start}) async {
final initial = start
? _startDate ?? DateTime.now()
: _endDate ?? _startDate ?? DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(1970),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) {
setState(() {
if (start) {
_startDate = picked;
if (_endDate != null && _endDate!.isBefore(picked)) {
_endDate = picked;
}
} else {
_endDate = picked;
}
});
await _refreshLegs();
}
}
void _clearFilters() {
setState(() {
_startDate = null;
_endDate = null;
_sortDirection = 0;
});
_refreshLegs();
}
@override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
return ListView.builder( final legs = data.legs;
itemCount: data.legs.length, final pageMileage = _pageMileage(legs);
itemBuilder: (context, index) {
final leg = data.legs[index]; return RefreshIndicator(
return Card( onRefresh: _refreshLegs,
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: ListView(
child: Padding( padding: const EdgeInsets.all(16),
padding: EdgeInsets.all(16), physics: const AlwaysScrollableScrollPhysics(),
child: Column( children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('${leg.start}${leg.end}', style: TextStyle(fontSize: 16)), Text('Logbook',
Text('Mileage: ${leg.mileage.toStringAsFixed(2)} km'), style: Theme.of(context).textTheme.labelMedium),
Text('Headcode: ${leg.headcode}'), const SizedBox(height: 2),
Text('Begin: ${leg.beginTime}'), Text('Entries',
style: Theme.of(context).textTheme.headlineSmall),
],
),
Card(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Page mileage',
style: Theme.of(context).textTheme.labelSmall),
Text('${pageMileage.toStringAsFixed(1)} mi',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w700)),
], ],
), ),
), ),
); ),
],
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Filters',
style: Theme.of(context).textTheme.titleMedium),
TextButton.icon(
onPressed: _clearFilters,
icon: const Icon(Icons.refresh),
label: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SegmentedButton<int>(
segments: const [
ButtonSegment(
value: 0,
icon: Icon(Icons.south),
label: Text('Newest first'),
),
ButtonSegment(
value: 1,
icon: Icon(Icons.north),
label: Text('Oldest first'),
),
],
selected: {_sortDirection},
onSelectionChanged: (selection) {
setState(() => _sortDirection = selection.first);
_refreshLegs();
}, },
),
FilledButton.tonalIcon(
onPressed: () => _pickDate(start: true),
icon: const Icon(Icons.calendar_month),
label: Text(
_startDate == null
? 'Start date'
: _formatDate(_startDate!)!,
),
),
FilledButton.tonalIcon(
onPressed: () => _pickDate(start: false),
icon: const Icon(Icons.event),
label: Text(
_endDate == null
? 'End date'
: _formatDate(_endDate!)!,
),
),
],
),
],
),
),
),
const SizedBox(height: 12),
if (data.isLegsLoading && legs.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: CircularProgressIndicator(),
),
)
else if (legs.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'No entries found',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
const Text('Adjust the filters or add a new leg.'),
],
),
),
)
else
Column(
children: [
...legs.map((leg) => Card(
child: ListTile(
leading: const Icon(Icons.train),
title: Text('${leg.start}${leg.end}'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_formatDateTime(leg.beginTime)),
if (leg.headcode.isNotEmpty)
Text('Headcode: ${leg.headcode}'),
if (leg.route.isNotEmpty)
Text(
leg.route,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('${leg.mileage.toStringAsFixed(1)} mi'),
Text(
leg.network,
style: Theme.of(context).textTheme.labelSmall,
),
],
),
isThreeLine: true,
),
)),
const SizedBox(height: 8),
if (data.legsHasMore || data.isLegsLoading)
Align(
alignment: Alignment.center,
child: OutlinedButton.icon(
onPressed:
data.isLegsLoading ? null : () => _loadMore(),
icon: data.isLegsLoading
? const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.expand_more),
label: Text(
data.isLegsLoading ? 'Loading...' : 'Load more',
),
),
),
],
),
],
),
); );
} }
String? _formatDate(DateTime? date) {
if (date == null) return null;
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
String _formatDateTime(DateTime date) {
final dateStr = _formatDate(date) ?? '';
final timeStr =
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
return '$dateStr · $timeStr';
}
} }

View File

@@ -1,10 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:mileograph_flutter/services/dataService.dart';
class NewEntryPage extends StatelessWidget {
Widget build(BuildContext context) {
final data = context.watch<DataService>();
return Center(child: Text("New Entry Page"));
}
}

View File

@@ -0,0 +1,637 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:mileograph_flutter/components/calculator/calculator.dart';
import 'package:mileograph_flutter/components/pages/traction.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/apiService.dart';
import 'package:mileograph_flutter/services/dataService.dart';
import 'package:provider/provider.dart';
class NewEntryPage extends StatefulWidget {
const NewEntryPage({super.key});
@override
State<NewEntryPage> createState() => _NewEntryPageState();
}
class _NewEntryPageState extends State<NewEntryPage> {
final _formKey = GlobalKey<FormState>();
DateTime _selectedDate = DateTime.now();
TimeOfDay _selectedTime = TimeOfDay.now();
final _startController = TextEditingController();
final _endController = TextEditingController();
final _headcodeController = TextEditingController();
final _notesController = TextEditingController();
final _mileageController = TextEditingController();
final _networkController = TextEditingController();
bool _submitting = false;
bool _useManualMileage = false;
RouteResult? _routeResult;
final List<_TractionItem> _tractionItems = [_TractionItem.marker()];
int? _selectedTripId;
@override
void initState() {
super.initState();
Future.microtask(() {
if (!mounted) return;
final data = context.read<DataService>();
data.fetchClassList();
data.fetchTrips();
});
}
@override
void dispose() {
_startController.dispose();
_endController.dispose();
_headcodeController.dispose();
_notesController.dispose();
_mileageController.dispose();
_networkController.dispose();
super.dispose();
}
Widget _buildTripSelector(BuildContext context) {
final trips = context.watch<DataService>().tripList;
final sorted = [...trips]..sort((a, b) => b.tripId.compareTo(a.tripId));
return Row(
children: [
Expanded(
child: DropdownButtonFormField<int>(
value: _selectedTripId,
decoration: const InputDecoration(
labelText: 'Trip',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem(value: null, child: Text('No trip')),
...sorted.map(
(t) =>
DropdownMenuItem(value: t.tripId, child: Text(t.tripName)),
),
],
onChanged: (val) => setState(() => _selectedTripId = val),
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () => _showAddTripDialog(context),
icon: const Icon(Icons.add),
label: const Text('New Trip'),
),
],
);
}
Future<void> _showAddTripDialog(BuildContext context) async {
final controller = TextEditingController();
final result = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('New Trip'),
content: TextField(
controller: controller,
decoration: const InputDecoration(labelText: 'Trip name'),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
child: const Text('Add'),
),
],
),
);
if (!mounted) return;
if (result != null && result.isNotEmpty) {
final api = context.read<ApiService>();
final data = context.read<DataService>();
final messenger = ScaffoldMessenger.of(context);
try {
await api.put('/trips/new', {"trip_name": result});
await data.fetchTrips();
if (!mounted) return;
final trips = data.tripList;
final match = trips.firstWhere(
(t) => t.tripName == result,
orElse: () => trips.isNotEmpty
? trips.first
: TripSummary(tripId: 0, tripName: result, tripMileage: 0),
);
setState(() => _selectedTripId = match.tripId);
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Failed to add trip: $e')),
);
}
}
}
Future<void> _openCalculator() async {
final result = await Navigator.of(context).push<RouteResult>(
MaterialPageRoute(
builder: (_) => _CalculatorPickerPage(
onResult: (res) => Navigator.of(context).pop(res),
),
),
);
if (result != null) {
setState(() {
_routeResult = result;
_mileageController.text = result.distance.toStringAsFixed(2);
_useManualMileage = false;
});
}
}
Future<void> _openTractionPicker() async {
final selectedKeys = _tractionItems
.where((e) => !e.isMarker && e.loco != null)
.map((e) => '${e.loco!.locoClass}-${e.loco!.number}')
.toSet();
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => TractionPage(
selectionMode: true,
selectedKeys: selectedKeys,
onSelect: (loco) {
final markerIndex = _tractionItems.indexWhere(
(element) => element.isMarker,
);
final key = '${loco.locoClass}-${loco.number}';
setState(() {
final existingIndex = _tractionItems.indexWhere(
(e) =>
!e.isMarker &&
e.loco != null &&
'${e.loco!.locoClass}-${e.loco!.number}' == key,
);
if (existingIndex != -1) {
_tractionItems.removeAt(existingIndex);
} else {
_tractionItems.insert(
markerIndex,
_TractionItem(loco: loco, powering: true),
);
}
});
},
),
),
);
}
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(1970),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) setState(() => _selectedDate = picked);
}
Future<void> _pickTime() async {
final picked = await showTimePicker(
context: context,
initialTime: _selectedTime,
);
if (picked != null) setState(() => _selectedTime = picked);
}
DateTime get _legDateTime => DateTime(
_selectedDate.year,
_selectedDate.month,
_selectedDate.day,
_selectedTime.hour,
_selectedTime.minute,
);
List<Map<String, dynamic>> _buildTractionPayload() {
final markerIndex = _tractionItems.indexWhere(
(element) => element.isMarker,
);
final payload = <Map<String, dynamic>>[];
for (var i = 0; i < _tractionItems.length; i++) {
final item = _tractionItems[i];
if (item.isMarker || item.loco == null) continue;
int allocPos;
if (i > markerIndex) {
allocPos = -(i - markerIndex);
} else {
allocPos = (markerIndex - 1) - i;
}
payload.add({
"loco_type": item.loco!.type,
"loco_number": item.loco!.number,
"alloc_pos": allocPos,
"alloc_powering": item.powering ? 1 : 0,
});
}
return payload;
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
if (!_useManualMileage && _routeResult == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please calculate mileage first')),
);
return;
}
setState(() => _submitting = true);
final api = context.read<ApiService>();
final routeStations = _routeResult?.calculatedRoute ?? [];
final startVal = _useManualMileage
? _startController.text.trim()
: (routeStations.isNotEmpty ? routeStations.first : '');
final endVal = _useManualMileage
? _endController.text.trim()
: (routeStations.isNotEmpty ? routeStations.last : '');
final mileageVal = _useManualMileage
? double.tryParse(_mileageController.text.trim()) ?? 0
: (_routeResult?.distance ?? 0);
final tractionPayload = _buildTractionPayload();
if (_useManualMileage) {
final body = {
"leg_trip": _selectedTripId ?? null,
"leg_start": startVal,
"leg_end": endVal,
"leg_begin_time": _legDateTime.toIso8601String(),
"leg_network": _networkController.text.trim(),
"leg_distance": mileageVal,
"isKilometers": false,
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
"locos": tractionPayload,
};
await api.post('/add/manual', body);
} else {
final body = {
"leg_trip": _selectedTripId ?? null,
"leg_begin_time": _legDateTime.toIso8601String(),
"leg_route": routeStations,
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
"leg_network": _networkController.text.trim(),
"locos": tractionPayload,
};
await api.post('/add', body);
}
if (mounted) {
context.read<DataService>().refreshLegs();
}
try {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Entry submitted')));
_formKey.currentState!.reset();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to submit: $e')));
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
final isMobile = MediaQuery.of(context).size.width < 700;
return Scaffold(
appBar: null,
body: Form(
key: _formKey,
child: LayoutBuilder(
builder: (context, constraints) {
final twoCol = !isMobile && constraints.maxWidth > 1000;
final detailPanel = _section('Details', [
_buildTripSelector(context),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _pickDate,
icon: const Icon(Icons.calendar_today),
label: Text(DateFormat.yMMMd().format(_selectedDate)),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _pickTime,
icon: const Icon(Icons.schedule),
label: Text(_selectedTime.format(context)),
),
),
],
),
if (_useManualMileage)
Row(
children: [
Expanded(
child: TextFormField(
controller: _startController,
decoration: const InputDecoration(
labelText: 'From',
border: OutlineInputBorder(),
),
validator: (v) => !_useManualMileage
? null
: (v == null || v.isEmpty ? 'Required' : null),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _endController,
decoration: const InputDecoration(
labelText: 'To',
border: OutlineInputBorder(),
),
validator: (v) => !_useManualMileage
? null
: (v == null || v.isEmpty ? 'Required' : null),
),
),
],
),
TextFormField(
controller: _headcodeController,
decoration: const InputDecoration(
labelText: 'Headcode',
border: OutlineInputBorder(),
),
),
TextFormField(
controller: _networkController,
decoration: const InputDecoration(
labelText: 'Network',
border: OutlineInputBorder(),
),
),
TextFormField(
controller: _notesController,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'Notes',
border: OutlineInputBorder(),
),
),
]);
final tractionPanel = _section('Traction', [
Align(
alignment: Alignment.centerLeft,
child: ElevatedButton.icon(
onPressed: _openTractionPicker,
icon: const Icon(Icons.search),
label: const Text('Search traction'),
),
),
_buildTractionList(),
]);
final mileagePanel = _section('Mileage', [
SwitchListTile(
title: const Text('Use manual mileage'),
subtitle: const Text('Turn on to enter mileage manually'),
value: _useManualMileage,
onChanged: (val) {
setState(() {
_useManualMileage = val;
});
},
),
if (_useManualMileage)
TextFormField(
controller: _mileageController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
labelText: 'Mileage (mi)',
border: OutlineInputBorder(),
),
)
else if (_routeResult != null)
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Calculated mileage'),
subtitle: Text(
'${_routeResult!.distance.toStringAsFixed(2)} mi',
),
),
if (!_useManualMileage)
Align(
alignment: Alignment.centerLeft,
child: ElevatedButton.icon(
onPressed: _openCalculator,
icon: const Icon(Icons.calculate),
label: const Text('Open mileage calculator'),
),
),
]);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
detailPanel,
const SizedBox(height: 16),
twoCol
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: tractionPanel),
const SizedBox(width: 16),
Expanded(child: mileagePanel),
],
)
: Column(
children: [
tractionPanel,
const SizedBox(height: 16),
mileagePanel,
],
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: _submitting ? null : _submit,
icon: _submitting
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
label: Text(_submitting ? 'Submitting...' : 'Submit entry'),
),
],
),
);
},
),
),
);
}
Widget _buildTractionList() {
if (_tractionItems.length == 1) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: Text('No traction selected yet.'),
);
}
return ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
onReorder: (oldIndex, newIndex) {
if (newIndex > oldIndex) newIndex -= 1;
setState(() {
final item = _tractionItems.removeAt(oldIndex);
_tractionItems.insert(newIndex, item);
});
},
itemCount: _tractionItems.length,
itemBuilder: (context, index) {
final item = _tractionItems[index];
if (item.isMarker) {
return Card(
key: const ValueKey('marker'),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const ListTile(
leading: Icon(Icons.train),
title: Text('Rolling stock marker'),
subtitle: Text(
'Place locomotives above/below. Positions set relative to this.',
),
),
);
}
final loco = item.loco!;
final markerIndex = _tractionItems.indexWhere(
(element) => element.isMarker,
);
final pos = index > markerIndex
? -(index - markerIndex)
: (markerIndex - 1) - index;
return Card(
key: ValueKey('${loco.locoClass}-${loco.number}-$index'),
child: ListTile(
leading: ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_indicator),
),
title: Text('${loco.locoClass} ${loco.number}'),
subtitle: Text('${loco.name ?? ''} · Position $pos'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Powering'),
Switch(
value: item.powering,
onChanged: (v) {
setState(() {
_tractionItems[index] = item.copyWith(powering: v);
});
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
setState(() {
_tractionItems.removeAt(index);
});
},
),
],
),
),
);
},
);
}
Widget _section(String title, List<Widget> children) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
...children.map(
(w) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: w,
),
),
],
),
),
);
}
}
class _CalculatorPickerPage extends StatelessWidget {
const _CalculatorPickerPage({required this.onResult});
final ValueChanged<RouteResult> onResult;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
title: const Text('Mileage calculator'),
),
body: RouteCalculator(onApplyRoute: onResult),
);
}
}
class _TractionItem {
final LocoSummary? loco;
final bool powering;
final bool isMarker;
_TractionItem({
required this.loco,
this.powering = true,
this.isMarker = false,
});
factory _TractionItem.marker() =>
_TractionItem(loco: null, powering: false, isMarker: true);
_TractionItem copyWith({LocoSummary? loco, bool? powering, bool? isMarker}) {
return _TractionItem(
loco: loco ?? this.loco,
powering: powering ?? this.powering,
isMarker: isMarker ?? this.isMarker,
);
}
}

View File

@@ -1,41 +1,753 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:provider/provider.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/dataService.dart';
import 'package:provider/provider.dart';
class TractionPage extends StatelessWidget { class TractionPage extends StatefulWidget {
const TractionPage({
super.key,
this.selectionMode = false,
this.onSelect,
this.selectedKeys = const {},
});
final bool selectionMode;
final ValueChanged<LocoSummary>? onSelect;
final Set<String> selectedKeys;
@override
State<TractionPage> createState() => _TractionPageState();
}
class _TractionPageState extends State<TractionPage> {
final _classController = TextEditingController();
final _classFocusNode = FocusNode();
final _numberController = TextEditingController();
bool _mileageFirst = true;
bool _initialised = false;
bool _showAdvancedFilters = false;
String? _selectedClass;
late Set<String> _selectedKeys;
final _nameController = TextEditingController();
final _operatorController = TextEditingController();
final _statusController = TextEditingController();
final _evnController = TextEditingController();
final _ownerController = TextEditingController();
final _locationController = TextEditingController();
final _liveryController = TextEditingController();
final _domainController = TextEditingController();
final _typeController = TextEditingController();
int offset = 0;
@override
void initState() {
super.initState();
_classController.addListener(_onClassTextChanged);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialised) {
_initialised = true;
_selectedKeys = {...widget.selectedKeys};
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<DataService>().fetchClassList();
_refreshTraction();
});
}
}
@override
void dispose() {
_classController.removeListener(_onClassTextChanged);
_classController.dispose();
_classFocusNode.dispose();
_numberController.dispose();
_nameController.dispose();
_operatorController.dispose();
_statusController.dispose();
_evnController.dispose();
_ownerController.dispose();
_locationController.dispose();
_liveryController.dispose();
_domainController.dispose();
_typeController.dispose();
super.dispose();
}
bool get _hasFilters {
return [
_selectedClass,
_classController.text,
_numberController.text,
_nameController.text,
_operatorController.text,
_statusController.text,
_evnController.text,
_ownerController.text,
_locationController.text,
_liveryController.text,
_domainController.text,
_typeController.text,
].any((value) => (value ?? '').toString().trim().isNotEmpty);
}
Future<void> _refreshTraction({bool append = false}) async {
final data = context.read<DataService>();
final filters = {
"name": _nameController.text.trim(),
"operator": _operatorController.text.trim(),
"status": _statusController.text.trim(),
"evn": _evnController.text.trim(),
"owner": _ownerController.text.trim(),
"location": _locationController.text.trim(),
"livery": _liveryController.text.trim(),
"domain": _domainController.text.trim(),
"type": _typeController.text.trim(),
}..removeWhere((key, value) => value.isEmpty);
final hadOnly = !_hasFilters;
await data.fetchTraction(
hadOnly: hadOnly,
locoClass: _selectedClass ?? _classController.text.trim(),
locoNumber: _numberController.text.trim(),
offset: append ? data.traction.length : 0,
append: append,
filters: filters,
mileageFirst: _mileageFirst,
);
}
void _clearFilters() {
for (final controller in [
_classController,
_numberController,
_nameController,
_operatorController,
_statusController,
_evnController,
_ownerController,
_locationController,
_liveryController,
_domainController,
_typeController,
]) {
controller.clear();
}
setState(() {
_selectedClass = null;
_mileageFirst = true;
});
_refreshTraction();
}
void _onClassTextChanged() {
if (_selectedClass != null &&
_classController.text.trim() != (_selectedClass ?? '')) {
setState(() {
_selectedClass = null;
});
}
}
@override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
return ListView.builder( final traction = data.traction;
itemCount: data.traction.length, final classOptions = data.locoClasses;
itemBuilder: (context, index) { final isMobile = MediaQuery.of(context).size.width < 700;
final loco = data.traction[index];
return Card( final listView = RefreshIndicator(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8), onRefresh: _refreshTraction,
child: Padding( child: ListView(
padding: EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( physics: const AlwaysScrollableScrollPhysics(),
mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text('${loco.locoClass} ${loco.number}')], children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Fleet', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 2),
Text(
'Traction',
style: Theme.of(context).textTheme.headlineSmall,
), ),
],
),
IconButton(
tooltip: 'Refresh',
onPressed: _refreshTraction,
icon: const Icon(Icons.refresh),
),
],
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
'${loco.name}', 'Filters',
style: TextStyle(fontStyle: FontStyle.italic), style: Theme.of(context).textTheme.titleMedium,
), ),
Text('${loco.mileage} mi'), TextButton(
], onPressed: _clearFilters,
child: const Text('Clear'),
), ),
], ],
), ),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
SizedBox(
width: isMobile ? double.infinity : 240,
child: RawAutocomplete<String>(
textEditingController: _classController,
focusNode: _classFocusNode,
optionsBuilder: (TextEditingValue textEditingValue) {
final query = textEditingValue.text.toLowerCase();
if (query.isEmpty) {
return classOptions;
}
return classOptions.where(
(c) => c.toLowerCase().contains(query),
);
},
fieldViewBuilder:
(
context,
controller,
focusNode,
onFieldSubmitted,
) {
return TextField(
controller: controller,
focusNode: focusNode,
decoration: const InputDecoration(
labelText: 'Class',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
);
},
optionsViewBuilder: (context, onSelected, options) {
final optionList = options.toList();
if (optionList.isEmpty) {
return const SizedBox.shrink();
}
final maxWidth = isMobile
? MediaQuery.of(context).size.width - 64
: 240.0;
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: maxWidth,
maxHeight: 240,
),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: optionList.length,
itemBuilder: (context, index) {
final option = optionList[index];
return ListTile(
title: Text(option),
onTap: () => onSelected(option),
);
},
),
),
), ),
); );
}, },
onSelected: (String selection) {
setState(() {
_selectedClass = selection;
_classController.text = selection;
});
_refreshTraction();
},
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _numberController,
decoration: const InputDecoration(
labelText: 'Number',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
FilterChip(
label: Text(
_mileageFirst ? 'Mileage first' : 'Number order',
),
selected: _mileageFirst,
onSelected: (v) {
setState(() => _mileageFirst = v);
_refreshTraction();
},
),
TextButton.icon(
onPressed: () => setState(
() => _showAdvancedFilters = !_showAdvancedFilters,
),
icon: Icon(
_showAdvancedFilters
? Icons.expand_less
: Icons.expand_more,
),
label: Text(
_showAdvancedFilters
? 'Hide filters'
: 'More filters',
),
),
ElevatedButton.icon(
onPressed: _refreshTraction,
icon: const Icon(Icons.search),
label: const Text('Search'),
),
],
),
AnimatedCrossFade(
crossFadeState: _showAdvancedFilters
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
firstChild: Padding(
padding: const EdgeInsets.only(top: 12.0),
child: Wrap(
spacing: 12,
runSpacing: 12,
children: [
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _operatorController,
decoration: const InputDecoration(
labelText: 'Operator',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _statusController,
decoration: const InputDecoration(
labelText: 'Status',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _evnController,
decoration: const InputDecoration(
labelText: 'EVN',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _ownerController,
decoration: const InputDecoration(
labelText: 'Owner',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _locationController,
decoration: const InputDecoration(
labelText: 'Location',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _liveryController,
decoration: const InputDecoration(
labelText: 'Livery',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _domainController,
decoration: const InputDecoration(
labelText: 'Domain',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
SizedBox(
width: isMobile ? double.infinity : 220,
child: TextField(
controller: _typeController,
decoration: const InputDecoration(
labelText: 'Type',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
),
],
),
),
secondChild: const SizedBox.shrink(),
),
],
),
),
),
const SizedBox(height: 12),
if (data.isTractionLoading && traction.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: CircularProgressIndicator(),
),
)
else if (traction.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'No traction found',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
const Text('Try relaxing the filters or sync again.'),
],
),
),
)
else
Column(
children: [
...traction.map((loco) => _buildTractionCard(context, loco)),
if (data.tractionHasMore || data.isTractionLoading)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: OutlinedButton.icon(
onPressed: data.isTractionLoading
? null
: () => _refreshTraction(append: true),
icon: data.isTractionLoading
? const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.expand_more),
label: Text(
data.isTractionLoading ? 'Loading...' : 'Load more',
),
),
),
],
),
],
),
);
if (widget.selectionMode) {
return Scaffold(
appBar: AppBar(
leadingWidth: 140,
leading: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: TextButton.icon(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back),
label: const Text('Back'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
foregroundColor: Theme.of(context).colorScheme.onSurface,
),
),
),
title: null,
),
body: listView,
); );
} }
return listView;
}
Widget _buildTractionCard(BuildContext context, LocoSummary loco) {
final keyVal = '${loco.locoClass}-${loco.number}';
final isSelected = _selectedKeys.contains(keyVal);
final status = loco.status ?? 'Unknown';
final operatorName = loco.operator ?? '';
final domain = loco.domain ?? '';
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${loco.locoClass} ${loco.number}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
if ((loco.name ?? '').isNotEmpty)
Text(
loco.name ?? '',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontStyle: FontStyle.italic,
),
),
],
),
Chip(
label: Text(status),
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
],
),
const SizedBox(height: 8),
Row(
children: [
TextButton.icon(
onPressed: () => _showLocoInfo(loco),
icon: const Icon(Icons.info_outline),
label: const Text('Details'),
),
const Spacer(),
if (widget.selectionMode)
TextButton.icon(
onPressed: () {
if (widget.onSelect != null) {
widget.onSelect!(loco);
}
setState(() {
if (isSelected) {
_selectedKeys.remove(keyVal);
} else {
_selectedKeys.add(keyVal);
}
});
},
icon: Icon(
isSelected
? Icons.remove_circle_outline
: Icons.add_circle_outline,
),
label: Text(isSelected ? 'Remove' : 'Add to entry'),
),
],
),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
_statPill(
context,
label: 'Miles',
value: _formatNumber(loco.mileage),
),
_statPill(
context,
label: 'Trips',
value: (loco.trips ?? loco.journeys ?? 0).toString(),
),
if (operatorName.isNotEmpty)
_statPill(context, label: 'Operator', value: operatorName),
if (domain.isNotEmpty)
_statPill(context, label: 'Domain', value: domain),
],
),
],
),
),
);
}
Widget _statPill(
BuildContext context, {
required String label,
required String value,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('$label: ', style: Theme.of(context).textTheme.labelSmall),
Text(
value,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700),
),
],
),
);
}
Future<void> _showLocoInfo(LocoSummary loco) async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (ctx) {
return DraggableScrollableSheet(
expand: false,
maxChildSize: 0.9,
initialChildSize: 0.65,
builder: (_, controller) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(ctx).pop(),
),
const SizedBox(width: 8),
Text(
'${loco.locoClass} ${loco.number}',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
if ((loco.name ?? '').isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 52.0, bottom: 12),
child: Text(
loco.name ?? '',
style: Theme.of(context).textTheme.bodyMedium,
),
),
const SizedBox(height: 4),
Expanded(
child: ListView(
controller: controller,
children: [
_detailRow('Status', loco.status ?? 'Unknown'),
_detailRow('Operator', loco.operator ?? ''),
_detailRow('Domain', loco.domain ?? ''),
_detailRow('Owner', loco.owner ?? ''),
_detailRow('Livery', loco.livery ?? ''),
_detailRow('Location', loco.location ?? ''),
_detailRow('Mileage', _formatNumber(loco.mileage ?? 0)),
_detailRow(
'Trips',
(loco.trips ?? loco.journeys ?? 0).toString(),
),
_detailRow('EVN', loco.evn ?? ''),
if (loco.notes != null && loco.notes!.isNotEmpty)
_detailRow('Notes', loco.notes!),
],
),
),
],
),
);
},
);
},
);
}
Widget _detailRow(String label, String value) {
if (value.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
children: [
SizedBox(
width: 110,
child: Text(
label,
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
),
),
Expanded(
child: Text(value, style: Theme.of(context).textTheme.bodyMedium),
),
],
),
);
}
String _formatNumber(double? value) {
if (value == null) return '0';
return value.toStringAsFixed(1);
}
} }

View File

@@ -1,28 +1,317 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/dataService.dart';
import 'package:provider/provider.dart';
class TripsPage extends StatelessWidget { class TripsPage extends StatefulWidget {
const TripsPage({super.key});
@override
State<TripsPage> createState() => _TripsPageState();
}
class _TripsPageState extends State<TripsPage> {
bool _initialised = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialised) {
_initialised = true;
_refreshTrips();
}
}
Future<void> _refreshTrips() async {
await context.read<DataService>().fetchTripDetails();
}
@override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
return ListView.builder( final tripDetails = data.tripDetails;
itemCount: data.legs.length, final tripSummaries = data.trips;
itemBuilder: (context, index) { final isMobile = MediaQuery.of(context).size.width < 700;
final leg = data.legs[index]; final showLoading = data.isTripDetailsLoading && tripDetails.isEmpty;
return Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8), return RefreshIndicator(
onRefresh: _refreshTrips,
child: ListView(
padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(),
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Journeys',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 2),
Text(
'Trips',
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
Row(
children: [
IconButton(
onPressed: _refreshTrips,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh trips',
),
],
),
],
),
const SizedBox(height: 12),
if (showLoading)
const Center(
child: Padding( child: Padding(
padding: EdgeInsets.all(16), padding: EdgeInsets.symmetric(vertical: 24.0),
child: CircularProgressIndicator(),
),
)
else if (tripDetails.isEmpty && tripSummaries.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'${leg.start}${leg.end}', 'No trips yet',
style: TextStyle(fontSize: 16), style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
const Text(
'Use the Add entry flow to start grouping legs into trips.',
),
],
),
),
)
else if (tripDetails.isEmpty)
Column(
children: tripSummaries
.map(
(trip) => Card(
child: ListTile(
title: Text(trip.tripName),
subtitle: Text(
'${trip.tripMileage.toStringAsFixed(1)} mi',
),
),
),
)
.toList(),
)
else
Column(
children: tripDetails
.map((trip) => _buildTripCard(context, trip, isMobile))
.toList(),
),
],
),
);
}
Widget _buildTripCard(BuildContext context, TripDetail trip, bool isMobile) {
final legs = trip.legs;
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
trip.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(
'${trip.mileage.toStringAsFixed(1)} mi · ${trip.legCount} legs',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
icon: const Icon(Icons.train),
tooltip: 'Traction',
onPressed: () => _showTripWinners(context, trip),
),
IconButton(
icon: const Icon(Icons.open_in_new),
tooltip: 'Details',
onPressed: () => _showTripDetail(context, trip),
),
],
),
],
),
const SizedBox(height: 8),
if (legs.isNotEmpty)
Column(
children: legs.take(isMobile ? 2 : 3).map((leg) {
return ListTile(
dense: isMobile,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.train),
title: Text('${leg.start}${leg.end}'),
subtitle: Text(
_formatDate(leg.beginTime),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
leg.mileage?.toStringAsFixed(1) ?? '-',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
);
}).toList(),
),
if (legs.length > 3)
Padding(
padding: const EdgeInsets.only(top: 6.0),
child: Text(
'+${legs.length - 3} more legs',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
),
);
}
String _formatDate(DateTime? date) {
if (date == null) return '';
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
void _showTripDetail(BuildContext context, TripDetail trip) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
Text(
trip.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Text('${trip.mileage.toStringAsFixed(1)} mi'),
],
),
const SizedBox(height: 8),
SizedBox(
height: MediaQuery.of(context).size.height * 0.6,
child: ListView.builder(
itemCount: trip.legs.length,
itemBuilder: (context, index) {
final leg = trip.legs[index];
return ListTile(
leading: const Icon(Icons.train),
title: Text('${leg.start}${leg.end}'),
subtitle: Text(_formatDate(leg.beginTime)),
trailing: Text(
leg.mileage?.toStringAsFixed(1) ?? '-',
style: Theme.of(context).textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
),
);
},
),
),
],
),
),
);
},
);
}
void _showTripWinners(BuildContext context, TripDetail trip) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
Text(
trip.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Text('${trip.mileage.toStringAsFixed(1)} mi'),
],
),
const SizedBox(height: 8),
SizedBox(
height: MediaQuery.of(context).size.height * 0.6,
child: ListView.builder(
itemCount: trip.legs.length,
itemBuilder: (context, index) {
final leg = trip.legs[index];
return ListTile(
leading: const Icon(Icons.train),
title: Text('${leg.start}${leg.end}'),
subtitle: Text(_formatDate(leg.beginTime)),
trailing: Text(
leg.mileage?.toStringAsFixed(1) ?? '-',
style: Theme.of(context).textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
),
);
},
),
), ),
Text('Mileage: ${leg.mileage.toStringAsFixed(2)} km'),
Text('Headcode: ${leg.headcode}'),
Text('Begin: ${leg.beginTime}'),
], ],
), ),
), ),

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:mileograph_flutter/components/pages/calculator.dart'; import 'package:mileograph_flutter/components/pages/calculator.dart';
import 'package:mileograph_flutter/components/pages/newEntry.dart'; import 'package:mileograph_flutter/components/pages/new_entry.dart';
import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/components/pages/traction.dart';
import 'package:mileograph_flutter/components/pages/trips.dart'; import 'package:mileograph_flutter/components/pages/trips.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -13,7 +13,6 @@ import 'package:mileograph_flutter/services/dataService.dart';
import 'components/login/login.dart'; import 'components/login/login.dart';
import 'components/pages/dashboard.dart'; import 'components/pages/dashboard.dart';
import 'components/dashboard/topTractionPanel.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -25,7 +24,7 @@ void main() {
providers: [ providers: [
Provider<ApiService>( Provider<ApiService>(
create: (_) { create: (_) {
api = ApiService(baseUrl: 'https://dev.mileograph.co.uk/api/v1'); api = ApiService(baseUrl: 'https://mileograph.co.uk/api/v1');
return api; return api;
}, },
), ),
@@ -38,6 +37,7 @@ void main() {
ProxyProvider<AuthService, void>( ProxyProvider<AuthService, void>(
update: (_, auth, __) { update: (_, auth, __) {
api.setTokenProvider(() => auth.token); api.setTokenProvider(() => auth.token);
api.setUnauthorizedHandler(() => auth.handleTokenExpired());
}, },
), ),
ChangeNotifierProxyProvider<ApiService, DataService>( ChangeNotifierProxyProvider<ApiService, DataService>(
@@ -172,8 +172,9 @@ class _MyHomePageState extends State<MyHomePage> {
} }
void _onItemTapped(int index, int currentIndex) { void _onItemTapped(int index, int currentIndex) {
if (index < 0 || index >= contentPages.length || index == currentIndex) if (index < 0 || index >= contentPages.length || index == currentIndex) {
return; return;
}
context.push(contentPages[index]); context.push(contentPages[index]);
_getIndexFromLocation(contentPages[index]); _getIndexFromLocation(contentPages[index]);
} }
@@ -188,9 +189,12 @@ class _MyHomePageState extends State<MyHomePage> {
if (!_fetched) { if (!_fetched) {
_fetched = true; _fetched = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
Future(() async {
final data = context.read<DataService>(); final data = context.read<DataService>();
final auth = context.read<AuthService>(); final auth = context.read<AuthService>();
api.setTokenProvider(() => auth.token); api.setTokenProvider(() => auth.token);
await auth.tryRestoreSession();
if (!auth.isLoggedIn) return;
if (data.homepageStats == null) { if (data.homepageStats == null) {
data.fetchHomepageStats(); data.fetchHomepageStats();
} }
@@ -200,6 +204,13 @@ class _MyHomePageState extends State<MyHomePage> {
if (data.traction.isEmpty) { if (data.traction.isEmpty) {
data.fetchHadTraction(); data.fetchHadTraction();
} }
if (data.onThisDay.isEmpty) {
data.fetchOnThisDay();
}
if (data.tripDetails.isEmpty) {
data.fetchTripDetails();
}
});
}); });
} }
} }
@@ -212,7 +223,7 @@ class _MyHomePageState extends State<MyHomePage> {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final auth = context.read<AuthService>(); final auth = context.read<AuthService>();
if (data.homepageStats != null) { if (data.homepageStats != null || !data.isHomepageLoading) {
currentPage = widget.child; currentPage = widget.child;
} else { } else {
currentPage = Center(child: CircularProgressIndicator()); currentPage = Center(child: CircularProgressIndicator());

View File

@@ -41,6 +41,7 @@ class HomepageStats {
final List<LocoSummary> topLocos; final List<LocoSummary> topLocos;
final List<LeaderboardEntry> leaderboard; final List<LeaderboardEntry> leaderboard;
final List<TripSummary> trips; final List<TripSummary> trips;
final UserData? user;
HomepageStats({ HomepageStats({
required this.totalMileage, required this.totalMileage,
@@ -48,23 +49,37 @@ class HomepageStats {
required this.topLocos, required this.topLocos,
required this.leaderboard, required this.leaderboard,
required this.trips, required this.trips,
this.user,
}); });
factory HomepageStats.fromJson(Map<String, dynamic> json) { factory HomepageStats.fromJson(Map<String, dynamic> json) {
final userData = json['user_data'];
final mileageData = json['milage_data'];
final totalMileage = mileageData is Map && mileageData['mileage'] != null
? (mileageData['mileage'] as num).toDouble()
: 0.0;
return HomepageStats( return HomepageStats(
totalMileage: (json['milage_data']['mileage'] as num).toDouble(), totalMileage: totalMileage,
yearlyMileage: (json['yearly_mileage'] as List) yearlyMileage: (json['yearly_mileage'] as List? ?? [])
.map((e) => YearlyMileage.fromJson(e)) .map((e) => YearlyMileage.fromJson(e))
.toList(), .toList(),
topLocos: (json['top_locos'] as List) topLocos: (json['top_locos'] as List? ?? [])
.map((e) => LocoSummary.fromJson(e)) .map((e) => LocoSummary.fromJson(e))
.toList(), .toList(),
leaderboard: (json['leaderboard_data'] as List) leaderboard: (json['leaderboard_data'] as List? ?? [])
.map((e) => LeaderboardEntry.fromJson(e)) .map((e) => LeaderboardEntry.fromJson(e))
.toList(), .toList(),
trips: (json['trip_data'] as List) trips: (json['trip_data'] as List? ?? [])
.map((e) => TripSummary.fromJson(e)) .map((e) => TripSummary.fromJson(e))
.toList(), .toList(),
user: userData == null
? null
: UserData(
userData['username'] ?? '',
userData['full_name'] ?? '',
userData['user_id'] ?? '',
userData['email'] ?? '',
),
); );
} }
} }
@@ -99,53 +114,79 @@ class Loco {
factory Loco.fromJson(Map<String, dynamic> json) => Loco( factory Loco.fromJson(Map<String, dynamic> json) => Loco(
id: json['loco_id'], id: json['loco_id'],
type: json['loco_type'], type: json['type'],
number: json['loco_number'], number: json['number'],
name: json['loco_name'] ?? "", name: json['name'] ?? "",
locoClass: json['loco_class'], locoClass: json['class'],
operator: json['loco_operator'], operator: json['operator'],
notes: json['loco_notes'], notes: json['notes'],
evn: json['loco_evn'], evn: json['evn'],
); );
} }
class LocoSummary extends Loco { class LocoSummary extends Loco {
final double? mileage; final double? mileage;
final int? journeys; final int? journeys;
final int? trips;
final String? status;
final String? domain;
final String? owner;
final String? livery;
final String? location;
final Map<String, dynamic> extra;
LocoSummary({ LocoSummary({
required int locoId, required int locoId,
required String locoType, required String locoType,
required String locoNumber, required String locoNumber,
required String locoName, required String locoName,
required String locoClass, required super.locoClass,
required String locoOperator, required String locoOperator,
String? locoNotes, String? locoNotes,
String? locoEvn, String? locoEvn,
this.mileage, this.mileage,
this.journeys, this.journeys,
}) : super( this.trips,
this.status,
this.domain,
this.owner,
this.livery,
this.location,
Map<String, dynamic>? extra,
}) : extra = extra ?? const {},
super(
id: locoId, id: locoId,
type: locoType, type: locoType,
number: locoNumber, number: locoNumber,
name: locoName, name: locoName,
locoClass: locoClass,
operator: locoOperator, operator: locoOperator,
notes: locoNotes, notes: locoNotes,
evn: locoEvn, evn: locoEvn,
); );
factory LocoSummary.fromJson(Map<String, dynamic> json) => LocoSummary( factory LocoSummary.fromJson(Map<String, dynamic> json) => LocoSummary(
locoId: json['loco_id'], locoId: json['loco_id'] ?? json['id'] ?? 0,
locoType: json['loco_type'], locoType: json['type'] ?? json['loco_type'] ?? '',
locoNumber: json['loco_number'], locoNumber: json['number'] ?? json['loco_number'] ?? '',
locoName: json['loco_name'] ?? "", locoName: json['name'] ?? json['loco_name'] ?? "",
locoClass: json['loco_class'], locoClass: json['class'] ?? json['loco_class'] ?? '',
locoOperator: json['loco_operator'], locoOperator: json['operator'] ?? json['loco_operator'] ?? '',
locoNotes: json['loco_notes'], locoNotes: json['notes'],
locoEvn: json['loco_evn'], locoEvn: json['evn'] ?? json['loco_evn'],
mileage: (json['loco_mileage'] as num?)?.toDouble() ?? 0, mileage:
journeys: json['loco_journeys'] ?? 0, ((json['loco_mileage'] ?? json['mileage']) as num?)?.toDouble() ?? 0,
journeys: (json['loco_journeys'] ?? json['journeys'] ?? 0) is num
? (json['loco_journeys'] ?? json['journeys'] ?? 0).toInt()
: 0,
trips: (json['loco_trips'] ?? json['trips']) is num
? (json['loco_trips'] ?? json['trips']).toInt()
: null,
status: json['status'] ?? json['loco_status'],
domain: json['domain'],
owner: json['owner'] ?? json['loco_owner'],
livery: json['livery'],
location: json['location'],
extra: Map<String, dynamic>.from(json),
); );
} }
@@ -285,3 +326,74 @@ class Station {
country: json['country'], country: json['country'],
); );
} }
class TripLeg {
final int? id;
final String start;
final String end;
final DateTime? beginTime;
final String? network;
final String? route;
final double? mileage;
final String? notes;
final List<Loco> locos;
TripLeg({
required this.id,
required this.start,
required this.end,
required this.beginTime,
required this.network,
required this.route,
required this.mileage,
required this.notes,
required this.locos,
});
factory TripLeg.fromJson(Map<String, dynamic> json) => TripLeg(
id: json['leg_id'],
start: json['leg_start'] ?? '',
end: json['leg_end'] ?? '',
beginTime:
json['leg_begin_time'] != null && json['leg_begin_time'] is String
? DateTime.tryParse(json['leg_begin_time'])
: (json['leg_begin_time'] is DateTime ? json['leg_begin_time'] : null),
network: json['leg_network'],
route: json['leg_route'],
mileage: (json['leg_mileage'] as num?)?.toDouble(),
notes: json['leg_notes'],
locos:
(json['locos'] as List?)
?.map((e) => Loco.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
}
class TripDetail {
final int id;
final String name;
final double mileage;
final int legCount;
final List<TripLeg> legs;
TripDetail({
required this.id,
required this.name,
required this.mileage,
required this.legCount,
required this.legs,
});
factory TripDetail.fromJson(Map<String, dynamic> json) => TripDetail(
id: json['trip_id'] ?? 0,
name: json['trip_name'] ?? '',
mileage: (json['trip_mileage'] as num?)?.toDouble() ?? 0,
legCount: json['leg_count'] ?? ((json['trip_legs'] as List?)?.length ?? 0),
legs:
(json['trip_legs'] as List?)
?.map((e) => TripLeg.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
}

View File

@@ -2,10 +2,12 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
typedef TokenProvider = String? Function(); typedef TokenProvider = String? Function();
typedef UnauthorizedHandler = Future<void> Function();
class ApiService { class ApiService {
final String baseUrl; final String baseUrl;
TokenProvider? _getToken; TokenProvider? _getToken;
UnauthorizedHandler? _onUnauthorized;
ApiService({required this.baseUrl}); ApiService({required this.baseUrl});
@@ -13,6 +15,10 @@ class ApiService {
_getToken = provider; _getToken = provider;
} }
void setUnauthorizedHandler(UnauthorizedHandler handler) {
_onUnauthorized = handler;
}
Map<String, String> _buildHeaders(Map<String, String>? extra) { Map<String, String> _buildHeaders(Map<String, String>? extra) {
final token = _getToken?.call(); final token = _getToken?.call();
final headers = {'accept': 'application/json', ...?extra}; final headers = {'accept': 'application/json', ...?extra};
@@ -35,10 +41,11 @@ class ApiService {
dynamic data, { dynamic data, {
Map<String, String>? headers, Map<String, String>? headers,
}) async { }) async {
final hasBody = data != null;
final response = await http.post( final response = await http.post(
Uri.parse('$baseUrl$endpoint'), Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(_jsonHeaders(headers)), headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers),
body: jsonEncode(data), body: hasBody ? jsonEncode(data) : null,
); );
return _processResponse(response); return _processResponse(response);
} }
@@ -60,10 +67,11 @@ class ApiService {
dynamic data, { dynamic data, {
Map<String, String>? headers, Map<String, String>? headers,
}) async { }) async {
final hasBody = data != null;
final response = await http.put( final response = await http.put(
Uri.parse('$baseUrl$endpoint'), Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(_jsonHeaders(headers)), headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers),
body: jsonEncode(data), body: hasBody ? jsonEncode(data) : null,
); );
return _processResponse(response); return _processResponse(response);
} }
@@ -83,12 +91,19 @@ class ApiService {
return {'Content-Type': 'application/json', if (extra != null) ...extra}; return {'Content-Type': 'application/json', if (extra != null) ...extra};
} }
dynamic _processResponse(http.Response res) { Future<dynamic> _processResponse(http.Response res) async {
final body = res.body.isNotEmpty ? jsonDecode(res.body) : null; final body = res.body.isNotEmpty ? jsonDecode(res.body) : null;
if (res.statusCode >= 200 && res.statusCode < 300) { if (res.statusCode >= 200 && res.statusCode < 300) {
return body; return body;
} else { }
if (res.statusCode == 401 &&
body is Map<String, dynamic> &&
body['detail'] == 'Not authenticated' &&
_onUnauthorized != null) {
await _onUnauthorized!();
}
throw Exception('API error ${res.statusCode}: $body'); throw Exception('API error ${res.statusCode}: $body');
} }
} }
}

View File

@@ -1,9 +1,15 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/apiService.dart'; import 'package:mileograph_flutter/services/apiService.dart';
class AuthService extends ChangeNotifier { class AuthService extends ChangeNotifier {
final ApiService api; final ApiService api;
static const _tokenKey = 'auth_token';
bool _restoring = false;
// secure storage instance
final FlutterSecureStorage _storage = const FlutterSecureStorage();
AuthService({required this.api}); AuthService({required this.api});
@@ -29,6 +35,7 @@ class AuthService extends ChangeNotifier {
access_token: accessToken, access_token: accessToken,
email: email, email: email,
); );
_persistToken(accessToken);
notifyListeners(); notifyListeners();
} }
@@ -65,8 +72,70 @@ class AuthService extends ChangeNotifier {
); );
} }
void logout() { Future<void> tryRestoreSession() async {
if (_restoring || _user != null) return;
_restoring = true;
try {
// read token from secure storage
final token = await _storage.read(key: _tokenKey);
if (token == null || token.isEmpty) return;
final userResponse = await api.get(
'/users/me',
headers: {
'Authorization': 'Bearer $token',
'accept': 'application/json',
},
);
setLoginData(
userId: userResponse['user_id'],
username: userResponse['username'],
fullName: userResponse['full_name'],
accessToken: token,
email: userResponse['email'],
);
} catch (_) {
await _clearToken();
} finally {
_restoring = false;
}
}
Future<void> _persistToken(String token) async {
await _storage.write(key: _tokenKey, value: token);
}
Future<void> _clearToken() async {
await _storage.delete(key: _tokenKey);
}
Future<void> register({
required String username,
required String email,
required String fullName,
required String password,
String inviteCode = '',
}) async {
final formData = {
'user_name': username,
'email': email,
'full_name': fullName,
'password': password,
'invitation_code': inviteCode,
'empty': '',
'empty2': '',
};
await api.postForm('/register', formData);
}
Future<void> handleTokenExpired() async {
_user = null; _user = null;
await _clearToken();
notifyListeners(); notifyListeners();
} }
void logout() {
handleTokenExpired(); // reuse
}
} }

View File

@@ -1,12 +1,33 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/apiService.dart'; // assumes you've moved HomepageStats + submodels to a separate file import 'package:mileograph_flutter/services/apiService.dart'; // assumes you've moved HomepageStats + submodels to a separate file
class _LegFetchOptions {
final int limit;
final String sortBy;
final int sortDirection;
final String? dateRangeStart;
final String? dateRangeEnd;
const _LegFetchOptions({
this.limit = 100,
this.sortBy = 'date',
this.sortDirection = 0,
this.dateRangeStart,
this.dateRangeEnd,
});
}
class DataService extends ChangeNotifier { class DataService extends ChangeNotifier {
final ApiService api; final ApiService api;
DataService({required this.api}); DataService({required this.api});
_LegFetchOptions _lastLegsFetch = const _LegFetchOptions();
// Homepage Data // Homepage Data
HomepageStats? _homepageStats; HomepageStats? _homepageStats;
HomepageStats? get homepageStats => _homepageStats; HomepageStats? get homepageStats => _homepageStats;
@@ -14,10 +35,32 @@ class DataService extends ChangeNotifier {
// Legs Data // Legs Data
List<Leg> _legs = []; List<Leg> _legs = [];
List<Leg> get legs => _legs; List<Leg> get legs => _legs;
List<Leg> _onThisDay = [];
List<Leg> get onThisDay => _onThisDay;
bool _isLegsLoading = false;
bool get isLegsLoading => _isLegsLoading;
bool _legsHasMore = false;
bool get legsHasMore => _legsHasMore;
// Traction Data // Traction Data
List<LocoSummary> _traction = []; List<LocoSummary> _traction = [];
List<LocoSummary> get traction => _traction; List<LocoSummary> get traction => _traction;
bool _isTractionLoading = false;
bool get isTractionLoading => _isTractionLoading;
bool _tractionHasMore = false;
bool get tractionHasMore => _tractionHasMore;
// Trips
List<TripSummary> _trips = [];
List<TripSummary> get trips => _trips;
List<TripDetail> _tripDetails = [];
List<TripDetail> get tripDetails => _tripDetails;
bool _isTripDetailsLoading = false;
bool get isTripDetailsLoading => _isTripDetailsLoading;
List<String> _locoClasses = [];
List<String> get locoClasses => _locoClasses;
List<TripSummary> _tripList = [];
List<TripSummary> get tripList => _tripList;
// Station Data // Station Data
List<Station>? _cachedStations; List<Station>? _cachedStations;
@@ -27,20 +70,30 @@ class DataService extends ChangeNotifier {
bool _isHomepageLoading = false; bool _isHomepageLoading = false;
bool get isHomepageLoading => _isHomepageLoading; bool get isHomepageLoading => _isHomepageLoading;
bool _isOnThisDayLoading = false;
bool get isOnThisDayLoading => _isOnThisDayLoading;
void _notifyAsync() {
// Always defer to the next frame to avoid setState during build.
SchedulerBinding.instance.addPostFrameCallback((_) {
notifyListeners();
});
}
Future<void> fetchHomepageStats() async { Future<void> fetchHomepageStats() async {
_isHomepageLoading = true; _isHomepageLoading = true;
notifyListeners();
try { try {
final json = await api.get('/stats/homepage'); final json = await api.get('/stats/homepage');
_homepageStats = HomepageStats.fromJson(json); _homepageStats = HomepageStats.fromJson(json);
_trips = _homepageStats?.trips ?? [];
} catch (e) { } catch (e) {
debugPrint('Failed to fetch homepage stats: $e'); debugPrint('Failed to fetch homepage stats: $e');
_homepageStats = null; _homepageStats = null;
_trips = [];
} finally { } finally {
_isHomepageLoading = false; _isHomepageLoading = false;
notifyListeners(); _notifyAsync();
} }
} }
@@ -49,34 +102,219 @@ class DataService extends ChangeNotifier {
int limit = 100, int limit = 100,
String sortBy = 'date', String sortBy = 'date',
int sortDirection = 0, int sortDirection = 0,
String? dateRangeStart,
String? dateRangeEnd,
bool append = false,
}) async { }) async {
final query = _isLegsLoading = true;
'?sort_direction=$sortDirection&sort_by=$sortBy&offset=$offset&limit=$limit'; if (!append) {
final json = await api.get('/user/legs$query'); _lastLegsFetch = _LegFetchOptions(
limit: limit,
sortBy: sortBy,
sortDirection: sortDirection,
dateRangeStart: dateRangeStart,
dateRangeEnd: dateRangeEnd,
);
}
final buffer = StringBuffer(
'?sort_direction=$sortDirection&sort_by=$sortBy&offset=$offset&limit=$limit',
);
if (dateRangeStart != null && dateRangeStart.isNotEmpty) {
buffer.write('&date_range_start=$dateRangeStart');
}
if (dateRangeEnd != null && dateRangeEnd.isNotEmpty) {
buffer.write('&date_range_end=$dateRangeEnd');
}
try {
final json = await api.get('/user/legs${buffer.toString()}');
if (json is List) { if (json is List) {
_legs = json.map((e) => Leg.fromJson(e)).toList(); final newLegs = json.map((e) => Leg.fromJson(e)).toList();
notifyListeners(); _legs = append ? [..._legs, ...newLegs] : newLegs;
_legsHasMore = newLegs.length >= limit;
} else { } else {
throw Exception('Unexpected legs response: $json'); throw Exception('Unexpected legs response: $json');
} }
} catch (e) {
debugPrint('Failed to fetch legs: $e');
if (!append) _legs = [];
_legsHasMore = false;
} finally {
_isLegsLoading = false;
_notifyAsync();
}
}
Future<void> refreshLegs() {
return fetchLegs(
limit: _lastLegsFetch.limit,
sortBy: _lastLegsFetch.sortBy,
sortDirection: _lastLegsFetch.sortDirection,
dateRangeStart: _lastLegsFetch.dateRangeStart,
dateRangeEnd: _lastLegsFetch.dateRangeEnd,
);
} }
Future<void> fetchHadTraction({int offset = 0, int limit = 100}) async { Future<void> fetchHadTraction({int offset = 0, int limit = 100}) async {
final query = '?offset=$offset&limit=$limit'; await fetchTraction(
final json = await api.get('/loco/mileage$query'); hadOnly: true,
offset: offset,
limit: limit,
append: offset > 0,
);
}
Future<void> fetchTraction({
bool hadOnly = false,
int offset = 0,
int limit = 50,
String? locoClass,
String? locoNumber,
bool mileageFirst = true,
bool append = false,
Map<String, dynamic>? filters,
}) async {
_isTractionLoading = true;
try {
final params = StringBuffer('?limit=$limit&offset=$offset');
if (hadOnly) params.write('&had_only=true');
if (!mileageFirst) params.write('&mileage_first=false');
final payload = <String, dynamic>{};
if (locoClass != null && locoClass.isNotEmpty) {
payload['class'] = locoClass;
}
if (locoNumber != null && locoNumber.isNotEmpty) {
payload['number'] = locoNumber;
}
if (filters != null) {
filters.forEach((key, value) {
if (value == null) return;
if (value is String && value.trim().isEmpty) return;
payload[key] = value;
});
}
final json = await api.post(
'/locos/search/v2${params.toString()}',
payload.isEmpty ? null : payload,
);
if (json is List) { if (json is List) {
_traction = json.map((e) => LocoSummary.fromJson(e)).toList(); final newItems = json.map((e) => LocoSummary.fromJson(e)).toList();
notifyListeners(); _traction = append ? [..._traction, ...newItems] : newItems;
_tractionHasMore = newItems.length >= limit - 1;
} else { } else {
throw Exception('Unexpected traction response: $json'); throw Exception('Unexpected traction response: $json');
} }
} catch (e) {
debugPrint('Failed to fetch traction: $e');
if (!append) {
_traction = [];
}
_tractionHasMore = false;
} finally {
_isTractionLoading = false;
_notifyAsync();
}
}
Future<void> fetchOnThisDay({DateTime? date}) async {
_isOnThisDayLoading = true;
final target = date ?? DateTime.now();
final formatted =
"${target.year.toString().padLeft(4, '0')}-${target.month.toString().padLeft(2, '0')}-${target.day.toString().padLeft(2, '0')}";
try {
final json = await api.get('/legs/on-this-day?date=$formatted');
if (json is List) {
_onThisDay = json.map((e) => Leg.fromJson(e)).toList();
} else {
_onThisDay = [];
}
} catch (e) {
debugPrint('Failed to fetch on-this-day legs: $e');
_onThisDay = [];
} finally {
_isOnThisDayLoading = false;
_notifyAsync();
}
}
Future<void> fetchTripDetails() async {
_isTripDetailsLoading = true;
try {
final json = await api.get('/trips/legs-and-stats');
if (json is List) {
final trip_map = json.map((e) => TripDetail.fromJson(e)).toList();
_tripDetails = [...trip_map]..sort((a, b) => b.id.compareTo(a.id));
} else {
_tripDetails = [];
}
} catch (e) {
debugPrint('Failed to fetch trip_map: $e');
_tripDetails = [];
} finally {
_isTripDetailsLoading = false;
_notifyAsync();
}
}
Future<void> fetchTrips() async {
try {
final json = await api.get('/trips/mileage');
Iterable<dynamic>? raw;
if (json is List) {
raw = json;
} else if (json is Map) {
for (final key in ['trips', 'trip_data', 'data']) {
final value = json[key];
if (value is List) {
raw = value;
break;
}
}
}
if (raw != null) {
final trip_map = raw
.whereType<Map<String, dynamic>>()
.map((e) => TripSummary.fromJson(e))
.toList();
_tripList = [...trip_map]..sort((a, b) => b.tripId.compareTo(a.tripId));
} else {
debugPrint('Unexpected trip list response: $json');
_tripList = [];
}
} catch (e) {
debugPrint('Failed to fetch trip list: $e');
_tripList = [];
} finally {
_notifyAsync();
}
}
Future<List<String>> fetchClassList() async {
if (_locoClasses.isNotEmpty) return _locoClasses;
try {
final json = await api.get('/loco/classlist');
if (json is List) {
_locoClasses = json.map((e) => e.toString()).toList();
_notifyAsync();
}
} catch (e) {
debugPrint('Failed to fetch class list: $e');
}
return _locoClasses;
} }
void clear() { void clear() {
_homepageStats = null; _homepageStats = null;
notifyListeners(); _legs = [];
_onThisDay = [];
_trips = [];
_tripDetails = [];
_notifyAsync();
} }
double getMileageForCurrentYear() { double getMileageForCurrentYear() {

View File

@@ -0,0 +1,36 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenStorageService {
// Singleton pattern (optional but usually handy for services)
TokenStorageService._internal();
static final TokenStorageService _instance = TokenStorageService._internal();
factory TokenStorageService() => _instance;
static const _tokenKey = 'auth_token';
// Use const constructor for secure storage
final FlutterSecureStorage _storage = const FlutterSecureStorage();
/// Save or update the token
Future<void> setToken(String token) async {
await _storage.write(key: _tokenKey, value: token);
}
/// Retrieve the stored token (null if none)
Future<String?> getToken() async {
return _storage.read(key: _tokenKey);
}
/// Delete the token
Future<void> clearToken() async {
await _storage.delete(key: _tokenKey);
}
/// Optional: check quickly if a token exists
Future<bool> hasToken() async {
final token = await getToken();
return token != null && token.isNotEmpty;
}
}

View File

@@ -7,9 +7,13 @@
#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 <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) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
} }

View File

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

View File

@@ -6,7 +6,13 @@ import FlutterMacOS
import Foundation import Foundation
import dynamic_color import dynamic_color
import flutter_secure_storage_darwin
import path_provider_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"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
} }

View File

@@ -65,6 +65,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -78,6 +94,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
url: "https://pub.dev"
source: hosted
version: "10.0.0"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -112,6 +176,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
intl:
dependency: "direct main"
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.dev"
source: hosted
version: "0.19.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -192,6 +264,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
url: "https://pub.dev"
source: hosted
version: "2.2.19"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -200,6 +336,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5" version: "6.1.5"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e
url: "https://pub.dev"
source: hosted
version: "2.4.13"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -285,6 +477,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks: sdks:
dart: ">=3.8.1 <4.0.0" dart: ">=3.8.1 <4.0.0"
flutter: ">=3.27.0" flutter: ">=3.29.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: 1.0.0+1 version: 0.1.2+1
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1
@@ -30,9 +30,12 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
intl: ^0.19.0
shared_preferences: ^2.2.2
http: ^1.4.0 http: ^1.4.0
provider: ^6.1.5 provider: ^6.1.5
dynamic_color: ^1.6.6 dynamic_color: ^1.6.6
flutter_secure_storage: ^10.0.0
# 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,8 +7,11 @@
#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 <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"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
} }

View File

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