4 Commits

Author SHA1 Message Date
eb01cf0e8e release apk
All checks were successful
Release / meta (push) Successful in 24s
Release / linux-build (push) Successful in 1m15s
Release / android-build (push) Successful in 48m22s
Release / release-dev (push) Successful in 6s
Release / release-master (push) Successful in 59s
2025-12-14 10:05:01 +00:00
f0dfbd185b QoL changes
All checks were successful
Release / meta (push) Successful in 22s
Release / linux-build (push) Successful in 4m32s
Release / android-build (push) Successful in 7m10s
Release / release-dev (push) Successful in 9s
Release / release-master (push) Successful in 9s
2025-12-14 09:45:32 +00:00
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
21 changed files with 1231 additions and 411 deletions

View File

@@ -81,16 +81,63 @@ jobs:
- name: Flutter dependencies - name: Flutter dependencies
run: flutter pub get run: flutter pub get
- name: Build APK (release) - name: Prepare Android keystore (optional)
if: ${{ secrets.ANDROID_KEYSTORE_BASE64 != '' }}
run: | run: |
flutter build apk --release echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android-release-key.jks
cp build/app/outputs/flutter-apk/app-release.apk app-release.apk echo "ANDROID_KEYSTORE_PATH=$PWD/android-release-key.jks" >> "$GITHUB_ENV"
echo "ANDROID_KEYSTORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> "$GITHUB_ENV"
echo "ANDROID_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}" >> "$GITHUB_ENV"
echo "ANDROID_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}" >> "$GITHUB_ENV"
- name: Build Android App Bundle (release)
run: flutter build appbundle --release
- name: Download bundletool
run: |
BUNDLETOOL_VERSION=1.15.6
curl -fsSL -o bundletool.jar "https://github.com/google/bundletool/releases/download/${BUNDLETOOL_VERSION}/bundletool-all-${BUNDLETOOL_VERSION}.jar"
- name: Extract universal APK from bundle
env:
BASE_VERSION: ${{ needs.meta.outputs.base_version }}
run: |
set -euo pipefail
BUNDLE="build/app/outputs/bundle/release/app-release.aab"
OUTPUT_APKS="app-release.apks"
APK_NAME="mileograph-${BASE_VERSION}.apk"
if [ ! -f "$BUNDLE" ]; then
echo "Bundle not found at $BUNDLE"
exit 1
fi
SIGNING_ARGS=()
if [ -n "${ANDROID_KEYSTORE_PATH:-}" ] && [ -f "$ANDROID_KEYSTORE_PATH" ]; then
SIGNING_ARGS+=(--ks="$ANDROID_KEYSTORE_PATH")
SIGNING_ARGS+=(--ks-pass="pass:${ANDROID_KEYSTORE_PASSWORD}")
SIGNING_ARGS+=(--ks-key-alias="${ANDROID_KEY_ALIAS}")
KEY_PASS="${ANDROID_KEY_PASSWORD:-$ANDROID_KEYSTORE_PASSWORD}"
SIGNING_ARGS+=(--key-pass="pass:${KEY_PASS}")
else
echo "No release keystore provided; bundletool will sign with the debug keystore."
fi
java -jar bundletool.jar build-apks \
--bundle="$BUNDLE" \
--output="$OUTPUT_APKS" \
--mode=universal \
"${SIGNING_ARGS[@]}"
unzip -p "$OUTPUT_APKS" universal.apk > "$APK_NAME"
ls -lh "$APK_NAME"
- name: Upload Android APK artifact - name: Upload Android APK artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: android-apk name: android-apk
path: app-release.apk path: mileograph-${{ needs.meta.outputs.base_version }}.apk
linux-build: linux-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -107,7 +154,7 @@ jobs:
SUDO="" SUDO=""
fi fi
$SUDO apt-get update $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 liblzma-dev curl jq $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 - name: Setup Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
@@ -141,6 +188,15 @@ jobs:
- android-build - android-build
- linux-build - linux-build
steps: 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 - name: Download Android APK
if: ${{ github.ref == 'refs/heads/dev' }} if: ${{ github.ref == 'refs/heads/dev' }}
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
@@ -148,54 +204,36 @@ jobs:
name: android-apk name: android-apk
path: artifacts path: artifacts
- name: Download Linux bundle - name: Prepare APK and tag
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' }} if: ${{ github.ref == 'refs/heads/dev' }}
id: bundle id: bundle
run: | run: |
BASE="${{ needs.meta.outputs.base_version }}" BASE="${{ needs.meta.outputs.base_version }}"
TAG="v${BASE}-dev" TAG="v${BASE}-dev.${{ github.run_number }}"
mv artifacts/app-release.apk "artifacts/app-${BASE}-dev.apk" mv "artifacts/mileograph-${BASE}.apk" "artifacts/mileograph-${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 "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "files=artifacts/*" >> "$GITHUB_OUTPUT" echo "apk=artifacts/mileograph-${BASE}-dev.apk" >> "$GITHUB_OUTPUT"
- name: Create prerelease on Gitea - name: Create prerelease on Gitea
if: ${{ github.ref == 'refs/heads/dev' }} if: ${{ github.ref == 'refs/heads/dev' }}
uses: ncipollo/release-action@v1 uses: ncipollo/release-action@v1
with: with:
tag: v${{ needs.meta.outputs.base_version }}-dev.${{ github.run_number }} tag: ${{ steps.bundle.outputs.tag }}
name: v${{ needs.meta.outputs.base_version }}-dev build ${{ github.run_number }} name: v${{ needs.meta.outputs.base_version }}-dev build ${{ github.run_number }}
prerelease: true prerelease: true
commit: ${{ github.sha }} commit: ${{ github.sha }}
token: ${{ secrets.GITEA_TOKEN }} token: ${{ secrets.GITEA_TOKEN }}
# NOTE: no `artifacts:` here # NOTE: no `artifacts:` here
- name: Attach artefacts to Gitea release - name: Attach APK to Gitea release
if: ${{ github.ref == 'refs/heads/dev' }} if: ${{ github.ref == 'refs/heads/dev' }}
run: | run: |
set -euo pipefail set -euo pipefail
BASE="${{ needs.meta.outputs.base_version }}" TAG="${{ steps.bundle.outputs.tag }}"
TAG="v${BASE}-dev.${{ github.run_number }}" APK="${{ steps.bundle.outputs.apk }}"
# 1. Find release ID by tag # 1. Find release ID by tag
RELEASE_JSON=$(curl -sS \ RELEASE_JSON=$(curl -sS \
@@ -206,20 +244,16 @@ jobs:
echo "Release ID: $RELEASE_ID" echo "Release ID: $RELEASE_ID"
# 2. Upload each artefact with multipart/form-data # 2. Upload APK with multipart/form-data
for f in artifacts/*; do NAME=$(basename "$APK")
[ -f "$f" ] || continue
NAME=$(basename "$f")
echo "Uploading $NAME" echo "Uploading $NAME"
curl -sS -X POST \ curl -sS -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \ -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@${f}" \ -F "attachment=@${APK}" \
-F "name=${NAME}" \ -F "name=${NAME}" \
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \ "${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null >/dev/null
done
release-master: release-master:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -228,6 +262,15 @@ jobs:
- android-build - android-build
- linux-build - linux-build
steps: 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 - name: Download Android APK
if: ${{ github.ref == 'refs/heads/master' }} if: ${{ github.ref == 'refs/heads/master' }}
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
@@ -235,35 +278,15 @@ jobs:
name: android-apk name: android-apk
path: artifacts path: artifacts
- name: Download Linux bundle - name: Prepare APK and tag
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' }} if: ${{ github.ref == 'refs/heads/master' }}
id: bundle id: bundle
run: | run: |
BASE="${{ needs.meta.outputs.base_version }}" BASE="${{ needs.meta.outputs.base_version }}"
TAG="v${BASE}" 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 "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "files=artifacts/*" >> "$GITHUB_OUTPUT" echo "apk=artifacts/mileograph-${BASE}.apk" >> "$GITHUB_OUTPUT"
- name: Create release on Gitea - name: Create release on Gitea
if: ${{ github.ref == 'refs/heads/master' }} if: ${{ github.ref == 'refs/heads/master' }}
@@ -273,4 +296,32 @@ jobs:
name: ${{ steps.bundle.outputs.tag }} name: ${{ steps.bundle.outputs.tag }}
prerelease: false prerelease: false
token: ${{ secrets.GITEA_TOKEN }} token: ${{ secrets.GITEA_TOKEN }}
artifacts: ${{ steps.bundle.outputs.files }} commit: ${{ github.sha }}
- name: Attach APK to Gitea release
if: ${{ github.ref == 'refs/heads/master' }}
run: |
set -euo pipefail
TAG="${{ steps.bundle.outputs.tag }}"
APK="${{ steps.bundle.outputs.apk }}"
# 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 APK with multipart/form-data
NAME=$(basename "$APK")
echo "Uploading $NAME"
curl -sS -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@${APK}" \
-F "name=${NAME}" \
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null

2
.gitignore vendored
View File

@@ -43,3 +43,5 @@ app.*.map.json
/android/app/debug /android/app/debug
/android/app/profile /android/app/profile
/android/app/release /android/app/release
api_return_examples.txt

View File

@@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
@@ -5,6 +7,28 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
}
val releaseStoreFile = System.getenv("ANDROID_KEYSTORE_PATH")
?: keystoreProperties.getProperty("storeFile")
val releaseStorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
?: keystoreProperties.getProperty("storePassword")
val releaseKeyAlias = System.getenv("ANDROID_KEY_ALIAS")
?: keystoreProperties.getProperty("keyAlias")
val releaseKeyPassword = System.getenv("ANDROID_KEY_PASSWORD")
?: keystoreProperties.getProperty("keyPassword")
val hasReleaseKeystore = listOf(
releaseStoreFile,
releaseStorePassword,
releaseKeyAlias,
releaseKeyPassword
).all { !it.isNullOrBlank() }
android { android {
namespace = "com.example.mileograph_flutter" namespace = "com.example.mileograph_flutter"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@@ -30,11 +54,21 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
signingConfigs {
if (hasReleaseKeystore) {
create("release") {
storeFile = file(releaseStoreFile!!)
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias!!
keyPassword = releaseKeyPassword
}
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. // Use a real release keystore when provided; fall back to debug keys otherwise.
// Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.getByName(if (hasReleaseKeystore) "release" else "debug")
signingConfig = signingConfigs.getByName("debug")
} }
} }
} }

View File

@@ -7,15 +7,24 @@ import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/dataService.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class Dashboard extends StatelessWidget { class Dashboard extends StatefulWidget {
const Dashboard({super.key}); const Dashboard({super.key});
@override
State<Dashboard> createState() => _DashboardState();
}
class _DashboardState extends State<Dashboard> {
bool _showAllOnThisDay = false;
@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>();
final stats = data.homepageStats; final stats = data.homepageStats;
final isInitialLoading = data.isHomepageLoading || stats == null;
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
await data.fetchHomepageStats(); await data.fetchHomepageStats();
@@ -34,7 +43,9 @@ class Dashboard extends StatelessWidget {
currentYearMileage: data.getMileageForCurrentYear(), currentYearMileage: data.getMileageForCurrentYear(),
trips: data.trips.length, trips: data.trips.length,
); );
return ListView( return Stack(
children: [
ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
_buildHeader(context, auth, stats, data.isHomepageLoading), _buildHeader(context, auth, stats, data.isHomepageLoading),
@@ -61,6 +72,25 @@ class Dashboard extends StatelessWidget {
], ],
), ),
], ],
),
if (isInitialLoading)
Positioned.fill(
child: Container(
color:
Theme.of(context).colorScheme.surface.withOpacity(0.7),
child: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 12),
Text('Loading dashboard data...'),
],
),
),
),
),
],
); );
}, },
), ),
@@ -153,6 +183,17 @@ class Dashboard extends StatelessWidget {
_buildCard( _buildCard(
context, context,
title: 'On this day', title: 'On this day',
action: data.onThisDay
.where((leg) => leg.beginTime.year != DateTime.now().year)
.length >
5
? TextButton(
onPressed: () => setState(() {
_showAllOnThisDay = !_showAllOnThisDay;
}),
child: Text(_showAllOnThisDay ? 'Show less' : 'Show more'),
)
: null,
trailing: data.isOnThisDayLoading trailing: data.isOnThisDayLoading
? const SizedBox( ? const SizedBox(
height: 18, height: 18,
@@ -163,6 +204,7 @@ class Dashboard extends StatelessWidget {
child: _buildLegList( child: _buildLegList(
context, context,
data.onThisDay, data.onThisDay,
showAll: _showAllOnThisDay,
emptyMessage: 'No historical moves for today yet.', emptyMessage: 'No historical moves for today yet.',
), ),
), ),
@@ -231,12 +273,17 @@ class Dashboard extends StatelessWidget {
BuildContext context, BuildContext context,
List<Leg> legs, { List<Leg> legs, {
required String emptyMessage, required String emptyMessage,
bool showAll = false,
}) { }) {
if (legs.isEmpty) { final filtered = legs
.where((leg) => leg.beginTime.year != DateTime.now().year)
.toList();
if (filtered.isEmpty) {
return Text(emptyMessage, style: Theme.of(context).textTheme.bodyMedium); return Text(emptyMessage, style: Theme.of(context).textTheme.bodyMedium);
} }
final toShow = showAll ? filtered : filtered.take(5).toList();
return Column( return Column(
children: legs.take(5).map((leg) { children: toShow.map((leg) {
return ListTile( return ListTile(
dense: true, dense: true,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,

View File

@@ -1,4 +1,7 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.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'; import 'package:provider/provider.dart';
@@ -155,25 +158,6 @@ class _LegsPageState extends State<LegsPage> {
runSpacing: 12, runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ 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( FilledButton.tonalIcon(
onPressed: () => _pickDate(start: true), onPressed: () => _pickDate(start: true),
icon: const Icon(Icons.calendar_month), icon: const Icon(Icons.calendar_month),
@@ -229,37 +213,7 @@ class _LegsPageState extends State<LegsPage> {
else else
Column( Column(
children: [ children: [
...legs.map((leg) => Card( ...legs.map((leg) => _buildLegCard(context, leg)),
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), const SizedBox(height: 8),
if (data.legsHasMore || data.isLegsLoading) if (data.legsHasMore || data.isLegsLoading)
Align( Align(
@@ -297,4 +251,148 @@ class _LegsPageState extends State<LegsPage> {
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
return '$dateStr · $timeStr'; return '$dateStr · $timeStr';
} }
Widget _buildLegCard(BuildContext context, Leg leg) {
final routeSegments = _parseRouteSegments(leg.route);
final textTheme = Theme.of(context).textTheme;
return Card(
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
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}',
style: textTheme.labelSmall,
),
if (leg.network.isNotEmpty)
Text(
leg.network,
style: textTheme.labelSmall,
),
],
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${leg.mileage.toStringAsFixed(1)} mi',
style:
textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
),
if (leg.tripId != 0)
Text(
'Trip #${leg.tripId}',
style: textTheme.labelSmall,
),
],
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (leg.notes.isNotEmpty) ...[
Text('Notes', style: textTheme.titleSmall),
const SizedBox(height: 4),
Text(leg.notes),
const SizedBox(height: 12),
],
if (leg.locos.isNotEmpty) ...[
Text('Locos', style: textTheme.titleSmall),
const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 8,
children: _buildLocoChips(context, leg),
),
const SizedBox(height: 12),
],
if (routeSegments.isNotEmpty) ...[
Text('Route', style: textTheme.titleSmall),
const SizedBox(height: 6),
_buildRouteList(routeSegments),
],
],
),
),
],
),
);
}
List<Widget> _buildLocoChips(BuildContext context, Leg leg) {
final theme = Theme.of(context);
return leg.locos
.map(
(loco) => Chip(
label: Text('${loco.locoClass} ${loco.number}'),
avatar: const Icon(Icons.directions_railway, size: 16),
backgroundColor: theme.colorScheme.surfaceContainerHighest,
),
)
.toList();
}
Widget _buildRouteList(List<String> segments) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: segments
.map(
(segment) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
children: [
const Icon(Icons.circle, size: 10),
const SizedBox(width: 8),
Expanded(child: Text(segment)),
],
),
),
)
.toList(),
);
}
List<String> _parseRouteSegments(String route) {
final trimmed = route.trim();
if (trimmed.isEmpty) return [];
try {
final decoded = jsonDecode(trimmed);
if (decoded is List) {
return decoded.map((e) => e.toString()).toList();
}
} catch (_) {
// ignore and try alternative parsing
}
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
final replaced = trimmed.replaceAll("'", '"');
final decoded = jsonDecode(replaced);
if (decoded is List) {
return decoded.map((e) => e.toString()).toList();
}
} catch (_) {}
}
if (trimmed.contains('->')) {
return trimmed
.split('->')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
if (trimmed.contains(',')) {
return trimmed
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
return [trimmed];
}
} }

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@@ -8,6 +9,7 @@ import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/apiService.dart'; import 'package:mileograph_flutter/services/apiService.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/dataService.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class NewEntryPage extends StatefulWidget { class NewEntryPage extends StatefulWidget {
const NewEntryPage({super.key}); const NewEntryPage({super.key});
@@ -17,6 +19,8 @@ class NewEntryPage extends StatefulWidget {
} }
class _NewEntryPageState extends State<NewEntryPage> { class _NewEntryPageState extends State<NewEntryPage> {
static const _draftPrefsKey = 'new_entry_draft';
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
DateTime _selectedDate = DateTime.now(); DateTime _selectedDate = DateTime.now();
TimeOfDay _selectedTime = TimeOfDay.now(); TimeOfDay _selectedTime = TimeOfDay.now();
@@ -31,20 +35,42 @@ class _NewEntryPageState extends State<NewEntryPage> {
RouteResult? _routeResult; RouteResult? _routeResult;
final List<_TractionItem> _tractionItems = [_TractionItem.marker()]; final List<_TractionItem> _tractionItems = [_TractionItem.marker()];
int? _selectedTripId; int? _selectedTripId;
bool _restoringDraft = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
for (final controller in [
_startController,
_endController,
_headcodeController,
_notesController,
_mileageController,
_networkController,
]) {
controller.addListener(_saveDraft);
}
Future.microtask(() { Future.microtask(() {
if (!mounted) return; if (!mounted) return;
final data = context.read<DataService>(); final data = context.read<DataService>();
data.fetchClassList(); data.fetchClassList();
data.fetchTrips(); data.fetchTrips();
_loadDraft();
}); });
} }
@override @override
void dispose() { void dispose() {
for (final controller in [
_startController,
_endController,
_headcodeController,
_notesController,
_mileageController,
_networkController,
]) {
controller.removeListener(_saveDraft);
}
_startController.dispose(); _startController.dispose();
_endController.dispose(); _endController.dispose();
_headcodeController.dispose(); _headcodeController.dispose();
@@ -57,11 +83,16 @@ class _NewEntryPageState extends State<NewEntryPage> {
Widget _buildTripSelector(BuildContext context) { Widget _buildTripSelector(BuildContext context) {
final trips = context.watch<DataService>().tripList; final trips = context.watch<DataService>().tripList;
final sorted = [...trips]..sort((a, b) => b.tripId.compareTo(a.tripId)); final sorted = [...trips]..sort((a, b) => b.tripId.compareTo(a.tripId));
final tripIds = sorted.map((t) => t.tripId).toSet();
final selectedValue =
(_selectedTripId != null && tripIds.contains(_selectedTripId))
? _selectedTripId
: null;
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: DropdownButtonFormField<int>( child: DropdownButtonFormField<int?>(
value: _selectedTripId, value: selectedValue,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Trip', labelText: 'Trip',
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -70,10 +101,13 @@ class _NewEntryPageState extends State<NewEntryPage> {
const DropdownMenuItem(value: null, child: Text('No trip')), const DropdownMenuItem(value: null, child: Text('No trip')),
...sorted.map( ...sorted.map(
(t) => (t) =>
DropdownMenuItem(value: t.tripId, child: Text(t.tripName)), DropdownMenuItem<int?>(value: t.tripId, child: Text(t.tripName)),
), ),
], ],
onChanged: (val) => setState(() => _selectedTripId = val), onChanged: (val) {
setState(() => _selectedTripId = val);
_saveDraft();
},
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -126,6 +160,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
: TripSummary(tripId: 0, tripName: result, tripMileage: 0), : TripSummary(tripId: 0, tripName: result, tripMileage: 0),
); );
setState(() => _selectedTripId = match.tripId); setState(() => _selectedTripId = match.tripId);
_saveDraft();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
messenger.showSnackBar( messenger.showSnackBar(
@@ -149,6 +184,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
_mileageController.text = result.distance.toStringAsFixed(2); _mileageController.text = result.distance.toStringAsFixed(2);
_useManualMileage = false; _useManualMileage = false;
}); });
_saveDraft();
} }
} }
@@ -183,6 +219,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
); );
} }
}); });
_saveDraft();
}, },
), ),
), ),
@@ -197,6 +234,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
lastDate: DateTime.now().add(const Duration(days: 365)), lastDate: DateTime.now().add(const Duration(days: 365)),
); );
if (picked != null) setState(() => _selectedDate = picked); if (picked != null) setState(() => _selectedDate = picked);
_saveDraft();
} }
Future<void> _pickTime() async { Future<void> _pickTime() async {
@@ -204,7 +242,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
context: context, context: context,
initialTime: _selectedTime, initialTime: _selectedTime,
); );
if (picked != null) setState(() => _selectedTime = picked); if (picked != null) {
setState(() => _selectedTime = picked);
_saveDraft();
}
} }
DateTime get _legDateTime => DateTime( DateTime get _legDateTime => DateTime(
@@ -295,7 +336,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(const SnackBar(content: Text('Entry submitted'))); ).showSnackBar(const SnackBar(content: Text('Entry submitted')));
_formKey.currentState!.reset(); _resetFormState(clearDraft: true);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(
@@ -306,6 +347,167 @@ class _NewEntryPageState extends State<NewEntryPage> {
} }
} }
Future<void> _resetFormState({bool clearDraft = false}) async {
_formKey.currentState?.reset();
_startController.clear();
_endController.clear();
_headcodeController.clear();
_notesController.clear();
_mileageController.clear();
_networkController.clear();
final now = DateTime.now();
setState(() {
_selectedDate = now;
_selectedTime = TimeOfDay.fromDateTime(now);
_useManualMileage = false;
_routeResult = null;
_tractionItems
..clear()
..add(_TractionItem.marker());
_selectedTripId = null;
_submitting = false;
});
if (clearDraft) {
await _clearDraft();
} else {
_saveDraft();
}
}
Future<void> _saveDraft() async {
if (_restoringDraft) return;
final prefs = await SharedPreferences.getInstance();
final draft = {
"date": _selectedDate.toIso8601String(),
"time": {"hour": _selectedTime.hour, "minute": _selectedTime.minute},
"start": _startController.text,
"end": _endController.text,
"headcode": _headcodeController.text,
"notes": _notesController.text,
"mileage": _mileageController.text,
"network": _networkController.text,
"useManualMileage": _useManualMileage,
"selectedTripId": _selectedTripId,
"routeResult": _routeResult == null
? null
: {
"input_route": _routeResult!.inputRoute,
"calculated_route": _routeResult!.calculatedRoute,
"costs": _routeResult!.costs,
"distance": _routeResult!.distance,
},
"tractionItems": _serializeTractionItems(),
};
await prefs.setString(_draftPrefsKey, jsonEncode(draft));
}
Future<void> _clearDraft() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_draftPrefsKey);
}
Future<void> _loadDraft() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_draftPrefsKey);
if (raw == null) return;
try {
final data = jsonDecode(raw);
if (data is! Map) return;
_restoringDraft = true;
setState(() {
if (data['date'] is String) {
_selectedDate = DateTime.tryParse(data['date']) ?? _selectedDate;
}
if (data['time'] is Map) {
final time = data['time'] as Map;
final hour = time['hour'] as int?;
final minute = time['minute'] as int?;
if (hour != null && minute != null) {
_selectedTime = TimeOfDay(hour: hour, minute: minute);
}
}
_useManualMileage = data['useManualMileage'] ?? _useManualMileage;
_selectedTripId = data['selectedTripId'];
if (data['routeResult'] is Map<String, dynamic>) {
_routeResult =
RouteResult.fromJson(Map<String, dynamic>.from(data['routeResult']));
_mileageController.text = _routeResult!.distance.toStringAsFixed(2);
}
if (data['tractionItems'] is List) {
_restoreTractionItems(List<Map<String, dynamic>>.from(
data['tractionItems'].cast<Map>(),
));
}
});
_startController.text = data['start'] ?? '';
_endController.text = data['end'] ?? '';
_headcodeController.text = data['headcode'] ?? '';
_notesController.text = data['notes'] ?? '';
_mileageController.text = data['mileage'] ?? '';
_networkController.text = data['network'] ?? '';
} catch (_) {
// Ignore corrupt draft data
} finally {
_restoringDraft = false;
}
}
List<Map<String, dynamic>> _serializeTractionItems() {
return _tractionItems
.map(
(item) => {
"isMarker": item.isMarker,
"powering": item.powering,
"loco": item.loco == null
? null
: {
"id": item.loco!.id,
"type": item.loco!.type,
"number": item.loco!.number,
"class": item.loco!.locoClass,
"name": item.loco!.name,
"operator": item.loco!.operator,
"notes": item.loco!.notes,
"evn": item.loco!.evn,
},
},
)
.toList();
}
void _restoreTractionItems(List<Map<String, dynamic>> items) {
final restored = <_TractionItem>[];
for (final item in items) {
final locoData = item['loco'] as Map<String, dynamic>?;
LocoSummary? loco;
if (locoData != null) {
loco = LocoSummary(
locoId: locoData['id'] ?? 0,
locoType: locoData['type'] ?? '',
locoNumber: locoData['number'] ?? '',
locoName: locoData['name'] ?? '',
locoClass: locoData['class'] ?? '',
locoOperator: locoData['operator'] ?? '',
locoNotes: locoData['notes'],
locoEvn: locoData['evn'],
);
}
restored.add(
_TractionItem(
loco: loco,
powering: item['powering'] ?? true,
isMarker: item['isMarker'] ?? false,
),
);
}
if (restored.where((e) => e.isMarker).isEmpty) {
restored.insert(0, _TractionItem.marker());
}
_tractionItems
..clear()
..addAll(restored);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isMobile = MediaQuery.of(context).size.width < 700; final isMobile = MediaQuery.of(context).size.width < 700;
@@ -404,17 +606,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
_buildTractionList(), _buildTractionList(),
]); ]);
final mileagePanel = _section('Mileage', [ final mileagePanel = _section(
SwitchListTile( 'Mileage',
title: const Text('Use manual mileage'), [
subtitle: const Text('Turn on to enter mileage manually'),
value: _useManualMileage,
onChanged: (val) {
setState(() {
_useManualMileage = val;
});
},
),
if (_useManualMileage) if (_useManualMileage)
TextFormField( TextFormField(
controller: _mileageController, controller: _mileageController,
@@ -443,7 +637,16 @@ class _NewEntryPageState extends State<NewEntryPage> {
label: const Text('Open mileage calculator'), label: const Text('Open mileage calculator'),
), ),
), ),
]); ],
trailing: FilterChip(
label: Text(_useManualMileage ? 'Manual' : 'Automatic'),
selected: _useManualMileage,
onSelected: (val) {
setState(() => _useManualMileage = val);
_saveDraft();
},
),
);
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -469,6 +672,14 @@ class _NewEntryPageState extends State<NewEntryPage> {
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _submitting
? null
: () => _resetFormState(clearDraft: true),
icon: const Icon(Icons.clear),
label: const Text('Clear form'),
),
const SizedBox(height: 8),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _submitting ? null : _submit, onPressed: _submitting ? null : _submit,
icon: _submitting icon: _submitting
@@ -506,6 +717,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
final item = _tractionItems.removeAt(oldIndex); final item = _tractionItems.removeAt(oldIndex);
_tractionItems.insert(newIndex, item); _tractionItems.insert(newIndex, item);
}); });
_saveDraft();
}, },
itemCount: _tractionItems.length, itemCount: _tractionItems.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -549,6 +761,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
setState(() { setState(() {
_tractionItems[index] = item.copyWith(powering: v); _tractionItems[index] = item.copyWith(powering: v);
}); });
_saveDraft();
}, },
), ),
IconButton( IconButton(
@@ -557,6 +770,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
setState(() { setState(() {
_tractionItems.removeAt(index); _tractionItems.removeAt(index);
}); });
_saveDraft();
}, },
), ),
], ],
@@ -567,12 +781,15 @@ class _NewEntryPageState extends State<NewEntryPage> {
); );
} }
Widget _section(String title, List<Widget> children) { Widget _section(String title, List<Widget> children, {Widget? trailing}) {
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
title, title,
@@ -580,6 +797,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
context, context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
), ),
if (trailing != null) trailing,
],
),
const SizedBox(height: 8), const SizedBox(height: 8),
...children.map( ...children.map(
(w) => Padding( (w) => Padding(

View File

@@ -23,22 +23,15 @@ class _TractionPageState extends State<TractionPage> {
final _classController = TextEditingController(); final _classController = TextEditingController();
final _classFocusNode = FocusNode(); final _classFocusNode = FocusNode();
final _numberController = TextEditingController(); final _numberController = TextEditingController();
final _nameController = TextEditingController();
bool _mileageFirst = true; bool _mileageFirst = true;
bool _initialised = false; bool _initialised = false;
bool _showAdvancedFilters = false; bool _showAdvancedFilters = false;
String? _selectedClass; String? _selectedClass;
late Set<String> _selectedKeys; 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; final Map<String, TextEditingController> _dynamicControllers = {};
final Map<String, String?> _enumSelections = {};
@override @override
void initState() { void initState() {
@@ -53,7 +46,9 @@ class _TractionPageState extends State<TractionPage> {
_initialised = true; _initialised = true;
_selectedKeys = {...widget.selectedKeys}; _selectedKeys = {...widget.selectedKeys};
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<DataService>().fetchClassList(); final data = context.read<DataService>();
data.fetchClassList();
data.fetchEventFields();
_refreshTraction(); _refreshTraction();
}); });
} }
@@ -66,47 +61,41 @@ class _TractionPageState extends State<TractionPage> {
_classFocusNode.dispose(); _classFocusNode.dispose();
_numberController.dispose(); _numberController.dispose();
_nameController.dispose(); _nameController.dispose();
_operatorController.dispose(); for (final controller in _dynamicControllers.values) {
_statusController.dispose(); controller.dispose();
_evnController.dispose(); }
_ownerController.dispose();
_locationController.dispose();
_liveryController.dispose();
_domainController.dispose();
_typeController.dispose();
super.dispose(); super.dispose();
} }
bool get _hasFilters { bool get _hasFilters {
final dynamicFieldsUsed = _dynamicControllers.values
.any((controller) => controller.text.trim().isNotEmpty) ||
_enumSelections.values
.any((value) => (value ?? '').toString().trim().isNotEmpty);
return [ return [
_selectedClass, _selectedClass,
_classController.text, _classController.text,
_numberController.text, _numberController.text,
_nameController.text, _nameController.text,
_operatorController.text, ].any((value) => (value ?? '').toString().trim().isNotEmpty) ||
_statusController.text, dynamicFieldsUsed;
_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 { Future<void> _refreshTraction({bool append = false}) async {
final data = context.read<DataService>(); final data = context.read<DataService>();
final filters = { final filters = <String, dynamic>{};
"name": _nameController.text.trim(), final name = _nameController.text.trim();
"operator": _operatorController.text.trim(), if (name.isNotEmpty) filters['name'] = name;
"status": _statusController.text.trim(), _dynamicControllers.forEach((key, controller) {
"evn": _evnController.text.trim(), final value = controller.text.trim();
"owner": _ownerController.text.trim(), if (value.isNotEmpty) filters[key] = value;
"location": _locationController.text.trim(), });
"livery": _liveryController.text.trim(), _enumSelections.forEach((key, value) {
"domain": _domainController.text.trim(), if (value != null && value.toString().trim().isNotEmpty) {
"type": _typeController.text.trim(), filters[key] = value;
}..removeWhere((key, value) => value.isEmpty); }
});
final hadOnly = !_hasFilters; final hadOnly = !_hasFilters;
await data.fetchTraction( await data.fetchTraction(
hadOnly: hadOnly, hadOnly: hadOnly,
@@ -120,21 +109,13 @@ class _TractionPageState extends State<TractionPage> {
} }
void _clearFilters() { void _clearFilters() {
for (final controller in [ for (final controller in [_classController, _numberController, _nameController]) {
_classController,
_numberController,
_nameController,
_operatorController,
_statusController,
_evnController,
_ownerController,
_locationController,
_liveryController,
_domainController,
_typeController,
]) {
controller.clear(); controller.clear();
} }
for (final controller in _dynamicControllers.values) {
controller.clear();
}
_enumSelections.clear();
setState(() { setState(() {
_selectedClass = null; _selectedClass = null;
_mileageFirst = true; _mileageFirst = true;
@@ -151,12 +132,34 @@ class _TractionPageState extends State<TractionPage> {
} }
} }
List<EventField> _activeEventFields(List<EventField> fields) {
return fields
.where(
(field) =>
!['class', 'number', 'name', 'build date', 'build_date']
.contains(field.name.toLowerCase()),
)
.toList();
}
void _ensureControllersForFields(List<EventField> fields) {
for (final field in fields) {
if (field.enumValues != null) {
_enumSelections.putIfAbsent(field.name, () => null);
} else {
_dynamicControllers.putIfAbsent(field.name, () => TextEditingController());
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final traction = data.traction; final traction = data.traction;
final classOptions = data.locoClasses; final classOptions = data.locoClasses;
final isMobile = MediaQuery.of(context).size.width < 700; final isMobile = MediaQuery.of(context).size.width < 700;
_ensureControllersForFields(data.eventFields);
final extraFields = _activeEventFields(data.eventFields);
final listView = RefreshIndicator( final listView = RefreshIndicator(
onRefresh: _refreshTraction, onRefresh: _refreshTraction,
@@ -225,12 +228,7 @@ class _TractionPageState extends State<TractionPage> {
); );
}, },
fieldViewBuilder: fieldViewBuilder:
( (context, controller, focusNode, onFieldSubmitted) {
context,
controller,
focusNode,
onFieldSubmitted,
) {
return TextField( return TextField(
controller: controller, controller: controller,
focusNode: focusNode, focusNode: focusNode,
@@ -325,9 +323,7 @@ class _TractionPageState extends State<TractionPage> {
: Icons.expand_more, : Icons.expand_more,
), ),
label: Text( label: Text(
_showAdvancedFilters _showAdvancedFilters ? 'Hide filters' : 'More filters',
? 'Hide filters'
: 'More filters',
), ),
), ),
ElevatedButton.icon( ElevatedButton.icon(
@@ -344,99 +340,27 @@ class _TractionPageState extends State<TractionPage> {
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
firstChild: Padding( firstChild: Padding(
padding: const EdgeInsets.only(top: 12.0), padding: const EdgeInsets.only(top: 12.0),
child: Wrap( child: data.isEventFieldsLoading
? const Center(
child: Padding(
padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: extraFields.isEmpty
? const Text('No extra filters available right now.')
: Wrap(
spacing: 12, spacing: 12,
runSpacing: 12, runSpacing: 12,
children: [ children: extraFields
SizedBox( .map(
width: isMobile ? double.infinity : 220, (field) => _buildFilterInput(
child: TextField( context,
controller: _operatorController, field,
decoration: const InputDecoration( isMobile,
labelText: 'Operator',
border: OutlineInputBorder(),
), ),
onSubmitted: (_) => _refreshTraction(), )
), .toList(),
),
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(), secondChild: const SizedBox.shrink(),
@@ -480,9 +404,8 @@ class _TractionPageState extends State<TractionPage> {
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: data.isTractionLoading onPressed:
? null data.isTractionLoading ? null : () => _refreshTraction(append: true),
: () => _refreshTraction(append: true),
icon: data.isTractionLoading icon: data.isTractionLoading
? const SizedBox( ? const SizedBox(
height: 14, height: 14,
@@ -535,6 +458,7 @@ class _TractionPageState extends State<TractionPage> {
final status = loco.status ?? 'Unknown'; final status = loco.status ?? 'Unknown';
final operatorName = loco.operator ?? ''; final operatorName = loco.operator ?? '';
final domain = loco.domain ?? ''; final domain = loco.domain ?? '';
final statusColors = _statusChipColors(context, status);
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
@@ -564,9 +488,8 @@ class _TractionPageState extends State<TractionPage> {
), ),
Chip( Chip(
label: Text(status), label: Text(status),
backgroundColor: Theme.of( backgroundColor: statusColors.$1,
context, labelStyle: TextStyle(color: statusColors.$2),
).colorScheme.surfaceContainerHighest,
), ),
], ],
), ),
@@ -654,6 +577,44 @@ class _TractionPageState extends State<TractionPage> {
); );
} }
(Color, Color) _statusChipColors(BuildContext context, String status) {
final scheme = Theme.of(context).colorScheme;
final isDark = scheme.brightness == Brightness.dark;
Color blend(Color base, {double bgOpacity = 0.18, double fgOpacity = 0.82}) {
final bg = Color.alphaBlend(
base.withOpacity(isDark ? bgOpacity + 0.07 : bgOpacity),
scheme.surface,
);
final fg = Color.alphaBlend(
base.withOpacity(isDark ? fgOpacity : fgOpacity * 0.8),
scheme.onSurface,
);
return Color.lerp(bg, fg, 0.0) ?? bg;
}
Color background;
Color foreground;
final key = status.toLowerCase();
if (key.contains('scrap')) {
background = blend(Colors.red);
foreground = Colors.red.shade200.withOpacity(isDark ? 0.85 : 0.9);
} else if (key.contains('active')) {
background = blend(scheme.primary);
foreground = scheme.primary.withOpacity(isDark ? 0.9 : 0.8);
} else if (key.contains('withdrawn')) {
background = blend(Colors.amber);
foreground = Colors.amber.shade800.withOpacity(isDark ? 0.9 : 0.8);
} else if (key.contains('stored') || key.contains('unknown')) {
background = blend(Colors.grey);
foreground = Colors.grey.shade700.withOpacity(isDark ? 0.85 : 0.75);
} else {
background = scheme.surfaceContainerHighest;
foreground = scheme.onSurface;
}
return (background, foreground);
}
Future<void> _showLocoInfo(LocoSummary loco) async { Future<void> _showLocoInfo(LocoSummary loco) async {
await showModalBottomSheet( await showModalBottomSheet(
context: context, context: context,
@@ -750,4 +711,68 @@ class _TractionPageState extends State<TractionPage> {
if (value == null) return '0'; if (value == null) return '0';
return value.toStringAsFixed(1); return value.toStringAsFixed(1);
} }
Widget _buildFilterInput(
BuildContext context,
EventField field,
bool isMobile,
) {
final width = isMobile ? double.infinity : 220.0;
if (field.enumValues != null && field.enumValues!.isNotEmpty) {
final options = field.enumValues!.map((e) => e.toString()).toSet().toList();
final currentValue = _enumSelections[field.name];
final safeValue = options.contains(currentValue) ? currentValue : null;
return SizedBox(
width: width,
child: DropdownButtonFormField<String?>(
value: safeValue,
decoration: InputDecoration(
labelText: field.display,
border: const OutlineInputBorder(),
),
items: [
const DropdownMenuItem(value: null, child: Text('Any')),
...options
.map(
(value) => DropdownMenuItem(
value: value,
child: Text(value),
),
)
.toList(),
],
onChanged: (val) {
setState(() {
_enumSelections[field.name] = val;
});
_refreshTraction();
},
),
);
}
final controller =
_dynamicControllers[field.name] ?? TextEditingController();
_dynamicControllers[field.name] = controller;
TextInputType? inputType;
if (field.type != null) {
final type = field.type!.toLowerCase();
if (type.contains('int') || type.contains('num') || type.contains('double')) {
inputType = const TextInputType.numberWithOptions(decimal: true);
}
}
return SizedBox(
width: width,
child: TextField(
controller: controller,
keyboardType: inputType,
decoration: InputDecoration(
labelText: field.display,
border: const OutlineInputBorder(),
),
onSubmitted: (_) => _refreshTraction(),
),
);
}
} }

View File

@@ -269,8 +269,15 @@ class _TripsPageState extends State<TripsPage> {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (_) { builder: (_) {
final data = context.read<DataService>();
return SafeArea( return SafeArea(
child: Padding( child: FutureBuilder<List<TripLocoStat>>(
future: data.fetchTripLocoStats(trip.id),
builder: (ctx, snapshot) {
final items = snapshot.data ?? [];
final loading =
snapshot.connectionState == ConnectionState.waiting;
return Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -284,29 +291,54 @@ class _TripsPageState extends State<TripsPage> {
), ),
Text( Text(
trip.name, trip.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context)
fontWeight: FontWeight.bold, .textTheme
), .titleMedium
?.copyWith(fontWeight: FontWeight.bold),
), ),
const Spacer(), const Spacer(),
Text('${trip.mileage.toStringAsFixed(1)} mi'), Text('${trip.mileage.toStringAsFixed(1)} mi'),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (loading)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: CircularProgressIndicator(),
),
)
else if (items.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16.0),
child: Text('No traction recorded for this trip yet.'),
)
else
SizedBox( SizedBox(
height: MediaQuery.of(context).size.height * 0.6, height: MediaQuery.of(context).size.height * 0.6,
child: ListView.builder( child: ListView.builder(
itemCount: trip.legs.length, itemCount: items.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final leg = trip.legs[index]; final loco = items[index];
final won = loco.won;
final isWon = won == true;
return ListTile( return ListTile(
leading: const Icon(Icons.train), leading: const Icon(Icons.train),
title: Text('${leg.start} ${leg.end}'), title: Text('${loco.locoClass} ${loco.number}'),
subtitle: Text(_formatDate(leg.beginTime)), subtitle:
trailing: Text( loco.name == null || loco.name!.isEmpty
leg.mileage?.toStringAsFixed(1) ?? '-', ? null
style: Theme.of(context).textTheme.labelLarge : Text(loco.name!),
?.copyWith(fontWeight: FontWeight.bold), trailing: Chip(
label: Text(isWon ? 'Won' : 'Dud'),
backgroundColor: isWon
? Colors.green.shade100
: Colors.grey.shade300,
labelStyle: TextStyle(
color: isWon
? Colors.green.shade900
: Colors.grey.shade800,
),
), ),
); );
}, },
@@ -314,6 +346,8 @@ class _TripsPageState extends State<TripsPage> {
), ),
], ],
), ),
);
},
), ),
); );
}, },

View File

@@ -37,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>(
@@ -194,6 +195,7 @@ class _MyHomePageState extends State<MyHomePage> {
api.setTokenProvider(() => auth.token); api.setTokenProvider(() => auth.token);
await auth.tryRestoreSession(); await auth.tryRestoreSession();
if (!auth.isLoggedIn) return; if (!auth.isLoggedIn) return;
data.fetchEventFields();
if (data.homepageStats == null) { if (data.homepageStats == null) {
data.fetchHomepageStats(); data.fetchHomepageStats();
} }

View File

@@ -397,3 +397,56 @@ class TripDetail {
[], [],
); );
} }
class TripLocoStat {
final String locoClass;
final String number;
final String? name;
final bool won;
TripLocoStat({
required this.locoClass,
required this.number,
required this.won,
this.name,
});
factory TripLocoStat.fromJson(Map<String, dynamic> json) => TripLocoStat(
locoClass: json['loco_class'] ?? json['class'] ?? '',
number: json['loco_number'] ?? json['number'] ?? '',
name: json['loco_name'] ?? json['name'],
won: json['won'] == 1 ||
json['won'] == true ||
(json['won'] is String && json['won'].toString() == '1'),
);
}
class EventField {
final String name;
final String display;
final String? type;
final List<String>? enumValues;
const EventField({
required this.name,
required this.display,
this.type,
this.enumValues,
});
factory EventField.fromJson(Map<String, dynamic> json) {
final enumList = json['enum'];
List<String>? enumValues;
if (enumList is List) {
enumValues = enumList.map((e) => e.toString()).toList();
}
final baseName = json['name']?.toString() ?? json['field']?.toString() ?? '';
final display = json['field']?.toString() ?? baseName;
return EventField(
name: baseName,
display: display,
type: json['type']?.toString(),
enumValues: enumValues,
);
}
}

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};
@@ -85,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,13 +1,14 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.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';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:mileograph_flutter/services/tokenStorageService.dart';
class AuthService extends ChangeNotifier { class AuthService extends ChangeNotifier {
final ApiService api; final ApiService api;
static const _tokenKey = 'auth_token';
bool _restoring = false; bool _restoring = false;
final TokenStorageService _tokenStorage = TokenStorageService();
AuthService({required this.api}); AuthService({required this.api});
AuthenticatedUserData? _user; AuthenticatedUserData? _user;
@@ -73,9 +74,10 @@ class AuthService extends ChangeNotifier {
if (_restoring || _user != null) return; if (_restoring || _user != null) return;
_restoring = true; _restoring = true;
try { try {
final prefs = await SharedPreferences.getInstance(); // read token from secure storage (with fallback)
final token = prefs.getString(_tokenKey); final token = await _tokenStorage.getToken();
if (token == null || token.isEmpty) return; if (token == null || token.isEmpty) return;
final userResponse = await api.get( final userResponse = await api.get(
'/users/me', '/users/me',
headers: { headers: {
@@ -83,6 +85,7 @@ class AuthService extends ChangeNotifier {
'accept': 'application/json', 'accept': 'application/json',
}, },
); );
setLoginData( setLoginData(
userId: userResponse['user_id'], userId: userResponse['user_id'],
username: userResponse['username'], username: userResponse['username'],
@@ -98,13 +101,11 @@ class AuthService extends ChangeNotifier {
} }
Future<void> _persistToken(String token) async { Future<void> _persistToken(String token) async {
final prefs = await SharedPreferences.getInstance(); await _tokenStorage.setToken(token);
await prefs.setString(_tokenKey, token);
} }
Future<void> _clearToken() async { Future<void> _clearToken() async {
final prefs = await SharedPreferences.getInstance(); await _tokenStorage.clearToken();
await prefs.remove(_tokenKey);
} }
Future<void> register({ Future<void> register({
@@ -126,9 +127,13 @@ class AuthService extends ChangeNotifier {
await api.postForm('/register', formData); await api.postForm('/register', formData);
} }
void logout() { Future<void> handleTokenExpired() async {
_user = null; _user = null;
_clearToken(); await _clearToken();
notifyListeners(); notifyListeners();
} }
void logout() {
handleTokenExpired(); // reuse
}
} }

View File

@@ -61,6 +61,10 @@ class DataService extends ChangeNotifier {
List<String> get locoClasses => _locoClasses; List<String> get locoClasses => _locoClasses;
List<TripSummary> _tripList = []; List<TripSummary> _tripList = [];
List<TripSummary> get tripList => _tripList; List<TripSummary> get tripList => _tripList;
List<EventField> _eventFields = [];
List<EventField> get eventFields => _eventFields;
bool _isEventFieldsLoading = false;
bool get isEventFieldsLoading => _isEventFieldsLoading;
// Station Data // Station Data
List<Station>? _cachedStations; List<Station>? _cachedStations;
@@ -73,6 +77,17 @@ class DataService extends ChangeNotifier {
bool _isOnThisDayLoading = false; bool _isOnThisDayLoading = false;
bool get isOnThisDayLoading => _isOnThisDayLoading; bool get isOnThisDayLoading => _isOnThisDayLoading;
static const List<EventField> _fallbackEventFields = [
EventField(name: 'operator', display: 'Operator'),
EventField(name: 'status', display: 'Status'),
EventField(name: 'evn', display: 'EVN'),
EventField(name: 'owner', display: 'Owner'),
EventField(name: 'location', display: 'Location'),
EventField(name: 'livery', display: 'Livery'),
EventField(name: 'domain', display: 'Domain'),
EventField(name: 'type', display: 'Type'),
];
void _notifyAsync() { void _notifyAsync() {
// Always defer to the next frame to avoid setState during build. // Always defer to the next frame to avoid setState during build.
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
@@ -260,6 +275,75 @@ class DataService extends ChangeNotifier {
} }
} }
Future<List<TripLocoStat>> fetchTripLocoStats(int tripId) async {
try {
final json = await api.get('/trips/stats?trip_id=$tripId');
if (json is List) {
return json
.whereType<Map<String, dynamic>>()
.map((e) => TripLocoStat.fromJson(e))
.toList();
}
if (json is Map && json['locos'] is List) {
return (json['locos'] as List)
.whereType<Map<String, dynamic>>()
.map((e) => TripLocoStat.fromJson(e))
.toList();
}
return [];
} catch (e) {
debugPrint('Failed to fetch trip loco stats: $e');
return [];
}
}
Future<void> fetchEventFields({bool force = false}) async {
if (_eventFields.isNotEmpty && !force) return;
_isEventFieldsLoading = true;
_notifyAsync();
try {
final json = await api.get('/event/fields');
List<EventField> fields = _parseEventFields(json);
if (fields.isEmpty) {
fields = _fallbackEventFields;
}
_eventFields = fields;
} catch (e) {
debugPrint('Failed to fetch event fields: $e');
_eventFields = _fallbackEventFields;
} finally {
_isEventFieldsLoading = false;
_notifyAsync();
}
}
List<EventField> _parseEventFields(dynamic json) {
if (json is List) {
return json
.whereType<Map<String, dynamic>>()
.map(EventField.fromJson)
.toList();
}
if (json is Map) {
if (json['fields'] is List) {
return (json['fields'] as List)
.whereType<Map<String, dynamic>>()
.map(EventField.fromJson)
.toList();
}
// If map of name -> definition
return json.entries
.where((entry) => entry.value is Map<String, dynamic>)
.map((entry) {
final map = Map<String, dynamic>.from(entry.value);
map['name'] = entry.key;
return EventField.fromJson(map);
})
.toList();
}
return [];
}
Future<void> fetchTrips() async { Future<void> fetchTrips() async {
try { try {
final json = await api.get('/trips/mileage'); final json = await api.get('/trips/mileage');
@@ -314,6 +398,7 @@ class DataService extends ChangeNotifier {
_onThisDay = []; _onThisDay = [];
_trips = []; _trips = [];
_tripDetails = []; _tripDetails = [];
_eventFields = [];
_notifyAsync(); _notifyAsync();
} }

View File

@@ -0,0 +1,57 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Stores the auth token in secure storage and falls back to SharedPreferences
/// so debug builds and platforms without a working keyring still persist.
class TokenStorageService {
TokenStorageService._internal();
static final TokenStorageService _instance = TokenStorageService._internal();
factory TokenStorageService() => _instance;
static const _tokenKey = 'auth_token';
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
Future<SharedPreferences> get _prefs async =>
await SharedPreferences.getInstance();
Future<void> setToken(String token) async {
try {
await _secureStorage.write(key: _tokenKey, value: token);
} catch (_) {
// ignore secure storage failures in debug/unsupported environments
}
final prefs = await _prefs;
await prefs.setString(_tokenKey, token);
}
Future<String?> getToken() async {
try {
final secured = await _secureStorage.read(key: _tokenKey);
if (secured != null && secured.isNotEmpty) {
return secured;
}
} catch (_) {
// ignore and fall back
}
final prefs = await _prefs;
final token = prefs.getString(_tokenKey);
return (token == null || token.isEmpty) ? null : token;
}
Future<void> clearToken() async {
try {
await _secureStorage.delete(key: _tokenKey);
} catch (_) {
// ignore
}
final prefs = await _prefs;
await prefs.remove(_tokenKey);
}
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,9 +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 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")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
} }

View File

@@ -94,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
@@ -216,6 +264,30 @@ 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: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@@ -405,6 +477,14 @@ 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: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View File

@@ -35,6 +35,7 @@ dependencies:
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