Compare commits

...

78 Commits

Author SHA1 Message Date
d8bcde1312 Add support for file uploads using new async upload jobs, add admin section for uploading distance files
All checks were successful
Release / meta (push) Successful in 3s
Release / linux-build (push) Successful in 59s
Release / web-build (push) Successful in 1m24s
Release / android-build (push) Successful in 6m13s
Release / release-master (push) Successful in 8s
Release / release-dev (push) Successful in 13s
2026-01-27 21:43:34 +00:00
45bd872b23 add support for network calculation from the calculator
All checks were successful
Release / meta (push) Successful in 1m39s
Release / linux-build (push) Successful in 1m55s
Release / web-build (push) Successful in 3m12s
Release / android-build (push) Successful in 6m48s
Release / release-master (push) Successful in 22s
Release / release-dev (push) Successful in 28s
2026-01-27 00:41:27 +00:00
94adf06726 add filter by network for legs, add full export for traction
All checks were successful
Release / meta (push) Successful in 1m45s
Release / linux-build (push) Successful in 1m48s
Release / web-build (push) Successful in 1m56s
Release / android-build (push) Successful in 7m33s
Release / release-dev (push) Successful in 30s
Release / release-master (push) Successful in 5s
2026-01-26 15:57:34 +00:00
8340501f37 make carousel more stable
All checks were successful
Release / meta (push) Successful in 4s
Release / linux-build (push) Successful in 1m14s
Release / web-build (push) Successful in 1m30s
Release / android-build (push) Successful in 5m52s
Release / release-dev (push) Successful in 7s
Release / release-master (push) Successful in 4s
2026-01-23 18:24:34 +00:00
a527ecdb17 add logo to login page 2026-01-23 18:04:40 +00:00
f3fcf07b05 add refresh token support 2026-01-23 17:55:55 +00:00
9896b6f1f8 add traction import export
All checks were successful
Release / meta (push) Successful in 3s
Release / linux-build (push) Successful in 1m1s
Release / web-build (push) Successful in 1m22s
Release / android-build (push) Successful in 6m49s
Release / release-master (push) Successful in 5s
Release / release-dev (push) Successful in 8s
2026-01-23 13:21:08 +00:00
56cc7c0902 fix mileograph tag issue
All checks were successful
Release / meta (push) Successful in 2s
Release / linux-build (push) Successful in 1m36s
Release / web-build (push) Successful in 1m18s
Release / android-build (push) Successful in 6m41s
Release / release-master (push) Successful in 15s
Release / release-dev (push) Successful in 20s
2026-01-23 00:25:47 +00:00
917d020ef5 fix release pipelines
Some checks failed
Release / meta (push) Has been cancelled
Release / android-build (push) Has been cancelled
Release / linux-build (push) Has been cancelled
Release / web-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2026-01-23 00:22:56 +00:00
d8312a3f1b Added class clearance fixes and improvements
Some checks failed
Release / meta (push) Successful in 18s
Release / android-build (push) Successful in 11m48s
Release / linux-build (push) Successful in 1m24s
Release / web-build (push) Successful in 6m15s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2026-01-22 23:17:15 +00:00
559f79b805 add transfer all button for admins
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 1m4s
Release / web-build (push) Successful in 2m47s
Release / android-build (push) Successful in 11m45s
Release / release-master (push) Successful in 26s
Release / release-dev (push) Successful in 30s
2026-01-12 17:11:37 +00:00
3b7ec31e5d add refresh button to pending page
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 50s
Release / web-build (push) Successful in 2m23s
Release / android-build (push) Successful in 7m48s
Release / release-master (push) Successful in 18s
Release / release-dev (push) Successful in 20s
2026-01-12 16:37:01 +00:00
e9b328e7e6 improve pending visibility, allow multiple users to have pending changes against locos. 2026-01-12 16:16:36 +00:00
45042b5001 new pending visibility
Some checks failed
Release / meta (push) Successful in 8s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / android-build (push) Has been cancelled
Release / linux-build (push) Has been cancelled
Release / web-build (push) Has been cancelled
2026-01-12 16:03:47 +00:00
5c0043146f minor page tweaks 2026-01-12 15:30:29 +00:00
91f5391684 fix edit widget issues 2026-01-12 15:00:09 +00:00
f06a1c75b6 Fix transfer functoin, add display for numer of pending locos
All checks were successful
Release / meta (push) Successful in 12s
Release / linux-build (push) Successful in 1m10s
Release / web-build (push) Successful in 1m19s
Release / android-build (push) Successful in 9m2s
Release / release-master (push) Successful in 17s
Release / release-dev (push) Successful in 19s
2026-01-06 14:44:12 +00:00
5b94ab263b move transfer button, hide delete button better
All checks were successful
Release / meta (push) Successful in 28s
Release / linux-build (push) Successful in 54s
Release / web-build (push) Successful in 4m48s
Release / android-build (push) Successful in 15m12s
Release / release-master (push) Successful in 31s
Release / release-dev (push) Successful in 34s
2026-01-06 12:04:34 +00:00
06bed86a49 Add accent colour picker, fix empty user card when accepting friend request, add button to transfer allocations
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 56s
Release / web-build (push) Successful in 2m15s
Release / android-build (push) Successful in 6m47s
Release / release-master (push) Successful in 19s
Release / release-dev (push) Successful in 21s
2026-01-06 00:21:19 +00:00
d5083e1cc7 add ability for non admins to add new traction, pending approval. Various QoL updates
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 57s
Release / web-build (push) Successful in 1m14s
Release / android-build (push) Successful in 5m33s
Release / release-master (push) Successful in 18s
Release / release-dev (push) Successful in 20s
2026-01-05 22:11:02 +00:00
a755644c31 revert web build changes
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 1m2s
Release / web-build (push) Successful in 1m19s
Release / android-build (push) Successful in 5m12s
Release / release-dev (push) Successful in 20s
Release / release-master (push) Successful in 19s
2026-01-05 01:30:52 +00:00
a14faeedbe update web build
Some checks failed
Release / meta (push) Successful in 6s
Release / web-build (push) Failing after 35s
Release / linux-build (push) Successful in 46s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-05 01:21:40 +00:00
8ab3f53c0d Add accepted leg edit notification and class leaderboard
All checks were successful
Release / meta (push) Successful in 12s
Release / linux-build (push) Successful in 1m0s
Release / web-build (push) Successful in 2m6s
Release / android-build (push) Successful in 6m8s
Release / release-master (push) Successful in 16s
Release / release-dev (push) Successful in 19s
2026-01-05 01:09:43 +00:00
42ac7a97e1 add profile page, privacy options
All checks were successful
Release / meta (push) Successful in 11s
Release / linux-build (push) Successful in 1m0s
Release / web-build (push) Successful in 2m29s
Release / android-build (push) Successful in 10m26s
Release / release-master (push) Successful in 37s
Release / release-dev (push) Successful in 49s
2026-01-04 19:50:06 +00:00
af37e25692 fix saving draft with shared user, display user shared to/from in expanded leg card
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 58s
Release / web-build (push) Successful in 2m0s
Release / android-build (push) Successful in 9m58s
Release / release-master (push) Successful in 37s
Release / release-dev (push) Successful in 44s
2026-01-03 23:26:33 +00:00
Jack
1689869ce5 Update release.yml
Some checks failed
Release / android-build (push) Blocked by required conditions
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 58s
Release / web-build (push) Successful in 1m18s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2026-01-03 22:58:02 +00:00
Jack
425ab46656 Update release.yml
Some checks failed
Release / meta (push) Successful in 7s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / android-build (push) Has been cancelled
Release / web-build (push) Has been cancelled
Release / linux-build (push) Has been cancelled
2026-01-03 22:57:32 +00:00
196511dfab unify pipeline, load friends leaderboard from homepage data
Some checks failed
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 57s
Release / web-build (push) Failing after 1m11s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-03 22:55:03 +00:00
Jack
23f294c6f0 Update README.md
All checks were successful
Release / meta (push) Successful in 14s
Release / linux-build (push) Successful in 1m9s
Release / web-build (push) Successful in 1m15s
Release / android-build (push) Successful in 5m14s
Release / release-dev (push) Successful in 17s
2026-01-03 22:48:36 +00:00
Jack
8e1bd05040 Update dev.yml
Some checks failed
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 46s
Release / web-build (push) Successful in 2m11s
Release / release-dev (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-03 22:44:19 +00:00
Jack
2b8cb8342a Update dev.yml
Some checks failed
Release / meta (push) Successful in 21s
Release / web-build (push) Failing after 1m10s
Release / linux-build (push) Successful in 1m13s
Release / release-dev (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-03 22:39:29 +00:00
Jack
f8bc962c84 Update dev.yml
Some checks failed
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 46s
Release / web-build (push) Failing after 1m46s
Release / release-dev (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-03 22:34:09 +00:00
Jack
0650a140c3 Update dev.yml
Some checks failed
Release / meta (push) Successful in 17s
Release / linux-build (push) Successful in 44s
Release / web-build (push) Failing after 2m4s
Release / release-dev (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-03 22:31:28 +00:00
Jack
7d4db0af5f Update dev.yml
Some checks failed
Release / meta (push) Successful in 3s
Release / linux-build (push) Successful in 50s
Release / web-build (push) Failing after 1m3s
Release / android-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
2026-01-03 22:27:24 +00:00
Jack
a4ab84e9a9 Update dev.yml
Some checks failed
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 44s
Release / web-build (push) Failing after 2m4s
Release / release-dev (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-03 22:23:03 +00:00
Jack
e3f35cf1a0 Update dev.yml
Some checks failed
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 1m2s
Release / web-build (push) Failing after 1m16s
Release / release-dev (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-03 22:19:53 +00:00
Jack
b5fc68c6f6 Update dev.yml
Some checks failed
Release / meta (push) Successful in 21s
Release / linux-build (push) Successful in 1m8s
Release / web-build (push) Failing after 2m35s
Release / release-dev (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-03 22:11:00 +00:00
Jack
30fab18946 Testing the build flow
Some checks failed
Release / meta (push) Successful in 2s
Release / android-build (push) Successful in 5m19s
Release / linux-build (push) Successful in 5m57s
Release / web-build (push) Failing after 7m30s
Release / release-dev (push) Has been skipped
2026-01-03 21:48:26 +00:00
ff38c3f838 fix leaderboard formatting, save shared users to drafts, display shared legs
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m59s
Release / web-build (push) Successful in 6m37s
Release / android-build (push) Successful in 18m3s
Release / release-master (push) Successful in 23s
Release / release-dev (push) Successful in 25s
2026-01-03 14:14:31 +00:00
69bd6f688a Add friends leaderboard, reverse button in add page
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 7m11s
Release / web-build (push) Successful in 5m3s
Release / android-build (push) Successful in 18m23s
Release / release-master (push) Successful in 22s
Release / release-dev (push) Successful in 25s
2026-01-03 13:22:43 +00:00
89b760508d Add new friends system, and sharing legs support
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 6m37s
Release / web-build (push) Successful in 5m29s
Release / android-build (push) Successful in 15m58s
Release / release-master (push) Successful in 20s
Release / release-dev (push) Successful in 26s
2026-01-03 01:07:08 +00:00
42af39b442 fix issue with version and app id
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 6m42s
Release / web-build (push) Successful in 5m38s
Release / android-build (push) Successful in 15m17s
Release / release-master (push) Successful in 24s
Release / release-dev (push) Successful in 28s
2026-01-02 19:10:32 +00:00
2fa66ff9c6 add admin page, fix no legs infinite load on dashboard
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m49s
Release / web-build (push) Successful in 6m23s
Release / android-build (push) Successful in 16m56s
Release / release-master (push) Successful in 30s
Release / release-dev (push) Successful in 32s
2026-01-02 18:49:14 +00:00
29cecf0ded add android bundle release
All checks were successful
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 6m32s
Release / web-build (push) Successful in 5m50s
Release / android-build (push) Successful in 20m43s
Release / release-master (push) Successful in 26s
Release / release-dev (push) Successful in 28s
2026-01-02 14:34:11 +00:00
f9c392bb07 new app icon
All checks were successful
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 7m21s
Release / web-build (push) Successful in 7m30s
Release / android-build (push) Successful in 19m56s
Release / release-dev (push) Successful in 25s
Release / release-master (push) Successful in 24s
2026-01-02 00:18:57 +00:00
e5b145b4b2 increment verison
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 6m28s
Release / web-build (push) Successful in 6m54s
Release / android-build (push) Successful in 18m9s
Release / release-master (push) Successful in 38s
Release / release-dev (push) Successful in 41s
2026-01-01 23:13:18 +00:00
59458484aa add build step for flutter web, add persistent pagination in the traction list
Some checks failed
Release / meta (push) Successful in 14s
Release / web-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / linux-build (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-01 23:08:22 +00:00
648872acf1 re add mileograph tags
All checks were successful
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 6m40s
Release / android-build (push) Successful in 14m28s
Release / release-master (push) Successful in 32s
Release / release-dev (push) Successful in 36s
2026-01-01 18:06:50 +00:00
b427ed0bd3 fix failed flutter install
Some checks failed
Release / meta (push) Successful in 2s
Release / android-build (push) Failing after 2m7s
Release / linux-build (push) Failing after 2m6s
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2026-01-01 17:26:54 +00:00
66a1d149f0 remove mileograph tag
Some checks failed
Release / meta (push) Successful in 26s
Release / android-build (push) Failing after 2m38s
Release / linux-build (push) Failing after 2m58s
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2026-01-01 17:11:55 +00:00
cea483ae0b Add ability to select distance unit
Some checks failed
Release / android-build (push) Blocked by required conditions
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 6m39s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2026-01-01 15:28:11 +00:00
7139cfcc99 add stats page
All checks were successful
Release / meta (push) Successful in 20s
Release / linux-build (push) Successful in 7m21s
Release / android-build (push) Successful in 16m39s
Release / release-master (push) Successful in 23s
Release / release-dev (push) Successful in 25s
2026-01-01 12:50:27 +00:00
1c15546b66 support new fields in adding
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 6m54s
Release / android-build (push) Successful in 23m30s
Release / release-master (push) Successful in 30s
Release / release-dev (push) Successful in 32s
2025-12-31 18:23:37 +00:00
e1ad1ea685 remove windows
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m55s
Release / android-build (push) Successful in 22m33s
Release / release-dev (push) Successful in 26s
Release / release-master (push) Successful in 25s
2025-12-30 12:18:37 +00:00
9b307ab56b add windows build
Some checks failed
Release / meta (push) Successful in 18s
Release / windows-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / linux-build (push) Has been cancelled
Release / android-build (push) Has been cancelled
2025-12-30 12:12:17 +00:00
8cf43c76e2 re add calculator page
Some checks failed
Release / meta (push) Successful in 11s
Release / linux-build (push) Successful in 8m31s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / android-build (push) Has been cancelled
2025-12-30 11:55:46 +00:00
2600e90efa adjust loggin in indicator
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 7m20s
Release / android-build (push) Successful in 25m10s
Release / release-dev (push) Successful in 30s
Release / release-master (push) Successful in 28s
2025-12-27 15:00:57 +00:00
a9bc6c306c add password reset on settings page
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 8m26s
Release / android-build (push) Successful in 21m31s
Release / release-master (push) Successful in 25s
Release / release-dev (push) Successful in 28s
2025-12-27 14:34:44 +00:00
54026aa93a make percentage cleared cards shorter
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m46s
Release / android-build (push) Successful in 14m42s
Release / release-dev (push) Successful in 26s
Release / release-master (push) Successful in 24s
2025-12-26 22:51:16 +00:00
0971124fd4 badge percentage support 2025-12-26 22:49:43 +00:00
4bd6f0bbed add support for badges and notifications, adjust nav pages
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 6m49s
Release / android-build (push) Successful in 15m55s
Release / release-master (push) Successful in 24s
Release / release-dev (push) Successful in 26s
2025-12-26 18:36:37 +00:00
44d79e7c28 Improve entries page and latest changes panel, units on events and timeline
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 8m3s
Release / android-build (push) Successful in 19m21s
Release / release-master (push) Successful in 40s
Release / release-dev (push) Successful in 42s
2025-12-23 17:41:21 +00:00
29959f7580 remove hero buttons
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m50s
Release / android-build (push) Successful in 16m21s
Release / release-dev (push) Successful in 33s
Release / release-master (push) Successful in 31s
2025-12-22 23:24:46 +00:00
d5d204dd19 add filter panel to calculator 2025-12-22 23:16:54 +00:00
950978b021 new settings panel for url pickup
All checks were successful
Release / meta (push) Successful in 12s
Release / linux-build (push) Successful in 7m42s
Release / android-build (push) Successful in 16m34s
Release / release-dev (push) Successful in 38s
Release / release-master (push) Successful in 37s
2025-12-22 22:45:33 +00:00
dc5ed2567f fix api endpoint
Some checks failed
Release / meta (push) Failing after 10s
Release / android-build (push) Has been skipped
Release / linux-build (push) Has been skipped
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2025-12-22 21:39:06 +00:00
b1a8f7baf4 dashboard overhaul
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 7m15s
Release / android-build (push) Successful in 18m37s
Release / release-master (push) Successful in 23s
Release / release-dev (push) Successful in 26s
2025-12-22 19:39:50 +00:00
7feb672e7e fix timeline popover
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 6m56s
Release / android-build (push) Successful in 16m36s
Release / release-master (push) Successful in 24s
Release / release-dev (push) Successful in 26s
2025-12-22 17:33:33 +00:00
45d543498f Layout changes, fix bugs in new entry page 2025-12-22 17:23:21 +00:00
63b545c7a3 increment version
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 6m22s
Release / android-build (push) Successful in 17m22s
Release / release-master (push) Successful in 33s
Release / release-dev (push) Successful in 35s
2025-12-17 17:42:54 +00:00
587933fa50 fix navbar freezing fix
Some checks failed
Release / meta (push) Failing after 9s
Release / android-build (push) Has been skipped
Release / linux-build (push) Has been skipped
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2025-12-17 17:41:09 +00:00
a9e0cdbe1b re add the mileograph tags
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 6m43s
Release / android-build (push) Successful in 20m14s
Release / release-master (push) Successful in 50s
Release / release-dev (push) Successful in 53s
2025-12-17 16:39:27 +00:00
334d6e3e18 major refactor
Some checks failed
Release / meta (push) Successful in 9s
Release / android-build (push) Failing after 4m3s
Release / linux-build (push) Successful in 5m38s
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2025-12-17 16:32:53 +00:00
1239a9dc85 add repo review to gitignore
Some checks failed
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 6m29s
Release / android-build (push) Failing after 38m59s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2025-12-17 15:19:09 +00:00
ceb453cd59 increment version for release
Some checks failed
Release / meta (push) Successful in 7s
Release / android-build (push) Failing after 32m18s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / linux-build (push) Has been cancelled
2025-12-17 14:58:11 +00:00
e9a9e66e39 add loco legs panel
Some checks failed
Release / meta (push) Failing after 9s
Release / android-build (push) Has been skipped
Release / linux-build (push) Has been skipped
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2025-12-17 14:42:31 +00:00
fa9773bcd1 add timeline edit/delete
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m36s
Release / android-build (push) Successful in 17m20s
Release / release-dev (push) Successful in 37s
Release / release-master (push) Successful in 35s
2025-12-17 12:17:41 +00:00
80be797322 major refactor
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 6m36s
Release / android-build (push) Successful in 15m50s
Release / release-master (push) Successful in 23s
Release / release-dev (push) Successful in 25s
2025-12-16 16:49:39 +00:00
142 changed files with 26024 additions and 5730 deletions

411
.gitea/workflows/dev.yml Normal file
View File

@@ -0,0 +1,411 @@
name: Release
on:
push:
branches:
- dev-other
env:
JAVA_VERSION: "17"
ANDROID_SDK_ROOT: "${{ github.workspace }}/android-sdk"
FLUTTER_VERSION: "3.38.5"
BUILD_WINDOWS: "false" # Windows build disabled (no runner available)
GITEA_BASE_URL: https://git.tgj.services
WEB_IMAGE: "git.tgj.services/petegregoryy/mileograph-web"
REGISTRY: git.tgj.services
jobs:
meta:
runs-on:
- tgj-arc
outputs:
base_version: ${{ steps.meta.outputs.base }}
release_tag: ${{ steps.meta.outputs.release_tag }}
dev_suffix: ${{ steps.meta.outputs.dev_suffix }}
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%%+*}
VERSION="${BASE_VERSION}"
TAG="v${VERSION}"
DEV_SUFFIX=""
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
DEV_ITER="${GITHUB_RUN_NUMBER:-}"
if [ -z "$DEV_ITER" ]; then
DEV_ITER=$(git rev-list --count HEAD)
fi
DEV_SUFFIX="-dev.${DEV_ITER}"
VERSION="${BASE_VERSION}${DEV_SUFFIX}"
TAG="v${VERSION}"
fi
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "dev_suffix=${DEV_SUFFIX}" >> "$GITHUB_OUTPUT"
- name: Fail if release already exists
env:
TAG: ${{ steps.meta.outputs.release_tag }}
run: |
set -euo pipefail
if ! command -v curl >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
SUDO=""
fi
$SUDO apt-get update
$SUDO apt-get install -y curl ca-certificates
fi
URL="${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/tags/${TAG}"
CODE="$(curl -sS -o /dev/null -w "%{http_code}" -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" "$URL" || true)"
if [ "$CODE" = "200" ]; then
echo "Release already exists for tag ${TAG}; refusing to re-release."
exit 1
fi
if [ "$CODE" != "404" ]; then
echo "Unexpected response checking existing release (${CODE}) at ${URL}"
exit 1
fi
android-build:
runs-on:
- tgj-arc
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: Install Flutter SDK
# run: |
# set -euo pipefail
# FLUTTER_HOME="$HOME/flutter"
# # Avoid git ownership issues when Flutter checks out deps.
# git config --global --add safe.directory "$FLUTTER_HOME" || true
# if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
# rm -rf "$FLUTTER_HOME"
# curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
# tar -C "$HOME" -xf /tmp/flutter.tar.xz
# fi
# echo "$FLUTTER_HOME/bin" >> "$GITHUB_PATH"
# "$FLUTTER_HOME/bin/flutter" --version
- name: Allow all git directories (CI)
run: git config --global --add safe.directory '*'
- name: Set pub cache path
run: echo "PUB_CACHE=${GITHUB_WORKSPACE}/.pub-cache" >> "$GITHUB_ENV"
- name: Flutter dependencies
run: flutter pub get
- name: Prepare Android keystore (optional)
if: ${{ secrets.ANDROID_KEYSTORE_BASE64 != '' }}
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android-release-key.jks
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: Archive Android App Bundle
env:
BASE_VERSION: ${{ needs.meta.outputs.base_version }}
run: |
set -euo pipefail
BUNDLE_SRC="build/app/outputs/bundle/release/app-release.aab"
if [ ! -f "$BUNDLE_SRC" ]; then
echo "Bundle not found at $BUNDLE_SRC"
exit 1
fi
cp "$BUNDLE_SRC" "mileograph-${BASE_VERSION}.aab"
- 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
uses: actions/upload-artifact@v3
with:
name: android-apk
path: mileograph-${{ needs.meta.outputs.base_version }}.apk
- name: Upload Android AAB artifact
uses: actions/upload-artifact@v3
with:
name: android-aab
path: mileograph-${{ needs.meta.outputs.base_version }}.aab
linux-build:
runs-on:
- tgj-arc
needs: meta
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Allow all git directories (CI)
run: git config --global --add safe.directory '*'
- name: Set pub cache path
run: echo "PUB_CACHE=${GITHUB_WORKSPACE}/.pub-cache" >> "$GITHUB_ENV"
- 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
web-build:
runs-on:
- tgj-arc
needs: meta
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Allow all git directories (CI)
run: git config --global --add safe.directory '*'
- name: Set pub cache path
run: echo "PUB_CACHE=${GITHUB_WORKSPACE}/.pub-cache" >> "$GITHUB_ENV"
- name: Flutter dependencies
run: flutter pub get
- name: Enable Flutter web
run: flutter config --enable-web
- name: Build Flutter web (release)
run: |
flutter build web --release --base-href=/
tar -C build/web -czf app-web.tar.gz .
- name: Upload Web artifact
uses: actions/upload-artifact@v3
with:
name: web-build
path: app-web.tar.gz
- name: Set up docker buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: dmeta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/petegregoryy/railframe-web
flavor: latest=false
tags: |
type=sha,prefix=
type=raw,value=${{ needs.meta.outputs.base_version }}${{ needs.meta.outputs.dev_suffix }}
- name: Login to the docker registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: petegregoryy
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v6
with:
file: Dockerfile.web
context: .
push: true
tags: ${{ steps.dmeta.outputs.tags }}
labels: ${{ steps.dmeta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/petegregoryy/railframe-web:buildcache-dev
cache-to: type=registry,ref=${{ env.REGISTRY }}/petegregoryy/railframe-web:buildcache-dev,mode=max
release-dev:
runs-on:
- tgj-arc
needs:
- meta
- android-build
- linux-build
- web-build
steps:
- name: Download Android APK
if: ${{ github.ref == 'refs/heads/dev' }}
uses: actions/download-artifact@v3
with:
name: android-apk
path: artifacts
- name: Download Android AAB
if: ${{ github.ref == 'refs/heads/dev' }}
uses: actions/download-artifact@v3
with:
name: android-aab
path: artifacts
- name: Prepare APK and tag
if: ${{ github.ref == 'refs/heads/dev' }}
id: bundle
run: |
BASE="${{ needs.meta.outputs.base_version }}"
TAG="${{ needs.meta.outputs.release_tag }}"
DEV_SUFFIX="${{ needs.meta.outputs.dev_suffix }}"
if [ -z "$DEV_SUFFIX" ]; then
echo "dev_suffix is empty; expected '-dev.<n>'"
exit 1
fi
VERSION="${BASE}${DEV_SUFFIX}"
APK_NAME="mileograph-${VERSION}.apk"
AAB_NAME="mileograph-${VERSION}.aab"
mv "artifacts/mileograph-${BASE}.apk" "artifacts/${APK_NAME}"
mv "artifacts/mileograph-${BASE}.aab" "artifacts/${AAB_NAME}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "apk=artifacts/${APK_NAME}" >> "$GITHUB_OUTPUT"
echo "aab=artifacts/${AAB_NAME}" >> "$GITHUB_OUTPUT"
- name: Create prerelease on Gitea
if: ${{ github.ref == 'refs/heads/dev' }}
uses: ncipollo/release-action@v1
with:
tag: ${{ steps.bundle.outputs.tag }}
name: ${{ steps.bundle.outputs.tag }}
prerelease: true
commit: ${{ github.sha }}
token: ${{ secrets.GITEA_TOKEN }}
# NOTE: no `artifacts:` here
- name: Attach APK to Gitea release
if: ${{ github.ref == 'refs/heads/dev' }}
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
# Attach AAB
AAB="${{ steps.bundle.outputs.aab }}"
NAME_AAB=$(basename "$AAB")
echo "Uploading $NAME_AAB"
curl -sS -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@${AAB}" \
-F "name=${NAME_AAB}" \
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null

View File

@@ -10,16 +10,19 @@ env:
JAVA_VERSION: "17" JAVA_VERSION: "17"
ANDROID_SDK_ROOT: "${{ github.workspace }}/android-sdk" ANDROID_SDK_ROOT: "${{ github.workspace }}/android-sdk"
FLUTTER_VERSION: "3.38.5" FLUTTER_VERSION: "3.38.5"
BUILD_WINDOWS: "false" # set to "true" when you actually want Windows builds BUILD_WINDOWS: "false" # Windows build disabled (no runner available)
GITEA_BASE_URL: https://git.tgj.services GITEA_BASE_URL: https://git.tgj.services
WEB_IMAGE: "git.tgj.services/petegregoryy/mileograph-web"
REGISTRY: git.tgj.services
jobs: jobs:
meta: meta:
runs-on: runs-on:
- mileograph - tgj-arc
outputs: outputs:
base_version: ${{ steps.meta.outputs.base }} base_version: ${{ steps.meta.outputs.base }}
release_tag: ${{ steps.meta.outputs.release_tag }} release_tag: ${{ steps.meta.outputs.release_tag }}
dev_suffix: ${{ steps.meta.outputs.dev_suffix }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -29,26 +32,70 @@ jobs:
run: | run: |
RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml) RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml)
BASE_VERSION=${RAW_VERSION%%+*} BASE_VERSION=${RAW_VERSION%%+*}
VERSION="${BASE_VERSION}"
TAG="v${VERSION}"
DEV_SUFFIX=""
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
DEV_ITER="${GITHUB_RUN_NUMBER:-}"
if [ -z "$DEV_ITER" ]; then
DEV_ITER=$(git rev-list --count HEAD)
fi
DEV_SUFFIX="-dev.${DEV_ITER}"
VERSION="${BASE_VERSION}${DEV_SUFFIX}"
TAG="${VERSION}"
fi
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT" echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=v${BASE_VERSION}" >> "$GITHUB_OUTPUT" echo "release_tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "dev_suffix=${DEV_SUFFIX}" >> "$GITHUB_OUTPUT"
android-build: - name: Fail if release already exists
runs-on: env:
- mileograph TAG: ${{ steps.meta.outputs.release_tag }}
needs: meta
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install OS deps (Android)
run: | run: |
set -euo pipefail
if ! command -v curl >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then if command -v sudo >/dev/null 2>&1; then
SUDO="sudo" SUDO="sudo"
else else
SUDO="" SUDO=""
fi fi
$SUDO apt-get update $SUDO apt-get update
$SUDO apt-get install -y unzip xz-utils zip libstdc++6 liblzma-dev curl jq $SUDO apt-get install -y curl ca-certificates
fi
URL="${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/tags/${TAG}"
CODE="$(curl -sS -o /dev/null -w "%{http_code}" -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" "$URL" || true)"
if [ "$CODE" = "200" ]; then
echo "Release already exists for tag ${TAG}; refusing to re-release."
exit 1
fi
if [ "$CODE" != "404" ]; then
echo "Unexpected response checking existing release (${CODE}) at ${URL}"
exit 1
fi
android-build:
runs-on:
- tgj-arc
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 - name: Setup Java
uses: actions/setup-java@v4 uses: actions/setup-java@v4
@@ -74,17 +121,19 @@ jobs:
echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH" echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH"
echo "$ANDROID_SDK_ROOT/build-tools/33.0.2" >> "$GITHUB_PATH" echo "$ANDROID_SDK_ROOT/build-tools/33.0.2" >> "$GITHUB_PATH"
- name: Install Flutter SDK # - name: Install Flutter SDK
run: | # run: |
set -euo pipefail # set -euo pipefail
FLUTTER_HOME="$HOME/flutter" # FLUTTER_HOME="$HOME/flutter"
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then # # Avoid git ownership issues when Flutter checks out deps.
rm -rf "$FLUTTER_HOME" # git config --global --add safe.directory "$FLUTTER_HOME" || true
curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz" # if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
tar -C "$HOME" -xf /tmp/flutter.tar.xz # rm -rf "$FLUTTER_HOME"
fi # curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
echo "$FLUTTER_HOME/bin" >> "$GITHUB_PATH" # tar -C "$HOME" -xf /tmp/flutter.tar.xz
"$FLUTTER_HOME/bin/flutter" --version # fi
# echo "$FLUTTER_HOME/bin" >> "$GITHUB_PATH"
# "$FLUTTER_HOME/bin/flutter" --version
- name: Allow all git directories (CI) - name: Allow all git directories (CI)
run: git config --global --add safe.directory '*' run: git config --global --add safe.directory '*'
@@ -107,6 +156,18 @@ jobs:
- name: Build Android App Bundle (release) - name: Build Android App Bundle (release)
run: flutter build appbundle --release run: flutter build appbundle --release
- name: Archive Android App Bundle
env:
BASE_VERSION: ${{ needs.meta.outputs.base_version }}
run: |
set -euo pipefail
BUNDLE_SRC="build/app/outputs/bundle/release/app-release.aab"
if [ ! -f "$BUNDLE_SRC" ]; then
echo "Bundle not found at $BUNDLE_SRC"
exit 1
fi
cp "$BUNDLE_SRC" "mileograph-${BASE_VERSION}.aab"
- name: Download bundletool - name: Download bundletool
run: | run: |
BUNDLETOOL_VERSION=1.15.6 BUNDLETOOL_VERSION=1.15.6
@@ -153,36 +214,20 @@ jobs:
name: android-apk name: android-apk
path: mileograph-${{ needs.meta.outputs.base_version }}.apk path: mileograph-${{ needs.meta.outputs.base_version }}.apk
- name: Upload Android AAB artifact
uses: actions/upload-artifact@v3
with:
name: android-aab
path: mileograph-${{ needs.meta.outputs.base_version }}.aab
linux-build: linux-build:
runs-on: runs-on:
- mileograph - tgj-arc
needs: meta needs: meta
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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: Install Flutter SDK
run: |
set -euo pipefail
FLUTTER_HOME="$HOME/flutter"
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
rm -rf "$FLUTTER_HOME"
curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
tar -C "$HOME" -xf /tmp/flutter.tar.xz
fi
echo "$FLUTTER_HOME/bin" >> "$GITHUB_PATH"
"$FLUTTER_HOME/bin/flutter" --version
- name: Allow all git directories (CI) - name: Allow all git directories (CI)
run: git config --global --add safe.directory '*' run: git config --global --add safe.directory '*'
@@ -206,13 +251,78 @@ jobs:
name: linux-bundle name: linux-bundle
path: app-linux-x64.tar.gz path: app-linux-x64.tar.gz
web-build:
runs-on:
- tgj-arc
needs: meta
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Allow all git directories (CI)
run: git config --global --add safe.directory '*'
- name: Set pub cache path
run: echo "PUB_CACHE=${GITHUB_WORKSPACE}/.pub-cache" >> "$GITHUB_ENV"
- name: Flutter dependencies
run: flutter pub get
- name: Enable Flutter web
run: flutter config --enable-web
- name: Build Flutter web (release)
run: |
flutter build web --release --base-href=/
tar -C build/web -czf app-web.tar.gz .
- name: Upload Web artifact
uses: actions/upload-artifact@v3
with:
name: web-build
path: app-web.tar.gz
- name: Set up docker buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: dmeta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/petegregoryy/railframe-web
flavor: latest=false
tags: |
type=sha,prefix=
type=raw,value=${{ needs.meta.outputs.base_version }}${{ needs.meta.outputs.dev_suffix }}
type=raw,value=dev
- name: Login to the docker registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: petegregoryy
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v6
with:
file: Dockerfile.web
context: .
push: true
tags: ${{ steps.dmeta.outputs.tags }}
labels: ${{ steps.dmeta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/petegregoryy/railframe-web:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/petegregoryy/railframe-web:buildcache,mode=max
release-dev: release-dev:
runs-on: runs-on:
- mileograph - tgj-arc
needs: needs:
- meta - meta
- android-build - android-build
- linux-build - linux-build
- web-build
steps: steps:
- name: Install jq - name: Install jq
run: | run: |
@@ -230,24 +340,42 @@ jobs:
name: android-apk name: android-apk
path: artifacts path: artifacts
- name: Download Android AAB
if: ${{ github.ref == 'refs/heads/dev' }}
uses: actions/download-artifact@v3
with:
name: android-aab
path: artifacts
- name: Prepare APK and tag - name: Prepare APK 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.${{ github.run_number }}" TAG="${{ needs.meta.outputs.release_tag }}"
DEV_SUFFIX="${{ needs.meta.outputs.dev_suffix }}"
if [ -z "$DEV_SUFFIX" ]; then
echo "dev_suffix is empty; expected '-dev.<n>'"
exit 1
fi
mv "artifacts/mileograph-${BASE}.apk" "artifacts/mileograph-${BASE}-dev.apk" VERSION="${BASE}${DEV_SUFFIX}"
APK_NAME="mileograph-${VERSION}.apk"
AAB_NAME="mileograph-${VERSION}.aab"
mv "artifacts/mileograph-${BASE}.apk" "artifacts/${APK_NAME}"
mv "artifacts/mileograph-${BASE}.aab" "artifacts/${AAB_NAME}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "apk=artifacts/mileograph-${BASE}-dev.apk" >> "$GITHUB_OUTPUT" echo "apk=artifacts/${APK_NAME}" >> "$GITHUB_OUTPUT"
echo "aab=artifacts/${AAB_NAME}" >> "$GITHUB_OUTPUT"
- name: Create prerelease on Gitea - name: Create prerelease on Gitea
if: ${{ github.ref == 'refs/heads/dev' }} if: ${{ github.ref == 'refs/heads/dev' }}
uses: ncipollo/release-action@v1 uses: ncipollo/release-action@v1
with: with:
tag: ${{ steps.bundle.outputs.tag }} tag: ${{ steps.bundle.outputs.tag }}
name: v${{ needs.meta.outputs.base_version }}-dev build ${{ github.run_number }} name: ${{ steps.bundle.outputs.tag }}
prerelease: true prerelease: true
commit: ${{ github.sha }} commit: ${{ github.sha }}
token: ${{ secrets.GITEA_TOKEN }} token: ${{ secrets.GITEA_TOKEN }}
@@ -281,13 +409,26 @@ jobs:
"${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
# Attach AAB
AAB="${{ steps.bundle.outputs.aab }}"
NAME_AAB=$(basename "$AAB")
echo "Uploading $NAME_AAB"
curl -sS -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@${AAB}" \
-F "name=${NAME_AAB}" \
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null
release-master: release-master:
runs-on: runs-on:
- mileograph - tgj-arc
needs: needs:
- meta - meta
- android-build - android-build
- linux-build - linux-build
- web-build
steps: steps:
- name: Install jq - name: Install jq
run: | run: |
@@ -305,6 +446,13 @@ jobs:
name: android-apk name: android-apk
path: artifacts path: artifacts
- name: Download Android AAB
if: ${{ github.ref == 'refs/heads/master' }}
uses: actions/download-artifact@v3
with:
name: android-aab
path: artifacts
- name: Prepare APK and tag - name: Prepare APK and tag
if: ${{ github.ref == 'refs/heads/master' }} if: ${{ github.ref == 'refs/heads/master' }}
id: bundle id: bundle
@@ -314,6 +462,7 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "apk=artifacts/mileograph-${BASE}.apk" >> "$GITHUB_OUTPUT" echo "apk=artifacts/mileograph-${BASE}.apk" >> "$GITHUB_OUTPUT"
echo "aab=artifacts/mileograph-${BASE}.aab" >> "$GITHUB_OUTPUT"
- name: Create release on Gitea - name: Create release on Gitea
if: ${{ github.ref == 'refs/heads/master' }} if: ${{ github.ref == 'refs/heads/master' }}
@@ -352,3 +501,15 @@ jobs:
-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
# Attach AAB
AAB="${{ steps.bundle.outputs.aab }}"
NAME_AAB=$(basename "$AAB")
echo "Uploading $NAME_AAB"
curl -sS -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@${AAB}" \
-F "name=${NAME_AAB}" \
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null

1
.gitignore vendored
View File

@@ -45,3 +45,4 @@ app.*.map.json
/android/app/release /android/app/release
api_return_examples.txt api_return_examples.txt
REPO_REVIEW.md

View File

@@ -1 +1,3 @@
{} {
"cmake.ignoreCMakeListsMissing": true
}

10
Dockerfile.web Normal file
View File

@@ -0,0 +1,10 @@
FROM nginx:1.27-alpine
# Use a minimal Nginx image to serve the built Flutter web app.
# Assumes `flutter build web` has already populated build/web/ in the build context.
COPY deploy/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY build/web /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,16 +1,74 @@
# mileograph_flutter # Mileograph (Flutter)
A new Flutter project. Mileograph is a Flutter client for logging and analysing railway journeys. It lets you record legs, group them into trips, track locomotive mileage, and view stats and leaderboards.
## Getting Started
This project is a starting point for a Flutter application. ## Features
- Add and edit journey legs with traction, timings, routes, notes, and delays.
- Group legs into trips and see mileage totals and traction stats.
- Browse traction, view loco details, mileage leaderboards, timelines, and legs.
- Dashboard with homepage stats, “On This Day”, recent traction changes, and trips.
- Profile badges, class clearance progress, and traction progress.
- Offline-friendly UI built with Provider, GoRouter, and Material 3 styling.
A few resources to get you started if this is your first Flutter project: ## Project layout
- `lib/objects/objects.dart` — shared model classes and helpers.
- `lib/services/` — API, data loading, auth, endpoints, distance units.
- `lib/components/` — UI pages and widgets (entries, traction, dashboard, trips, settings, etc.).
- `assets/` — icons/fonts and other bundled assets.
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) ## Prerequisites
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) - Flutter SDK (3.x or later recommended).
- Dart SDK (bundled with Flutter).
- A Mileograph API endpoint (set in Settings within the app).
For help getting started with Flutter development, view the ## Setup
[online documentation](https://docs.flutter.dev/), which offers tutorials, 1) Install Flutter: follow https://docs.flutter.dev/get-started/install and ensure `flutter doctor` is green.
samples, guidance on mobile development, and a full API reference. 2) Get dependencies:
```bash
flutter pub get
```
3) Configure an API endpoint:
- Run the app, open Settings, and set the base URL for your Mileograph backend.
- The app probes the endpoint for a version string before saving.
## Running
- Debug (mobile/web depending on your toolchain):
```bash
flutter run
```
- Release build (example for Android):
```bash
flutter build apk --release
```
- Web (release + CanvasKit renderer for best performance/icons):
```bash
flutter build web --release --web-renderer canvaskit --tree-shake-icons
# or for local profiling:
flutter run -d chrome --profile --web-renderer canvaskit
```
## Testing and linting
- Static analysis: `flutter analyze`
- Unit/widget tests (if present): `flutter test`
## Contributing
1) Fork or branch from `main`.
2) Make changes with clear, small commits.
3) Add tests where feasible and keep `flutter analyze` clean.
4) Submit a PR describing:
- What changed and why.
- How to test or reproduce.
- Any API or migration notes.
### Coding conventions
- Prefer stateless widgets where possible; keep state localized.
- Use existing services in `lib/services` for API access; add new endpoints there.
- Keep models in `objects.dart` (or nearby files) and use helper parsers for defensive JSON handling.
- Follow Material theming already in use; keep strings user-facing and concise.
### Issue reporting
Include device/OS, Flutter version (`flutter --version`), steps to reproduce, expected vs. actual behaviour, and logs if available.
## License
Copyright © Mileograph contributors. See repository terms if provided.

View File

@@ -1,3 +1,4 @@
import java.io.File
import java.util.Properties import java.util.Properties
plugins { plugins {
@@ -22,8 +23,21 @@ val releaseKeyAlias = System.getenv("ANDROID_KEY_ALIAS")
val releaseKeyPassword = System.getenv("ANDROID_KEY_PASSWORD") val releaseKeyPassword = System.getenv("ANDROID_KEY_PASSWORD")
?: keystoreProperties.getProperty("keyPassword") ?: keystoreProperties.getProperty("keyPassword")
val releaseStoreFilePath: File? = releaseStoreFile?.let { path ->
val candidate = File(path)
if (candidate.isAbsolute) return@let candidate
val candidates = listOfNotNull(
rootProject.file(path),
rootProject.projectDir.parentFile?.let { File(it, path) },
project.file(path),
)
candidates.firstOrNull { it.exists() }
}
val hasReleaseKeystore = listOf( val hasReleaseKeystore = listOf(
releaseStoreFile, releaseStoreFilePath?.path,
releaseStorePassword, releaseStorePassword,
releaseKeyAlias, releaseKeyAlias,
releaseKeyPassword releaseKeyPassword
@@ -31,7 +45,7 @@ val hasReleaseKeystore = listOf(
android { android {
namespace = "com.example.mileograph_flutter" namespace = "com.example.mileograph_flutter"
compileSdk = flutter.compileSdkVersion compileSdk = 36
ndkVersion = "27.0.12077973" ndkVersion = "27.0.12077973"
compileOptions { compileOptions {
@@ -45,11 +59,11 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.mileograph_flutter" applicationId = "com.petegregoryy.mileograph_flutter"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = 24
targetSdk = flutter.targetSdkVersion targetSdk = 36
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
} }
@@ -57,7 +71,7 @@ android {
signingConfigs { signingConfigs {
if (hasReleaseKeystore) { if (hasReleaseKeystore) {
create("release") { create("release") {
storeFile = file(releaseStoreFile!!) storeFile = releaseStoreFilePath
storePassword = releaseStorePassword storePassword = releaseStorePassword
keyAlias = releaseKeyAlias!! keyAlias = releaseKeyAlias!!
keyPassword = releaseKeyPassword keyPassword = releaseKeyPassword

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1014 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 689 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#ffffff</color> <color name="ic_launcher_background">#000000</color>
</resources> </resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="120"
height="120"
viewBox="0 0 120 120"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="layer1">
<path
style="fill:#00b7fd;stroke-width:4.40315"
d="M 42.563242,0 H 92.5 A 27.5,27.5 45 0 1 120,27.5 27.5,27.5 135 0 1 92.5,55 h -50 A 2.5,2.5 45 0 1 40,52.5 v -10 A 2.5,2.5 135 0 1 42.5,40 h 50 A 12.5,12.5 135 0 0 105,27.5 12.5,12.5 45 0 0 92.5,15 l -50,0 A 27.5,27.5 135 0 0 15,42.5 v 50 A 2.5,2.5 135 0 1 12.5,95 H 2.5 A 2.5,2.5 45 0 1 0,92.5 V 42.563242 A 42.563242,42.563242 135 0 1 42.563242,0 Z"
id="path1"
transform="translate(0,12.5)" />
<path
style="fill:#999999;stroke-width:4.40315"
d="m 42.5,60 h 60 A 17.5,17.5 45 0 1 120,77.5 17.5,17.5 135 0 1 102.5,95 h -60 A 22.5,22.5 45 0 1 20,72.5 v -30 A 22.5,22.5 135 0 1 42.5,20 h 50 a 7.5,7.5 45 0 1 7.5,7.5 7.5,7.5 135 0 1 -7.5,7.5 h -50 A 7.5,7.5 135 0 0 35,42.5 v 30 a 7.5,7.5 45 0 0 7.5,7.5 h 60 A 2.5,2.5 135 0 0 105,77.5 2.5,2.5 45 0 0 102.5,75 h -60 A 2.5,2.5 45 0 1 40,72.5 v -10 A 2.5,2.5 135 0 1 42.5,60 Z"
id="path2"
transform="translate(0,12.5)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

28
deploy/web/nginx.conf Normal file
View File

@@ -0,0 +1,28 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
include /etc/nginx/mime.types;
# Serve hashed assets aggressively; keep index/service worker cacheable but not immutable.
location /assets/ {
try_files $uri =404;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
}
location /icons/ {
try_files $uri =404;
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
location = /flutter_service_worker.js {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 598 B

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 834 B

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 697 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1017 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 876 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 632 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 698 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 834 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 841 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

58
lib/app.dart Normal file
View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/accent_color_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/endpoint_service.dart';
import 'package:mileograph_flutter/services/theme_mode_service.dart';
import 'package:mileograph_flutter/ui/app_shell.dart';
import 'package:provider/provider.dart';
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider<EndpointService>(
create: (_) => EndpointService(),
),
ChangeNotifierProvider<AccentColorService>(
create: (_) => AccentColorService(),
),
ChangeNotifierProvider<DistanceUnitService>(
create: (_) => DistanceUnitService(),
),
ChangeNotifierProvider<ThemeModeService>(
create: (_) => ThemeModeService(),
),
ProxyProvider<EndpointService, ApiService>(
update: (_, endpoint, api) {
final service = api ?? ApiService(baseUrl: endpoint.baseUrl);
service.setBaseUrl(endpoint.baseUrl);
return service;
},
create: (_) => ApiService(baseUrl: EndpointService.defaultBaseUrl),
),
ChangeNotifierProvider<AuthService>(
create: (context) => AuthService(api: context.read<ApiService>()),
),
ChangeNotifierProxyProvider<AuthService, DataService>(
create: (context) => DataService(api: context.read<ApiService>()),
update: (context, auth, data) {
data ??= DataService(api: context.read<ApiService>());
data.handleAuthChanged(
auth.userId,
entriesVisibility: auth.entriesVisibility,
mileageVisibility: auth.mileageVisibility,
);
return data;
},
),
],
child: const MyApp(),
);
}
}

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.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/api_service.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/components/widgets/multi_select_filter.dart';
import './routeSummaryWidget.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import './route_summary_widget.dart';
class StationAutocomplete extends StatefulWidget { class StationAutocomplete extends StatefulWidget {
const StationAutocomplete({ const StationAutocomplete({
@@ -24,9 +26,6 @@ class StationAutocomplete extends StatefulWidget {
class _StationAutocompleteState extends State<StationAutocomplete> { class _StationAutocompleteState extends State<StationAutocomplete> {
late final TextEditingController _controller; late final TextEditingController _controller;
// Simulated list of over 10,000 stations
final List<String> stations = List.generate(10000, (i) => 'Station $i');
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -49,15 +48,7 @@ class _StationAutocompleteState extends State<StationAutocomplete> {
if (textEditingValue.text.isEmpty) { if (textEditingValue.text.isEmpty) {
return const Iterable<String>.empty(); return const Iterable<String>.empty();
} }
final query = textEditingValue.text.toLowerCase(); return _findTopMatches(textEditingValue.text);
final matches = widget.allStations
.map((s) => s.name)
.where((name) => name.toLowerCase().contains(query))
.toList();
matches.sort((a, b) => a.length.compareTo(b.length));
return matches.take(10);
}, },
onSelected: (String selection) { onSelected: (String selection) {
_controller.text = selection; _controller.text = selection;
@@ -71,21 +62,7 @@ class _StationAutocompleteState extends State<StationAutocomplete> {
controller: textEditingController, controller: textEditingController,
focusNode: focusNode, focusNode: focusNode,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onSubmitted: (_) { onSubmitted: (_) => onFieldSubmitted(),
final query = textEditingController.text.toLowerCase();
final matches = widget.allStations
.map((s) => s.name)
.where((name) => name.toLowerCase().contains(query))
.toList();
if (matches.isNotEmpty) {
matches.sort((a, b) => a.length.compareTo(b.length));
final firstMatch = matches.first;
_controller.text = firstMatch;
widget.onChanged(firstMatch);
focusNode.unfocus(); // optionally close keyboard
}
},
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Select station', labelText: 'Select station',
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -94,6 +71,42 @@ class _StationAutocompleteState extends State<StationAutocomplete> {
}, },
); );
} }
Iterable<String> _findTopMatches(String rawQuery) {
final query = rawQuery.trim().toLowerCase();
if (query.isEmpty) return const <String>[];
// Keep a bounded, sorted list (by shortest name, then alpha) without
// sorting the entire match set.
final best = <String>[];
for (final station in widget.allStations) {
final name = station.name;
if (name.isEmpty) continue;
if (!name.toLowerCase().contains(query)) continue;
_insertCandidate(best, name, max: 10);
}
return best;
}
void _insertCandidate(List<String> best, String candidate, {required int max}) {
final existingIndex = best.indexOf(candidate);
if (existingIndex >= 0) return;
int insertAt = 0;
while (insertAt < best.length &&
_candidateCompare(best[insertAt], candidate) <= 0) {
insertAt++;
}
best.insert(insertAt, candidate);
if (best.length > max) best.removeLast();
}
int _candidateCompare(String a, String b) {
final byLength = a.length.compareTo(b.length);
if (byLength != 0) return byLength;
return a.compareTo(b);
}
} }
class RouteCalculator extends StatefulWidget { class RouteCalculator extends StatefulWidget {
@@ -114,13 +127,17 @@ class RouteCalculator extends StatefulWidget {
class _RouteCalculatorState extends State<RouteCalculator> { class _RouteCalculatorState extends State<RouteCalculator> {
List<Station> allStations = []; List<Station> allStations = [];
List<String> _networks = [];
List<String> _countries = [];
List<String> _selectedNetworks = [];
List<String> _selectedCountries = [];
bool _loadingStations = false;
RouteResult? _routeResult; RouteResult? _routeResult;
List<String>? _calculatedStations;
RouteResult? get result => _routeResult; RouteResult? get result => _routeResult;
String? _errorMessage; String? _errorMessage;
bool _showDetails = false;
bool _fetched = false; bool _fetched = false;
@override @override
@@ -133,41 +150,90 @@ class _RouteCalculatorState extends State<RouteCalculator> {
} }
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
final data = context.read<DataService>(); final data = context.read<DataService>();
final result = await data.fetchStations(); await data.fetchStationFilters();
if (mounted) { if (!mounted) return;
setState(() => allStations = result); setState(() {
} _networks = data.stationNetworks;
_countries = data.stationCountryNetworks.keys.toList();
});
await _loadStations();
}); });
} }
} }
Future<void> _loadStations() async {
setState(() => _loadingStations = true);
final data = context.read<DataService>();
final stations = await data.fetchStations(
countries: _selectedCountries,
networks: _selectedNetworks,
);
if (!mounted) return;
setState(() {
allStations = stations;
_loadingStations = false;
});
}
Future<void> _calculateRoute(List<String> stations) async { Future<void> _calculateRoute(List<String> stations) async {
final cleaned = stations.where((s) => s.trim().isNotEmpty).toList();
if (cleaned.length < 2) {
setState(() {
_routeResult = null;
_calculatedStations = null;
_errorMessage = 'Add at least two stations before calculating.';
});
return;
}
setState(() { setState(() {
_errorMessage = null; _errorMessage = null;
_routeResult = null; _routeResult = null;
_calculatedStations = null;
}); });
final api = context.read<ApiService>(); // context is valid here final api = context.read<ApiService>(); // context is valid here
try {
final res = await api.post('/route/distance2', { final res = await api.post('/route/distance2', {
'route': stations.where((s) => s.trim().isNotEmpty).toList(), 'route': cleaned,
}); });
if (res['error'] == false) { if (res is Map && res['error'] == false) {
setState(() { setState(() {
_routeResult = RouteResult.fromJson(res); _routeResult = RouteResult.fromJson(Map<String, dynamic>.from(res));
_calculatedStations = List.from(cleaned);
}); });
final distance = (_routeResult?.distance ?? 0); final distance = (_routeResult?.distance ?? 0);
widget.onDistanceComputed?.call(distance); widget.onDistanceComputed?.call(distance);
} else if (res is Map && res['error_obj'] is List && res['error_obj'].isNotEmpty) {
setState(() {
_errorMessage = RouteError.fromJson(
Map<String, dynamic>.from(res['error_obj'][0] as Map),
).msg;
});
} else { } else {
setState(() { setState(() {
_errorMessage = RouteError.fromJson(res["error_obj"][0]).msg; _errorMessage = 'Failed to calculate route.';
_calculatedStations = null;
}); });
} }
} catch (e) {
setState(() {
_errorMessage = 'Failed to calculate route: $e';
_calculatedStations = null;
});
}
}
void _markRouteDirty() {
_routeResult = null;
_calculatedStations = null;
_errorMessage = null;
} }
void _addStation() { void _addStation() {
final data = context.read<DataService>(); final data = context.read<DataService>();
setState(() { setState(() {
data.stations.add(''); data.stations.add('');
_markRouteDirty();
}); });
} }
@@ -175,6 +241,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.read<DataService>(); final data = context.read<DataService>();
setState(() { setState(() {
data.stations.removeAt(index); data.stations.removeAt(index);
_markRouteDirty();
}); });
} }
@@ -182,21 +249,156 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.read<DataService>(); final data = context.read<DataService>();
setState(() { setState(() {
data.stations[index] = value; data.stations[index] = value;
_markRouteDirty();
}); });
} }
void _clearCalculator() {
final data = context.read<DataService>();
setState(() {
data.stations = [''];
_markRouteDirty();
});
}
bool _isResultCurrent(List<String> stations) {
if (_routeResult == null || _calculatedStations == null) return false;
final cleaned = stations.where((s) => s.trim().isNotEmpty).toList();
if (cleaned.length != _calculatedStations!.length) return false;
for (var i = 0; i < cleaned.length; i++) {
if (cleaned[i] != _calculatedStations![i]) return false;
}
return true;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
if (_showDetails && _routeResult != null) { final isCompact = MediaQuery.of(context).size.width < 600;
return RouteDetailsView( final showApply =
route: _routeResult!.calculatedRoute, widget.onApplyRoute != null && _isResultCurrent(data.stations);
costs: _routeResult!.costs, final primaryPadding = EdgeInsets.symmetric(
onBack: () => setState(() => _showDetails = false), horizontal: isCompact ? 14 : 20,
vertical: isCompact ? 10 : 14,
);
final secondaryPadding = EdgeInsets.symmetric(
horizontal: isCompact ? 10 : 16,
vertical: isCompact ? 8 : 12,
);
final primaryStyle = FilledButton.styleFrom(
padding: primaryPadding,
minimumSize: Size(0, isCompact ? 38 : 46),
);
final secondaryStyle = OutlinedButton.styleFrom(
padding: secondaryPadding,
minimumSize: Size(0, isCompact ? 34 : 42),
);
Widget buildSecondaryButton({
required IconData icon,
required String label,
required VoidCallback onPressed,
}) {
if (isCompact) {
return Tooltip(
message: label,
child: OutlinedButton(
onPressed: onPressed,
style: secondaryStyle,
child: Icon(icon, size: 20),
),
); );
} }
return OutlinedButton.icon(
onPressed: onPressed,
icon: Icon(icon, size: 20),
label: Text(label),
style: secondaryStyle,
);
}
Widget buildPrimaryAction({required bool fullWidth}) {
final button = showApply
? FilledButton.icon(
onPressed: () => widget.onApplyRoute!(_routeResult!),
icon: const Icon(Icons.check),
label: const Text('Apply to entry'),
style: primaryStyle,
)
: FilledButton.icon(
onPressed: () async {
await _calculateRoute(data.stations);
},
icon: const Icon(Icons.route),
label: const Text('Calculate Route'),
style: primaryStyle,
);
final key =
ValueKey<String>(showApply ? 'apply-primary-action' : 'calc-primary-action');
if (!fullWidth) return KeyedSubtree(key: key, child: button);
return KeyedSubtree(
key: key,
child: SizedBox(width: double.infinity, child: button),
);
}
return Column( return Column(
children: [ children: [
Align(
alignment: Alignment.centerRight,
child: IconButton(
tooltip: 'Clear calculator',
icon: const Icon(Icons.clear_all),
onPressed: _clearCalculator,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
MultiSelectFilter(
label: 'Countries',
options: _countries,
selected: _selectedCountries,
onChanged: (vals) {
setState(() => _selectedCountries = vals);
_loadStations();
},
),
MultiSelectFilter(
label: 'Networks',
options: _networks,
selected: _selectedNetworks,
onChanged: (vals) {
setState(() => _selectedNetworks = vals);
_loadStations();
},
onRefresh: () async {
final data = context.read<DataService>();
await data.fetchStationFilters();
if (!mounted) return;
setState(() {
_networks = data.stationNetworks;
_countries = data.stationCountryNetworks.keys.toList();
});
await _loadStations();
},
),
if (_loadingStations)
const Padding(
padding: EdgeInsets.only(left: 8.0),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
),
),
Expanded( Expanded(
child: ReorderableListView( child: ReorderableListView(
buildDefaultDragHandles: false, buildDefaultDragHandles: false,
@@ -206,6 +408,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
setState(() { setState(() {
final moved = data.stations.removeAt(oldIndex); final moved = data.stations.removeAt(oldIndex);
data.stations.insert(newIndex, moved); data.stations.insert(newIndex, moved);
_markRouteDirty();
}); });
}, },
children: List.generate(data.stations.length, (index) { children: List.generate(data.stations.length, (index) {
@@ -263,47 +466,100 @@ class _RouteCalculatorState extends State<RouteCalculator> {
else if (_routeResult != null) ...[ else if (_routeResult != null) ...[
RouteSummaryWidget( RouteSummaryWidget(
distance: _routeResult!.distance, distance: _routeResult!.distance,
onDetailsPressed: () => setState(() => _showDetails = true), onDetailsPressed: () {
), final result = _routeResult;
if (widget.onApplyRoute != null) if (result == null) return;
Padding( context.push('/calculator/details', extra: result);
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),
LayoutBuilder( Padding(
builder: (context, constraints) { padding: const EdgeInsets.symmetric(horizontal: 16.0),
double screenWidth = constraints.maxWidth; child: isCompact
? Column(
return Padding( children: [
padding: EdgeInsets.only(right: screenWidth < 450 ? 70 : 0), Row(
child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
ElevatedButton.icon( buildSecondaryButton(
icon: const Icon(Icons.add), icon: Icons.swap_horiz,
label: const Text('Add Station'), label: 'Reverse route',
onPressed: _addStation,
),
const SizedBox(width: 16),
ElevatedButton.icon(
icon: const Icon(Icons.route),
label: const Text('Calculate Route'),
onPressed: () async { onPressed: () async {
setState(() {
data.stations = data.stations.reversed.toList();
});
await _calculateRoute(data.stations); await _calculateRoute(data.stations);
}, },
), ),
const SizedBox(width: 12),
buildSecondaryButton(
icon: Icons.add,
label: 'Add station',
onPressed: _addStation,
),
], ],
), ),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
transitionBuilder: (child, animation) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
);
return ScaleTransition(
scale:
Tween<double>(begin: 0.94, end: 1.0).animate(curved),
child: FadeTransition(opacity: animation, child: child),
); );
}, },
child: buildPrimaryAction(fullWidth: true),
),
),
],
)
: Wrap(
alignment: WrapAlignment.center,
spacing: 12,
runSpacing: 8,
children: [
buildSecondaryButton(
icon: Icons.swap_horiz,
label: 'Reverse route',
onPressed: () async {
setState(() {
data.stations = data.stations.reversed.toList();
});
await _calculateRoute(data.stations);
},
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
transitionBuilder: (child, animation) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
);
return ScaleTransition(
scale:
Tween<double>(begin: 0.94, end: 1.0).animate(curved),
child: FadeTransition(opacity: animation, child: child),
);
},
child: buildPrimaryAction(fullWidth: false),
),
buildSecondaryButton(
icon: Icons.add,
label: 'Add station',
onPressed: _addStation,
),
],
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@@ -1,73 +0,0 @@
import 'package:flutter/material.dart';
class RouteSummaryWidget extends StatelessWidget {
final double distance;
final VoidCallback onDetailsPressed;
const RouteSummaryWidget({
super.key,
required this.distance,
required this.onDetailsPressed,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Text(
"Total Distance: ${distance.toStringAsFixed(2)} mi",
style: Theme.of(context).textTheme.titleMedium,
),
),
TextButton(
onPressed: onDetailsPressed,
child: const Text("View Details"),
),
],
),
);
}
}
class RouteDetailsView extends StatelessWidget {
final List<String> route;
final List<double> costs;
final VoidCallback onBack;
const RouteDetailsView({
super.key,
required this.route,
required this.costs,
required this.onBack,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: onBack,
icon: const Icon(Icons.arrow_back),
label: const Text('Back'),
),
),
Expanded(
child: ListView.builder(
itemCount: route.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(route[index]),
trailing: Text("${costs[index].toStringAsFixed(2)} mi"),
);
},
),
),
],
);
}
}

View File

@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class RouteSummaryWidget extends StatelessWidget {
final double distance;
final VoidCallback onDetailsPressed;
const RouteSummaryWidget({
super.key,
required this.distance,
required this.onDetailsPressed,
});
@override
Widget build(BuildContext context) {
final distanceUnits = context.watch<DistanceUnitService>();
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Text(
"Total Distance: ${distanceUnits.format(distance, decimals: 2)}",
style: Theme.of(context).textTheme.titleMedium,
),
),
TextButton(
onPressed: onDetailsPressed,
child: const Text("View Details"),
),
],
),
);
}
}
class RouteDetailsView extends StatelessWidget {
final List<String> route;
final List<double> costs;
final VoidCallback onBack;
final Set<String> routingPoints;
final VoidCallback? onNetworksPressed;
const RouteDetailsView({
super.key,
required this.route,
required this.costs,
required this.onBack,
this.routingPoints = const {},
this.onNetworksPressed,
});
@override
Widget build(BuildContext context) {
final distanceUnits = context.watch<DistanceUnitService>();
final highlightColor = Theme.of(context).colorScheme.primary;
final mutedColor = Theme.of(context).colorScheme.outlineVariant;
return Column(
children: [
Row(
children: [
TextButton.icon(
onPressed: onBack,
icon: const Icon(Icons.arrow_back),
label: const Text('Back'),
),
const Spacer(),
if (onNetworksPressed != null)
TextButton.icon(
onPressed: onNetworksPressed,
icon: const Icon(Icons.account_tree),
label: const Text('Networks'),
),
],
),
Expanded(
child: ListView.builder(
itemCount: route.length,
itemBuilder: (context, index) {
final label = route[index];
final isRoutingPoint = routingPoints.contains(label);
return ListTile(
leading: Icon(
Icons.circle,
size: 12,
color: isRoutingPoint ? highlightColor : mutedColor,
),
title: Text(
label,
style: isRoutingPoint
? TextStyle(color: highlightColor, fontWeight: FontWeight.w600)
: null,
),
trailing: Text(
distanceUnits.format(costs[index], decimals: 2),
),
);
},
),
),
],
);
}
}

View File

@@ -0,0 +1,498 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
class LatestLocoChangesPanel extends StatefulWidget {
const LatestLocoChangesPanel({super.key, this.expanded = false});
final bool expanded;
@override
State<LatestLocoChangesPanel> createState() => _LatestLocoChangesPanelState();
}
class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
late final ScrollController _controller;
final Set<String> _collapsedDates = {};
final Set<String> _collapsedClasses = {};
final Set<String> _collapsedLocos = {};
@override
void initState() {
super.initState();
_controller = ScrollController();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final changes = data.latestLocoChanges;
final isLoading = data.isLatestLocoChangesLoading;
final textTheme = Theme.of(context).textTheme;
return Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.bolt, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Latest Loco Changes',
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
if (isLoading && changes.isNotEmpty)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
const SizedBox(height: 8),
if (isLoading && changes.isEmpty)
const Padding(
padding: EdgeInsets.all(12.0),
child: Center(child: CircularProgressIndicator()),
)
else if (changes.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
'No recent loco changes yet.',
style: textTheme.bodyMedium,
),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildChangesList(changes, textTheme),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: isLoading ? null : _loadMore,
icon: isLoading
? const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.expand_more),
label: Text(isLoading ? 'Loading...' : 'Show more'),
),
),
],
),
],
),
),
);
}
Widget _buildChangesList(List<LocoChange> changes, TextTheme textTheme) {
final grouped = _groupChanges(changes);
// Start with all locos collapsed by default.
if (_collapsedLocos.isEmpty) {
for (final group in grouped) {
for (final classGroup in group.classGroups) {
for (final locoGroup in classGroup.locoGroups) {
_collapsedLocos.add(
_locoKey(group.dateLabel, classGroup.classLabel, locoGroup.locoLabel),
);
}
}
}
}
final listView = ListView.separated(
controller: null,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, groupIndex) {
final group = grouped[groupIndex];
final dateCollapsed = _collapsedDates.contains(group.dateLabel);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
children: [
IconButton(
iconSize: 18,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _toggleDate(group.dateLabel),
icon: Icon(
dateCollapsed ? Icons.chevron_right : Icons.expand_more,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
group.dateLabel,
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
TextButton(
onPressed: () => _collapseDateChildren(
group.dateLabel,
group.classGroups,
collapse: !_isDateFullyCollapsed(group),
),
child: Text(
_isDateFullyCollapsed(group) ? 'Expand all' : 'Collapse all',
),
),
],
),
),
if (!dateCollapsed)
...group.classGroups.map(
(classGroup) {
final classKey = _classKey(group.dateLabel, classGroup.classLabel);
final classCollapsed = _collapsedClasses.contains(classKey);
return Padding(
padding: const EdgeInsets.only(bottom: 8.0, left: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
iconSize: 18,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _toggleClass(classKey),
icon: Icon(
classCollapsed
? Icons.chevron_right
: Icons.expand_more,
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
classGroup.classLabel,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
TextButton(
onPressed: () => _collapseClassChildren(
group.dateLabel,
classGroup.classLabel,
classGroup.locoGroups,
collapse:
!_isClassFullyCollapsed(classKey, classGroup, group.dateLabel),
),
child: Text(
_isClassFullyCollapsed(classKey, classGroup, group.dateLabel)
? 'Expand all'
: 'Collapse all',
),
),
],
),
if (!classCollapsed)
...classGroup.locoGroups.map(
(locoGroup) {
final locoKey =
_locoKey(group.dateLabel, classGroup.classLabel, locoGroup.locoLabel);
final locoCollapsed = _collapsedLocos.contains(locoKey);
return Padding(
padding:
const EdgeInsets.only(bottom: 4.0, left: 22.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
iconSize: 18,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _toggleLoco(locoKey),
icon: Icon(
locoCollapsed
? Icons.chevron_right
: Icons.expand_more,
),
),
const SizedBox(width: 4),
Text(
locoGroup.locoLabel,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
if (!locoCollapsed) ...[
const SizedBox(height: 2),
...locoGroup.changes.map(
(change) => ListTile(
dense: true,
visualDensity: const VisualDensity(
horizontal: 0,
vertical: -3,
),
minVerticalPadding: 0,
contentPadding: EdgeInsets.zero,
title: Text(
'${change.changeLabel}: ${change.valueLabel}',
style: textTheme.bodyMedium,
),
trailing: change.approvedBy.isEmpty
? null
: Text(
change.approvedBy,
style: textTheme.labelSmall,
),
),
),
],
],
),
);
},
),
],
),
);
},
),
],
);
},
separatorBuilder: (_, index) => const Divider(height: 8),
itemCount: grouped.length,
);
if (widget.expanded) {
return listView;
}
return listView;
}
void _toggleDate(String date) {
setState(() {
if (_collapsedDates.contains(date)) {
_collapsedDates.remove(date);
} else {
_collapsedDates.add(date);
}
});
}
void _toggleClass(String key) {
setState(() {
if (_collapsedClasses.contains(key)) {
_collapsedClasses.remove(key);
} else {
_collapsedClasses.add(key);
}
});
}
void _toggleLoco(String key) {
setState(() {
if (_collapsedLocos.contains(key)) {
_collapsedLocos.remove(key);
} else {
_collapsedLocos.add(key);
}
});
}
void _collapseDateChildren(
String date,
List<_ClassGroup> classGroups, {
required bool collapse,
}) {
setState(() {
for (final classGroup in classGroups) {
final classKey = _classKey(date, classGroup.classLabel);
if (collapse) {
_collapsedClasses.add(classKey);
} else {
_collapsedClasses.remove(classKey);
}
for (final locoGroup in classGroup.locoGroups) {
final locoKey = _locoKey(date, classGroup.classLabel, locoGroup.locoLabel);
if (collapse) {
_collapsedLocos.add(locoKey);
} else {
_collapsedLocos.remove(locoKey);
}
}
}
});
}
void _collapseClassChildren(
String date,
String classLabel,
List<_LocoGroup> locos, {
required bool collapse,
}) {
setState(() {
final classKey = _classKey(date, classLabel);
if (collapse) {
_collapsedClasses.add(classKey);
} else {
_collapsedClasses.remove(classKey);
}
for (final locoGroup in locos) {
final locoKey = _locoKey(date, classLabel, locoGroup.locoLabel);
if (collapse) {
_collapsedLocos.add(locoKey);
} else {
_collapsedLocos.remove(locoKey);
}
}
});
}
bool _isDateFullyCollapsed(_ChangeGroup group) {
for (final classGroup in group.classGroups) {
final classKey = _classKey(group.dateLabel, classGroup.classLabel);
if (!_collapsedClasses.contains(classKey)) return false;
for (final loco in classGroup.locoGroups) {
final locoKey = _locoKey(group.dateLabel, classGroup.classLabel, loco.locoLabel);
if (!_collapsedLocos.contains(locoKey)) return false;
}
}
return true;
}
bool _isClassFullyCollapsed(String classKey, _ClassGroup classGroup, String date) {
if (!_collapsedClasses.contains(classKey)) return false;
for (final loco in classGroup.locoGroups) {
final locoKey = _locoKey(date, classGroup.classLabel, loco.locoLabel);
if (!_collapsedLocos.contains(locoKey)) return false;
}
return true;
}
String _classKey(String date, String classLabel) => '$date|$classLabel';
String _locoKey(String date, String classLabel, String locoLabel) =>
'$date|$classLabel|$locoLabel';
List<_ChangeGroup> _groupChanges(List<LocoChange> changes) {
final dateFormat = DateFormat('yyyy-MM-dd');
final Map<String, Map<String, Map<String, List<LocoChange>>>> grouped = {};
final filtered = changes.where((change) {
final code = change.attrCode.toLowerCase();
return code != 'build_prec' && code != 'operational' && code != 'gettable';
});
for (final change in filtered) {
final date = change.approvedAt ?? change.validFrom;
final dateKey = date != null ? dateFormat.format(date) : 'Unknown date';
final classKey = change.locoClass.isNotEmpty
? change.locoClass
: 'Unknown class';
final locoKey = _locoLabel(change);
grouped.putIfAbsent(dateKey, () => {});
grouped[dateKey]!.putIfAbsent(classKey, () => {});
grouped[dateKey]![classKey]!.putIfAbsent(locoKey, () => []);
grouped[dateKey]![classKey]![locoKey]!.add(change);
}
final sortedDates = grouped.keys.toList()
..sort((a, b) {
if (a == 'Unknown date') return 1;
if (b == 'Unknown date') return -1;
return b.compareTo(a); // newest first
});
return sortedDates
.map(
(dateKey) => _ChangeGroup(
dateLabel: dateKey,
classGroups: grouped[dateKey]!.entries
.map(
(classEntry) => _ClassGroup(
classLabel: classEntry.key,
locoGroups: classEntry.value.entries
.map(
(locoEntry) => _LocoGroup(
locoLabel: locoEntry.key,
changes: locoEntry.value
..sort(
(a, b) => (b.approvedAt ?? b.validFrom ?? DateTime(0))
.compareTo(a.approvedAt ?? a.validFrom ?? DateTime(0)),
),
),
)
.toList(),
),
)
.toList(),
),
)
.toList();
}
Future<void> _loadMore() async {
final data = context.read<DataService>();
await data.fetchLatestLocoChanges(
offset: data.latestLocoChanges.length,
append: true,
);
}
}
class _ChangeGroup {
final String dateLabel;
final List<_ClassGroup> classGroups;
_ChangeGroup({required this.dateLabel, required this.classGroups});
}
class _LocoGroup {
final String locoLabel;
final List<LocoChange> changes;
_LocoGroup({required this.locoLabel, required this.changes});
}
class _ClassGroup {
final String classLabel;
final List<_LocoGroup> locoGroups;
_ClassGroup({required this.classLabel, required this.locoGroups});
}
String _locoLabel(LocoChange change) {
final number = change.locoNumber.trim();
final name = change.locoName.trim();
if (number.isNotEmpty && name.isNotEmpty) return '$number$name';
if (number.isNotEmpty) return number;
if (name.isNotEmpty) return name;
return 'Loco ${change.locoId}';
}

View File

@@ -1,83 +0,0 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/dataService.dart';
import 'package:provider/provider.dart';
class LeaderboardPanel extends StatelessWidget {
const LeaderboardPanel({super.key});
@override
Widget build(BuildContext context) {
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(
padding: const EdgeInsets.all(10.0),
child: Card(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Leaderboard",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
decoration: TextDecoration.underline,
),
),
if (leaderboard.isEmpty)
const Padding(
padding: EdgeInsets.all(16.0),
child: Text('No leaderboard data yet'),
)
else
Column(
children: List.generate(
leaderboard.length,
(index) {
final leaderboardEntry = leaderboard[index];
return Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(
horizontal: 0, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text.rich(
TextSpan(
children: [
TextSpan(
text: '${index + 1}. ',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
TextSpan(
text: leaderboardEntry.userFullName,
),
],
),
),
Text(
'${leaderboardEntry.mileage.toStringAsFixed(1)} mi',
),
],
),
),
);
},
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/widgets/animated_count_text.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
enum _LeaderboardScope { global, friends }
class LeaderboardPanel extends StatefulWidget {
const LeaderboardPanel({super.key});
@override
State<LeaderboardPanel> createState() => _LeaderboardPanelState();
}
class _LeaderboardPanelState extends State<LeaderboardPanel> {
_LeaderboardScope _scope = _LeaderboardScope.global;
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final leaderboard = _scope == _LeaderboardScope.global
? (data.homepageStats?.leaderboard ?? [])
: data.friendsLeaderboard;
final loading = _scope == _LeaderboardScope.global
? data.isHomepageLoading
: data.isFriendsLeaderboardLoading;
final textTheme = Theme.of(context).textTheme;
if (loading && leaderboard.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
);
}
return Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.emoji_events, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
"Leaderboard",
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
if (leaderboard.isNotEmpty &&
MediaQuery.of(context).size.width > 600)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'Top ${leaderboard.length}',
style: textTheme.labelSmall,
),
),
],
),
const SizedBox(height: 8),
Center(
child: SegmentedButton<_LeaderboardScope>(
segments: const [
ButtonSegment(
value: _LeaderboardScope.global,
label: Text('Global'),
),
ButtonSegment(
value: _LeaderboardScope.friends,
label: Text('Friends'),
),
],
selected: {_scope},
onSelectionChanged: (vals) async {
if (vals.isEmpty) return;
final selected = vals.first;
setState(() => _scope = selected);
if (selected == _LeaderboardScope.friends &&
data.friendsLeaderboard.isEmpty &&
!data.isFriendsLeaderboardLoading) {
await data.fetchFriendsLeaderboard();
} else if (selected == _LeaderboardScope.global &&
(data.homepageStats?.leaderboard.isEmpty ?? true) &&
!data.isHomepageLoading) {
await data.fetchHomepageStats();
}
},
style: SegmentedButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
),
),
const SizedBox(height: 8),
if (leaderboard.isEmpty)
const Padding(
padding: EdgeInsets.all(8.0),
child: Text('No leaderboard data yet'),
)
else
Column(
children: [
for (int index = 0; index < leaderboard.length; index++) ...[
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: CircleAvatar(
radius: 18,
backgroundColor:
Theme.of(context).colorScheme.secondaryContainer,
child: Text(
'${index + 1}',
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
title: Text(
leaderboard[index].userFullName,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
trailing: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
children: [
AnimatedCountText(
value: leaderboard[index].mileage,
formatter: (val) =>
distanceUnits.format(val, decimals: 1),
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
Builder(
builder: (ctx) => IconButton(
tooltip: 'View profile',
icon: const Icon(Icons.open_in_new, size: 20),
onPressed: () {
final auth = ctx.read<AuthService>();
final userId = leaderboard[index].userId;
if (auth.userId == userId) {
ctx.go('/more/profile');
} else {
ctx.pushNamed(
'user-profile',
queryParameters: {'user_id': userId},
);
}
},
),
),
],
),
),
if (index != leaderboard.length - 1) const Divider(height: 12),
],
],
),
],
),
),
);
}
}

View File

@@ -1,93 +0,0 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/dataService.dart';
import 'package:provider/provider.dart';
class TopTractionPanel extends StatelessWidget {
const TopTractionPanel({super.key});
@override
Widget build(BuildContext context) {
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(
padding: const EdgeInsets.all(10.0),
child: Card(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Top Traction",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
decoration: TextDecoration.underline,
),
),
if (locos.isEmpty)
const Padding(
padding: EdgeInsets.all(16.0),
child: Text('No traction data yet'),
)
else
Column(
children: List.generate(
locos.length,
(index) {
final loco = locos[index];
return Container(
width: double.infinity,
margin:
const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
children: [
TextSpan(
text: '${index + 1}. ',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
TextSpan(
text:
'${loco.locoClass} ${loco.number}',
),
],
),
),
Text(
loco.name ?? '',
style:
const TextStyle(fontStyle: FontStyle.italic),
),
],
),
Text('${loco.mileage?.toStringAsFixed(1)} mi'),
],
),
),
);
},
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/components/widgets/animated_count_text.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class TopTractionPanel extends StatelessWidget {
const TopTractionPanel({super.key});
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final stats = data.homepageStats;
final locos = stats?.topLocos ?? [];
final textTheme = Theme.of(context).textTheme;
if (data.isHomepageLoading && locos.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
);
}
return Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.train, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
"Top traction",
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
],
),
const SizedBox(height: 8),
if (locos.isEmpty)
const Padding(
padding: EdgeInsets.all(8.0),
child: Text('No traction data yet'),
)
else
Column(
children: [
for (int index = 0; index < locos.length; index++) ...[
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: CircleAvatar(
radius: 18,
backgroundColor:
Theme.of(context).colorScheme.primaryContainer,
child: Text(
'${index + 1}',
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
title: Text(
'${locos[index].locoClass} ${locos[index].number}',
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
subtitle: (locos[index].name ?? '').isEmpty
? null
: Text(
locos[index].name ?? '',
style: textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
),
),
trailing: AnimatedCountText(
value: (locos[index].mileage ?? 0).toDouble(),
formatter: (val) =>
distanceUnits.format(val, decimals: 1),
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (index != locos.length - 1) const Divider(height: 12),
],
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,596 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class LegCard extends StatefulWidget {
const LegCard({
super.key,
required this.leg,
this.showEditButton = true,
this.showDate = true,
});
final Leg leg;
final bool showEditButton;
final bool showDate;
@override
State<LegCard> createState() => _LegCardState();
}
class _LegCardState extends State<LegCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final leg = widget.leg;
final isShared = leg.legShareId != null && leg.legShareId!.isNotEmpty;
final sharedFrom = leg.sharedFrom;
final sharedTo = leg.sharedTo;
final distanceUnits = context.watch<DistanceUnitService>();
final routeSegments = _parseRouteSegments(leg.route);
final networkMileage = _sortedNetworkMileage(leg);
final countryMileage = _sortedCountryMileage(leg);
final textTheme = Theme.of(context).textTheme;
return Card(
clipBehavior: Clip.antiAlias,
elevation: 1,
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
onExpansionChanged: (v) => setState(() => _expanded = v),
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: const Icon(Icons.train),
title: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth > 520;
final beginTimeWidget = _timeWithDelay(
context,
leg.beginTime,
leg.beginDelayMinutes,
includeDate: widget.showDate,
);
final endTimeWidget = leg.endTime == null
? null
: _timeWithDelay(
context,
leg.endTime!,
leg.endDelayMinutes,
includeDate: widget.showDate,
);
final routeText = Text(
'${leg.start}${leg.end}',
softWrap: true,
);
if (!isWide) {
final timeStyle = Theme.of(context).textTheme.labelSmall;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
routeText,
const SizedBox(height: 2),
Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_timeWithDelay(
context,
leg.beginTime,
leg.beginDelayMinutes,
includeDate: widget.showDate,
style: timeStyle,
),
if (endTimeWidget != null) ...[
const Text('·'),
_timeWithDelay(
context,
leg.endTime!,
leg.endDelayMinutes,
includeDate: widget.showDate,
style: timeStyle,
),
],
],
),
],
);
}
return Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
beginTimeWidget,
const Text('·'),
routeText,
if (endTimeWidget != null) ...[
const Text('·'),
endTimeWidget,
],
],
);
},
),
subtitle: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth > 520;
final tractionWrap = !_expanded && leg.locos.isNotEmpty
? Wrap(
spacing: 8,
runSpacing: 4,
children: leg.locos.map((loco) {
final iconColor = loco.powering
? Theme.of(context).colorScheme.primary
: Theme.of(context).hintColor;
final label = '${loco.locoClass} ${loco.number}'.trim();
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.train, size: 14, color: iconColor),
const SizedBox(width: 4),
Text(
label.isEmpty ? 'Loco ${loco.id}' : label,
style: textTheme.labelSmall,
),
],
);
}).toList(),
)
: null;
final children = <Widget>[];
if (isWide) {
if (tractionWrap != null) {
children.add(tractionWrap);
}
} else {
if (tractionWrap != null) {
children.add(tractionWrap);
}
}
if (leg.headcode.isNotEmpty) {
children.add(
Text(
'Headcode: ${leg.headcode}',
style: textTheme.labelSmall,
),
);
}
final networkSummary = _networkSummary(leg);
if (networkSummary != null) {
children.add(
Text(
networkSummary,
style: textTheme.labelSmall,
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
);
},
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
distanceUnits.format(leg.mileage, decimals: 1),
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (leg.tripId != 0) ...[
const SizedBox(height: 2),
Text(
'Trip #${leg.tripId}',
style: textTheme.labelSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
if (isShared || sharedFrom != null || (sharedTo.isNotEmpty)) ...[
const SizedBox(width: 8),
_SharedIcons(sharedFrom: sharedFrom, sharedTo: sharedTo, isShared: isShared),
],
if (widget.showEditButton) ...[
const SizedBox(width: 8),
IconButton(
tooltip: 'Edit entry',
icon: const Icon(Icons.edit),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => context.push('/legs/edit/${leg.id}'),
),
if (_expanded) ...[
const SizedBox(width: 4),
IconButton(
tooltip: 'Delete entry',
icon: const Icon(Icons.delete_outline),
color: Theme.of(context).colorScheme.error,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => _confirmDelete(context, leg.id),
),
],
],
],
),
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 (_hasTrainDetails(leg)) ...[
Text('Train', style: textTheme.titleSmall),
const SizedBox(height: 6),
..._buildTrainDetails(leg, textTheme),
const SizedBox(height: 12),
],
if (sharedFrom != null)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
'Shared from ${sharedFrom.sharedFromDisplay.isNotEmpty ? sharedFrom.sharedFromDisplay : 'another user'}.',
style: textTheme.bodyMedium,
),
),
if (sharedTo.isNotEmpty) ...[
Text(
'Shared to:',
style: textTheme.bodyMedium,
),
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 4,
children: sharedTo
.map((s) => Chip(
label: Text(s.sharedToDisplay.isNotEmpty
? s.sharedToDisplay
: s.sharedToUserId),
visualDensity: VisualDensity.compact,
))
.toList(),
),
const SizedBox(height: 12),
],
if (networkMileage.isNotEmpty || countryMileage.isNotEmpty) ...[
Text('Network mileage', style: textTheme.titleSmall),
const SizedBox(height: 6),
...networkMileage.map(
(entry) => Text(
'${entry.network}: ${distanceUnits.format(entry.miles, decimals: 1)}',
style: textTheme.bodyMedium,
),
),
if (countryMileage.isNotEmpty) ...[
const SizedBox(height: 8),
Text('Country mileage', style: textTheme.titleSmall),
const SizedBox(height: 6),
...countryMileage.map(
(entry) => Text(
'${entry.country}: ${distanceUnits.format(entry.miles, decimals: 1)}',
style: textTheme.bodyMedium,
),
),
],
const SizedBox(height: 12),
],
if (routeSegments.isNotEmpty) ...[
Text('Route', style: textTheme.titleSmall),
const SizedBox(height: 6),
_buildRouteList(routeSegments),
],
],
),
),
],
),
),
);
}
Future<void> _confirmDelete(BuildContext context, int legId) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete entry?'),
content: const Text('Are you sure you want to delete this entry?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true) return;
if (!context.mounted) return;
final data = context.read<DataService>();
final messenger = ScaffoldMessenger.of(context);
try {
await data.api.delete('/legs/delete?leg_id=$legId');
await data.refreshLegs();
messenger.showSnackBar(const SnackBar(content: Text('Entry deleted')));
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to delete entry: $e')),
);
}
}
Widget _timeWithDelay(
BuildContext context,
DateTime time,
int? delay, {
bool includeDate = true,
TextStyle? style,
}) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final delayMinutes = delay ?? 0;
final delayText =
delayMinutes == 0 ? null : '${delayMinutes > 0 ? '+' : ''}$delayMinutes';
final delayColor = delayMinutes == 0
? null
: (delayMinutes < 0 ? Colors.green : colorScheme.error);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatDateTime(time, includeDate: includeDate),
style: style,
),
if (delayText != null) ...[
const SizedBox(width: 4),
Text(
'$delayText m',
style:
(style ?? textTheme.labelSmall)?.copyWith(color: delayColor),
),
],
],
);
}
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')}';
}
String _formatTime(DateTime date) {
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
String _formatDateTime(DateTime date, {bool includeDate = true}) {
final timeStr = _formatTime(date);
if (!includeDate) return timeStr;
final dateStr = _formatDate(date);
return '$dateStr · $timeStr';
}
List<Widget> _buildLocoChips(BuildContext context, Leg leg) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
return leg.locos
.map(
(loco) {
final powering = loco.powering == true;
final iconColor =
powering ? theme.colorScheme.primary : theme.disabledColor;
final labelStyle = powering
? null
: textTheme.bodyMedium?.copyWith(color: theme.disabledColor);
final background = powering
? theme.colorScheme.surfaceContainerHighest
: theme.colorScheme.surfaceContainerLow;
return Chip(
label: Text(
'${loco.locoClass} ${loco.number}',
style: labelStyle,
),
avatar: Icon(
Icons.directions_railway,
size: 16,
color: iconColor,
),
backgroundColor: background,
);
},
)
.toList();
}
bool _hasTrainDetails(Leg leg) {
return leg.headcode.isNotEmpty ||
leg.origin.isNotEmpty ||
leg.destination.isNotEmpty ||
leg.originTime != null ||
leg.destinationTime != null;
}
List<Widget> _buildTrainDetails(Leg leg, TextTheme textTheme) {
final widgets = <Widget>[];
if (leg.headcode.isNotEmpty) {
widgets.add(
Text(
'Headcode: ${leg.headcode}',
style: textTheme.bodyMedium,
),
);
}
final originLine = _locationLine(
'Origin',
leg.origin,
leg.originTime,
);
if (originLine != null) {
widgets.add(Text(originLine, style: textTheme.bodyMedium));
}
final destinationLine = _locationLine(
'Destination',
leg.destination,
leg.destinationTime,
);
if (destinationLine != null) {
widgets.add(Text(destinationLine, style: textTheme.bodyMedium));
}
return widgets;
}
String? _locationLine(String label, String location, DateTime? time) {
final parts = <String>[];
if (location.trim().isNotEmpty) parts.add(location.trim());
if (time != null) parts.add(_formatDateTime(time));
if (parts.isEmpty) return null;
return '$label: ${parts.join(' · ')}';
}
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(List<String> route) {
return route.map((e) => e.toString()).where((e) => e.trim().isNotEmpty).toList();
}
List<NetworkMileage> _sortedNetworkMileage(Leg leg) {
final items = leg.networkMileage
.where((entry) => entry.network.trim().isNotEmpty)
.toList();
items.sort((a, b) => b.miles.compareTo(a.miles));
return items;
}
List<CountryMileage> _sortedCountryMileage(Leg leg) {
final items = leg.countryMileage
.where((entry) => entry.country.trim().isNotEmpty)
.toList();
items.sort((a, b) => b.miles.compareTo(a.miles));
return items;
}
String? _networkSummary(Leg leg) {
final networks = _sortedNetworkMileage(leg);
if (networks.isNotEmpty) {
return networks.map((entry) => entry.network).join(', ');
}
if (leg.network.trim().isNotEmpty) {
return leg.network;
}
return null;
}
}
class _SharedIcons extends StatelessWidget {
const _SharedIcons({
required this.sharedFrom,
required this.sharedTo,
required this.isShared,
});
final LegShareMeta? sharedFrom;
final List<LegShareMeta> sharedTo;
final bool isShared;
@override
Widget build(BuildContext context) {
final icons = <Widget>[];
if (isShared || sharedFrom != null) {
final fromName = sharedFrom?.sharedFromDisplay ?? '';
icons.add(
Tooltip(
message: fromName.isNotEmpty ? 'Shared from $fromName' : 'Shared entry',
child: Icon(
Icons.share,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
),
);
}
if (sharedTo.isNotEmpty) {
final names = sharedTo
.map((s) => s.sharedToDisplay)
.where((name) => name.isNotEmpty)
.toList();
final tooltip = names.isEmpty
? 'Shared to others'
: 'Shared to: ${names.join(', ')}';
icons.add(
Tooltip(
message: tooltip,
child: Icon(
Icons.group,
size: 18,
color: Theme.of(context).colorScheme.tertiary,
),
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < icons.length; i++) ...[
if (i > 0) const SizedBox(width: 6),
icons[i],
],
],
);
}
}

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/components/pages/settings.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
@@ -16,7 +19,9 @@ class _LoginScreenState extends State<LoginScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _checkExistingSession()); WidgetsBinding.instance.addPostFrameCallback(
(_) => _checkExistingSession(),
);
} }
Future<void> _checkExistingSession() async { Future<void> _checkExistingSession() async {
@@ -26,7 +31,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (!valid) return; if (!valid) return;
await auth.tryRestoreSession(); await auth.tryRestoreSession();
if (!mounted) return; if (!mounted) return;
context.go('/'); context.go('/dashboard');
} finally { } finally {
if (mounted) setState(() => _checkingSession = false); if (mounted) setState(() => _checkingSession = false);
} }
@@ -38,17 +43,29 @@ class _LoginScreenState extends State<LoginScreen> {
resizeToAvoidBottomInset: true, resizeToAvoidBottomInset: true,
body: Container( body: Container(
color: Theme.of(context).scaffoldBackgroundColor, color: Theme.of(context).scaffoldBackgroundColor,
child: SafeArea(
child: Column(
children: [
Expanded(
child: Center( child: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text.rich( LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: constraints.maxWidth,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text.rich(
TextSpan( TextSpan(
children: [ children: [
TextSpan( TextSpan(
text: "Mile", text: "Mile",
style: TextStyle( style: TextStyle(
color: Theme.of(context).textTheme.bodyLarge?.color, color: Theme.of(
context,
).textTheme.bodyLarge?.color,
), ),
), ),
const TextSpan( const TextSpan(
@@ -58,7 +75,9 @@ class _LoginScreenState extends State<LoginScreen> {
TextSpan( TextSpan(
text: "graph", text: "graph",
style: TextStyle( style: TextStyle(
color: Theme.of(context).textTheme.bodyLarge?.color, color: Theme.of(
context,
).textTheme.bodyLarge?.color,
), ),
), ),
], ],
@@ -69,18 +88,56 @@ class _LoginScreenState extends State<LoginScreen> {
fontSize: 50, fontSize: 50,
), ),
), ),
softWrap: false,
overflow: TextOverflow.visible,
), ),
if (_checkingSession) ),
const Padding( );
padding: EdgeInsets.only(top: 12), },
child: SizedBox( ),
const SizedBox(height: 40),
if (_checkingSession) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
height: 24, height: 24,
width: 24, width: 24,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
), ),
const SizedBox(width: 8),
Text(
'Trying to log in',
style: Theme.of(context).textTheme.bodyMedium,
), ),
const SizedBox(height: 50), ],
),
] else ...[
const SizedBox(height: 10),
const LoginPanel(), const LoginPanel(),
const SizedBox(height: 16),
IconButton(
icon: const Icon(Icons.settings, color: Colors.grey),
tooltip: 'Settings',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => const SettingsPage(),
),
);
},
),
],
],
),
),
),
const Padding(
padding: EdgeInsets.only(bottom: 12),
child: _LoginLogo(),
),
], ],
), ),
), ),
@@ -89,6 +146,57 @@ class _LoginScreenState extends State<LoginScreen> {
} }
} }
class _LoginLogo extends StatefulWidget {
const _LoginLogo();
@override
State<_LoginLogo> createState() => _LoginLogoState();
}
class _LoginLogoState extends State<_LoginLogo> {
late final Future<String> _svgFuture;
@override
void initState() {
super.initState();
_svgFuture = rootBundle.loadString('assets/logos/pg_logo_v2.svg');
}
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
final grey = const Color(0xFF999999);
return SizedBox(
height: 42,
child: Opacity(
opacity: 0.75,
child: FutureBuilder<String>(
future: _svgFuture,
builder: (context, snapshot) {
final svg = snapshot.data;
if (svg == null) {
return const SizedBox.shrink();
}
final tinted = svg
.replaceAll('#00b7fd', _colorToHex(accent))
.replaceAll('#999999', _colorToHex(grey));
return SvgPicture.string(
tinted,
fit: BoxFit.contain,
semanticsLabel: 'Mileograph logo',
);
},
),
),
);
}
String _colorToHex(Color color) {
final hex = color.toARGB32().toRadixString(16).padLeft(8, '0');
return '#${hex.substring(2)}';
}
}
class LoginPanel extends StatefulWidget { class LoginPanel extends StatefulWidget {
const LoginPanel({super.key}); const LoginPanel({super.key});
@@ -151,6 +259,13 @@ class _LoginPanelContentState extends State<LoginPanelContent> {
bool _loggingIn = false; bool _loggingIn = false;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> login() async { Future<void> login() async {
final username = _usernameController.text; final username = _usernameController.text;
final password = _passwordController.text; final password = _passwordController.text;
@@ -166,14 +281,15 @@ class _LoginPanelContentState extends State<LoginPanelContent> {
setState(() { setState(() {
_loggingIn = false; _loggingIn = false;
}); });
context.go('/dashboard');
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_loggingIn = false; _loggingIn = false;
}); });
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Login failed: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Login failed: $e')));
} }
} }
@@ -261,6 +377,7 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _inviteController = TextEditingController(); final _inviteController = TextEditingController();
bool _registering = false; bool _registering = false;
String? _usernameError;
@override @override
void dispose() { void dispose() {
@@ -273,10 +390,21 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
} }
Future<void> _register() async { Future<void> _register() async {
final username = _usernameController.text.trim();
if (username.contains(' ') || username.contains('@')) {
setState(() {
_usernameError = 'Username cannot contain spaces or @';
});
return;
} else {
setState(() {
_usernameError = null;
});
}
setState(() => _registering = true); setState(() => _registering = true);
try { try {
await widget.authService.register( await widget.authService.register(
username: _usernameController.text.trim(), username: username,
email: _emailController.text.trim(), email: _emailController.text.trim(),
fullName: _displayNameController.text.trim(), fullName: _displayNameController.text.trim(),
password: _passwordController.text, password: _passwordController.text,
@@ -284,14 +412,16 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
); );
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Registration successful. Please log in.')), const SnackBar(
content: Text('Registration successful. Please log in.'),
),
); );
widget.onBack(); widget.onBack();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Registration failed: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Registration failed: $e')));
} finally { } finally {
if (mounted) setState(() => _registering = false); if (mounted) setState(() => _registering = false);
} }
@@ -328,6 +458,21 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
labelText: "Username", labelText: "Username",
), ),
onChanged: (_) {
if (_usernameError != null &&
!_usernameController.text.contains(' ') &&
!_usernameController.text.contains('@')) {
setState(() => _usernameError = null);
}
},
),
if (_usernameError != null)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
_usernameError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( TextField(

View File

@@ -0,0 +1,768 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
class BadgesPage extends StatefulWidget {
const BadgesPage({super.key});
@override
State<BadgesPage> createState() => _BadgesPageState();
}
class _BadgesPageState extends State<BadgesPage> {
bool _initialised = false;
final Map<String, bool> _groupExpanded = {};
bool _loadingAwards = false;
bool _loadingClassProgress = false;
bool _loadingLocoProgress = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_initialised) return;
_initialised = true;
_refreshAwards();
}
Future<void> _refreshAwards() {
_loadingAwards = false;
_loadingClassProgress = false;
_loadingLocoProgress = false;
final data = context.read<DataService>();
return Future.wait([
data.fetchBadgeAwards(limit: 20, badgeCode: 'class_clearance'),
data.fetchClassClearanceProgress(),
data.fetchLocoClearanceProgress(),
]);
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final awards = data.badgeAwards;
final loading = data.isBadgeAwardsLoading;
final classProgress = data.classClearanceProgress;
final classProgressLoading =
data.isClassClearanceProgressLoading || _loadingClassProgress;
final locoProgress = data.locoClearanceProgress;
final locoProgressLoading =
data.isLocoClearanceProgressLoading || _loadingLocoProgress;
final hasAnyData =
awards.isNotEmpty || classProgress.isNotEmpty || locoProgress.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: const Text('Badges'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop();
} else {
context.go('/more');
}
},
),
),
body: RefreshIndicator(
onRefresh: _refreshAwards,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if ((loading || classProgressLoading || locoProgressLoading) &&
!hasAnyData)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: CircularProgressIndicator(),
),
)
else if (!hasAnyData)
const Padding(
padding: EdgeInsets.symmetric(vertical: 12.0),
child: Text('No badges awarded yet.'),
)
else
..._buildGroupedAwards(
context,
awards,
classProgress,
locoProgress,
classProgressLoading,
locoProgressLoading,
data.classClearanceHasMore,
data.locoClearanceHasMore,
data.badgeAwardsHasMore,
loading,
),
],
),
),
);
}
List<Widget> _buildGroupedAwards(
BuildContext context,
List<BadgeAward> awards,
List<ClassClearanceProgress> classProgress,
List<LocoClearanceProgress> locoProgress,
bool classProgressLoading,
bool locoProgressLoading,
bool classProgressHasMore,
bool locoProgressHasMore,
bool badgeAwardsHasMore,
bool badgeAwardsLoading,
) {
final grouped = _groupAwards(awards);
if ((classProgress.isNotEmpty || classProgressLoading) &&
!grouped.containsKey('class_clearance')) {
grouped['class_clearance'] = [];
}
if ((locoProgress.isNotEmpty || locoProgressLoading) &&
!grouped.containsKey('loco_clearance')) {
grouped['loco_clearance'] = [];
}
final codes = _orderedBadgeCodes(grouped.keys.toList());
return codes.map((code) {
final items = grouped[code]!;
final expanded = _groupExpanded[code] ?? true;
final title = _formatBadgeName(code);
final isClass = code == 'class_clearance';
final isLoco = code == 'loco_clearance';
final classItems = isClass ? classProgress : <ClassClearanceProgress>[];
final locoItems = isLoco ? locoProgress : <LocoClearanceProgress>[];
final awardCount = isLoco
? locoItems.where((item) => item.awardedTiers.isNotEmpty).length
: items.length;
final isLoadingSection = isClass
? (classProgressLoading || badgeAwardsLoading || _loadingAwards)
: (isLoco ? locoProgressLoading : false);
final children = <Widget>[];
if (isClass && items.isNotEmpty) {
children.add(_buildSubheading(context, 'Awarded'));
children.addAll(
items.map(
(award) => Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
child: _buildAwardCard(context, award, compact: true),
),
),
);
if (badgeAwardsHasMore || badgeAwardsLoading || _loadingAwards) {
children.add(
Padding(
padding: const EdgeInsets.only(top: 4.0, bottom: 8.0),
child: _buildLoadMoreButton(
context,
badgeAwardsLoading || _loadingAwards,
() => _loadMoreAwards(),
),
),
);
}
} else if (!isClass && !isLoco && items.isNotEmpty) {
children.add(_buildSubheading(context, 'Awarded'));
children.addAll(
items.map(
(award) => Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
child: _buildAwardCard(context, award, compact: true),
),
),
);
}
if (isClass) {
children.addAll(
_buildClassProgressSection(
context,
classItems,
classProgressLoading,
classProgressHasMore,
),
);
}
if (isLoco) {
children.addAll(
_buildLocoProgressSection(
context,
locoItems,
locoProgressLoading,
locoProgressHasMore,
showHeading: false,
),
);
}
if (children.isEmpty && !isLoadingSection) {
children.add(
const Padding(
padding: EdgeInsets.symmetric(vertical: 6.0),
child: Text('No awards'),
),
);
}
return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0),
child: ExpansionTile(
key: ValueKey(code),
tilePadding: const EdgeInsets.symmetric(horizontal: 12.0),
title: Row(
children: [
Expanded(child: Text(title)),
if (isLoadingSection) ...[
const SizedBox(width: 8),
const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
const SizedBox(width: 8),
_buildCountChip(context, awardCount),
],
),
initiallyExpanded: expanded,
onExpansionChanged: (isOpen) {
setState(() => _groupExpanded[code] = isOpen);
},
children: children,
),
);
}).toList();
}
Map<String, List<BadgeAward>> _groupAwards(List<BadgeAward> awards) {
final Map<String, List<BadgeAward>> grouped = {};
for (final award in awards) {
final code = award.badgeCode.toLowerCase();
grouped.putIfAbsent(code, () => []).add(award);
}
return grouped;
}
Widget _buildAwardCard(
BuildContext context,
BadgeAward award, {
bool compact = false,
}) {
final badgeName = _formatBadgeName(award.badgeCode);
final tier = award.badgeTier.isNotEmpty
? award.badgeTier[0].toUpperCase() + award.badgeTier.substring(1)
: '';
final tierIcon = _buildTierIcon(award.badgeTier);
final scope = _scopeToShow(award);
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (tierIcon != null) ...[
tierIcon,
const SizedBox(width: 8),
],
Expanded(
child: Text(
'$badgeName$tier',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (award.awardedAt != null)
Text(
_formatAwardDate(award.awardedAt!),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
if (scope != null && scope.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
scope,
style: Theme.of(context).textTheme.bodyMedium,
),
],
if (award.loco != null) ...[
const SizedBox(height: 6),
_buildLocoInfo(context, award.loco!),
],
],
);
if (compact) {
return content;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(10.0),
child: content,
),
);
}
Widget _buildLocoInfo(BuildContext context, LocoSummary loco) {
final lines = <String>[];
final classNum = [
if (loco.locoClass.isNotEmpty) loco.locoClass,
if (loco.number.isNotEmpty) loco.number,
].join(' ');
if (classNum.isNotEmpty) lines.add(classNum);
if ((loco.name ?? '').isNotEmpty) lines.add(loco.name!);
if ((loco.livery ?? '').isNotEmpty) lines.add(loco.livery!);
if ((loco.location ?? '').isNotEmpty) lines.add(loco.location!);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.train, size: 20),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: lines.map((line) {
return Text(
line,
style: Theme.of(context).textTheme.bodyMedium,
);
}).toList(),
),
),
],
);
}
String _formatBadgeName(String code) {
if (code.isEmpty) return 'Badge';
const known = {
'class_clearance': 'Class Clearance',
'loco_clearance': 'Loco Clearance',
};
final lower = code.toLowerCase();
if (known.containsKey(lower)) return known[lower]!;
final parts = code.split(RegExp(r'[_\\s]+')).where((p) => p.isNotEmpty);
return parts
.map((p) => p[0].toUpperCase() + p.substring(1).toLowerCase())
.join(' ');
}
List<String> _orderedBadgeCodes(List<String> codes) {
final lowerCodes = codes.map((c) => c.toLowerCase()).toSet();
final ordered = <String>[];
for (final code in ['loco_clearance', 'class_clearance']) {
if (lowerCodes.remove(code)) ordered.add(code);
}
final remaining = lowerCodes.toList()
..sort((a, b) => _formatBadgeName(a).compareTo(_formatBadgeName(b)));
ordered.addAll(remaining);
return ordered;
}
Widget _buildSubheading(BuildContext context, String label) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text(
label,
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(fontWeight: FontWeight.w700),
),
);
}
List<Widget> _buildClassProgressSection(
BuildContext context,
List<ClassClearanceProgress> progress,
bool isLoading,
bool hasMore,
) {
if (progress.isEmpty && !isLoading && !hasMore) return const [];
return [
_buildSubheading(context, 'In Progress'),
...progress.map(
(item) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0),
child: _buildClassProgressCard(context, item),
),
),
if (hasMore || isLoading)
Padding(
padding: const EdgeInsets.only(top: 4.0, bottom: 8.0),
child: _buildLoadMoreButton(
context,
isLoading,
() => _loadMoreClassProgress(),
),
),
if (progress.isNotEmpty) const SizedBox(height: 4),
];
}
List<Widget> _buildLocoProgressSection(
BuildContext context,
List<LocoClearanceProgress> progress,
bool isLoading,
bool hasMore,
{bool showHeading = true}
) {
if (progress.isEmpty && !isLoading && !hasMore) return const [];
return [
if (showHeading) _buildSubheading(context, 'In Progress'),
if (progress.isEmpty && isLoading)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: _buildLoadingIndicator(),
),
...progress.map(
(item) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0),
child: _buildLocoProgressCard(context, item),
),
),
if (hasMore || isLoading)
Padding(
padding: const EdgeInsets.only(top: 4.0, bottom: 8.0),
child: _buildLoadMoreButton(
context,
isLoading,
() => _loadMoreLocoProgress(),
),
),
if (progress.isNotEmpty) const SizedBox(height: 4),
];
}
Widget _buildClassProgressCard(
BuildContext context,
ClassClearanceProgress progress,
) {
final pct = progress.percentComplete.clamp(0, 100);
final activePct = progress.activePercent.clamp(0, 100);
final showActive =
progress.activeTotal > 0 || progress.activeCompleted > 0;
final showActiveCrest =
progress.activeTotal > 0 && progress.activeCompleted == progress.activeTotal;
final scheme = Theme.of(context).colorScheme;
final total = progress.total;
final activeTotal = progress.activeTotal.clamp(0, total);
final had = progress.completed.clamp(0, total);
final activeHad = progress.activeCompleted.clamp(0, activeTotal).clamp(0, had);
final activeRemaining =
(activeTotal - activeHad).clamp(0, total - had);
final remaining =
(total - activeTotal - (had - activeHad)).clamp(0, total);
final hadColor = scheme.primary;
final activeColor = scheme.primary.withValues(alpha: 0.4);
final remainingColor = scheme.primary.withValues(alpha: 0.18);
return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
progress.className,
style: Theme.of(context).textTheme.bodyMedium,
),
),
Text(
'${pct.toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.labelMedium,
),
],
),
const SizedBox(height: 4),
if (showActive)
Padding(
padding: const EdgeInsets.only(bottom: 6.0),
child: Row(
children: [
Text(
'Active: ${progress.activeCompleted}/${progress.activeTotal} (${activePct.toStringAsFixed(0)}%)',
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(color: Theme.of(context).hintColor),
),
if (showActiveCrest) ...[
const SizedBox(width: 6),
Icon(
Icons.verified,
size: 14,
color: scheme.primary,
),
],
],
),
),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
height: 6,
child: total <= 0
? Container(color: remainingColor)
: Row(
children: [
if (had > 0)
Expanded(
flex: had,
child: Container(color: hadColor),
),
if (showActive && activeRemaining > 0)
Expanded(
flex: activeRemaining,
child: Container(color: activeColor),
),
if (remaining > 0)
Expanded(
flex: remaining,
child: Container(color: remainingColor),
),
],
),
),
),
if (progress.total > 0)
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
'${progress.completed}/${progress.total}',
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(color: Theme.of(context).hintColor),
),
),
],
),
),
);
}
Widget _buildLocoProgressCard(
BuildContext context,
LocoClearanceProgress progress,
) {
final tierIcons = progress.awardedTiers
.map((tier) => _buildTierIcon(tier, size: 18))
.whereType<Widget>()
.toList();
final reachedTopTier = progress.nextTier.isEmpty;
final pct = progress.percent.clamp(0, 100);
final nextTier = progress.nextTier.isNotEmpty
? progress.nextTier[0].toUpperCase() + progress.nextTier.substring(1)
: 'Next';
final loco = progress.loco;
final title = [
if (loco.number.isNotEmpty) loco.number,
if (loco.locoClass.isNotEmpty) loco.locoClass,
].join('');
return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0),
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title.isNotEmpty ? title : 'Loco',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (tierIcons.isNotEmpty)
Row(
children: tierIcons
.expand((icon) sync* {
yield icon;
yield const SizedBox(width: 4);
})
.toList()
..removeLast(),
),
],
),
if ((loco.name ?? '').isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
loco.name ?? '',
style: Theme.of(context).textTheme.bodySmall,
),
),
if (!reachedTopTier) ...[
const SizedBox(height: 4),
LinearProgressIndicator(
value: progress.required == 0 ? 0 : pct / 100,
minHeight: 6,
),
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
'${pct.toStringAsFixed(0)}% to $nextTier award',
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
],
),
),
);
}
Widget _buildLoadMoreButton(
BuildContext context,
bool isLoading,
Future<void> Function() onPressed,
) {
return Align(
alignment: Alignment.center,
child: OutlinedButton.icon(
onPressed: isLoading
? null
: () {
onPressed();
},
icon: isLoading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.expand_more),
label: Text(isLoading ? 'Loading...' : 'Load more'),
),
);
}
Widget _buildLoadingIndicator() {
return const Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
Widget _buildCountChip(BuildContext context, int count) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'$count',
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(fontWeight: FontWeight.w700),
),
);
}
Future<void> _loadMoreClassProgress() {
final data = context.read<DataService>();
if (data.isClassClearanceProgressLoading || _loadingClassProgress) {
return Future.value();
}
setState(() => _loadingClassProgress = true);
return data
.fetchClassClearanceProgress(
offset: data.classClearanceProgress.length,
append: true,
)
.whenComplete(() {
if (mounted) setState(() => _loadingClassProgress = false);
});
}
Future<void> _loadMoreLocoProgress() {
final data = context.read<DataService>();
if (data.isLocoClearanceProgressLoading || _loadingLocoProgress) {
return Future.value();
}
setState(() => _loadingLocoProgress = true);
return data
.fetchLocoClearanceProgress(
offset: data.locoClearanceProgress.length,
append: true,
)
.whenComplete(() {
if (mounted) setState(() => _loadingLocoProgress = false);
});
}
Future<void> _loadMoreAwards() {
final data = context.read<DataService>();
if (data.isBadgeAwardsLoading || _loadingAwards) return Future.value();
setState(() => _loadingAwards = true);
return data
.fetchBadgeAwards(
offset: data.badgeAwards.length,
append: true,
badgeCode: 'class_clearance',
limit: 20,
)
.whenComplete(() {
if (mounted) setState(() => _loadingAwards = false);
});
}
String _formatAwardDate(DateTime date) {
final y = date.year.toString().padLeft(4, '0');
final m = date.month.toString().padLeft(2, '0');
final d = date.day.toString().padLeft(2, '0');
return '$y-$m-$d';
}
Widget? _buildTierIcon(String tier, {double size = 24}) {
final lower = tier.toLowerCase();
Color? color;
switch (lower) {
case 'bronze':
color = const Color(0xFFCD7F32);
break;
case 'silver':
color = const Color(0xFFC0C0C0);
break;
case 'gold':
color = const Color(0xFFFFD700);
break;
}
if (color == null) return null;
return Icon(Icons.emoji_events, color: color, size: size);
}
String? _scopeToShow(BadgeAward award) {
final scope = award.scopeValue?.trim() ?? '';
if (scope.isEmpty) return null;
final code = award.badgeCode.toLowerCase();
if (code == 'loco_clearance') {
// Hide numeric loco IDs; loco details are shown separately.
if (int.tryParse(scope) != null) return null;
}
return scope;
}
}

View File

@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/calculator/route_summary_widget.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class CalculatorDetailsPage extends StatelessWidget {
const CalculatorDetailsPage({
super.key,
required this.result,
});
final Object? result;
@override
Widget build(BuildContext context) {
final parsed = result is RouteResult ? result as RouteResult : null;
if (parsed == null) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextButton.icon(
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back),
label: const Text('Back'),
),
const SizedBox(height: 12),
const Text(
'No route details available.',
),
],
),
);
}
final networks = List<NetworkMileage>.from(parsed.networkMileage)
..sort((a, b) => b.miles.compareTo(a.miles));
final countries = List<CountryMileage>.from(parsed.countryMileage)
..sort((a, b) => b.miles.compareTo(a.miles));
return Scaffold(
endDrawer: _NetworksDrawer(
networks: networks,
countries: countries,
),
body: Builder(
builder: (scaffoldContext) => Padding(
padding: const EdgeInsets.all(16.0),
child: RouteDetailsView(
route: parsed.calculatedRoute,
costs: parsed.costs,
routingPoints: parsed.inputRoute.toSet(),
onBack: () => context.pop(),
onNetworksPressed: () =>
Scaffold.of(scaffoldContext).openEndDrawer(),
),
),
),
);
}
}
class _NetworksDrawer extends StatelessWidget {
const _NetworksDrawer({
required this.networks,
required this.countries,
});
final List<NetworkMileage> networks;
final List<CountryMileage> countries;
@override
Widget build(BuildContext context) {
final distanceUnits = context.watch<DistanceUnitService>();
return Drawer(
child: SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Networks',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
if (networks.isEmpty)
const Text('No network mileage data.')
else
...networks.map(
(entry) => ListTile(
contentPadding: EdgeInsets.zero,
title: Text(entry.network),
trailing:
Text(distanceUnits.format(entry.miles, decimals: 2)),
),
),
const SizedBox(height: 16),
Text(
'Countries',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
if (countries.isEmpty)
const Text('No country mileage data.')
else
...countries.map(
(entry) => ListTile(
contentPadding: EdgeInsets.zero,
title: Text(entry.country),
trailing:
Text(distanceUnits.format(entry.miles, decimals: 2)),
),
),
],
),
),
);
}
}

View File

@@ -1,10 +1,17 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/dashboard/leaderboardPanel.dart'; import 'package:intl/intl.dart';
import 'package:mileograph_flutter/components/dashboard/topTractionPanel.dart'; import 'package:mileograph_flutter/components/dashboard/latest_loco_changes_panel.dart';
import 'package:mileograph_flutter/components/dashboard/leaderboard_panel.dart';
import 'package:mileograph_flutter/components/dashboard/top_traction_panel.dart';
import 'package:mileograph_flutter/components/widgets/animated_count_text.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class Dashboard extends StatefulWidget { class Dashboard extends StatefulWidget {
@@ -16,60 +23,50 @@ class Dashboard extends StatefulWidget {
class _DashboardState extends State<Dashboard> { class _DashboardState extends State<Dashboard> {
bool _showAllOnThisDay = false; bool _showAllOnThisDay = false;
bool _isCurrent = false;
Timer? _carouselTimer;
int _carouselIndex = 0;
int _carouselItemCount = 0;
final Random _carouselRandom = Random();
List<ClassClearanceProgress> _carouselItems = const [];
String _carouselSignature = '';
@override
void initState() {
super.initState();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final auth = context.watch<AuthService>(); final auth = context.watch<AuthService>();
final distanceUnits = context.watch<DistanceUnitService>();
final stats = data.homepageStats; final stats = data.homepageStats;
final isInitialLoading = data.isHomepageLoading || stats == null; final isInitialLoading = data.isHomepageLoading && stats == null;
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
await data.fetchHomepageStats(); await _refreshDashboardData(force: true);
await Future.wait([
data.fetchOnThisDay(),
data.fetchTripDetails(),
data.fetchHadTraction(),
]);
}, },
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isWide = constraints.maxWidth > 1100; _handleRouteFocus();
final metricChips = _buildMetricChips( const spacing = 16.0;
context, final maxWidth = constraints.maxWidth;
totalMileage: stats?.totalMileage ?? 0,
currentYearMileage: data.getMileageForCurrentYear(),
trips: data.trips.length,
);
return Stack( return Stack(
children: [ children: [
ListView( ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
_buildHeader(context, auth, stats, data.isHomepageLoading), _buildHero(context, auth, data, stats, distanceUnits),
const SizedBox(height: 12), const SizedBox(height: spacing),
Wrap(spacing: 12, runSpacing: 12, children: metricChips), _buildTiles(
const SizedBox(height: 16), context,
isWide data,
? Row( distanceUnits,
crossAxisAlignment: CrossAxisAlignment.start, maxWidth,
children: [ spacing,
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),
],
), ),
], ],
), ),
@@ -78,7 +75,7 @@ class _DashboardState extends State<Dashboard> {
child: Container( child: Container(
color: Theme.of( color: Theme.of(
context, context,
).colorScheme.surface.withOpacity(0.7), ).colorScheme.surface.withValues(alpha: 0.7),
child: const Center( child: const Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -98,104 +95,424 @@ class _DashboardState extends State<Dashboard> {
); );
} }
Widget _buildHeader( Widget _buildHero(
BuildContext context, BuildContext context,
AuthService auth, AuthService auth,
DataService data,
HomepageStats? stats, HomepageStats? stats,
bool loading, DistanceUnitService distanceUnits,
) { ) {
final colorScheme = Theme.of(context).colorScheme;
final greetingName = final greetingName =
stats?.user?.full_name ?? auth.fullName ?? auth.username ?? 'there'; stats?.user?.fullName ?? auth.fullName ?? auth.username ?? 'there';
return Row( final totalMileage = stats?.totalMileage ?? 0;
mainAxisAlignment: MainAxisAlignment.spaceBetween, final currentYearMileage = data.getMileageForCurrentYear();
final legCount = stats?.legCount ?? data.trips.length;
return Card(
clipBehavior: Clip.antiAlias,
elevation: 2,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
colorScheme.primaryContainer,
colorScheme.secondaryContainer,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Column( _heroHeading(context, greetingName, colorScheme),
crossAxisAlignment: CrossAxisAlignment.start, const SizedBox(height: 18),
Wrap(
spacing: 12,
runSpacing: 12,
children: [ children: [
Text('Dashboard', style: Theme.of(context).textTheme.labelMedium), _animatedMetricTile(
const SizedBox(height: 2), context,
Text( label: 'Total mileage',
'Welcome back, $greetingName', value: totalMileage.toDouble(),
style: Theme.of(context).textTheme.headlineSmall, formatter: (val) =>
distanceUnits.format(val, decimals: 1),
icon: Icons.route,
color: colorScheme.onPrimaryContainer,
),
_animatedMetricTile(
context,
label: 'This year',
value: currentYearMileage.toDouble(),
formatter: (val) =>
distanceUnits.format(val, decimals: 1),
icon: Icons.calendar_today,
color: colorScheme.onPrimaryContainer,
),
_animatedMetricTile(
context,
label: 'Entries logged',
value: legCount.toDouble(),
formatter: (val) => val.round().toString(),
icon: Icons.format_list_bulleted,
color: colorScheme.onPrimaryContainer,
), ),
], ],
), ),
if (loading) const SizedBox(height: 16),
const Padding( _buildClassClearanceCarousel(context, data, colorScheme),
padding: EdgeInsets.only(right: 8.0), ],
),
),
);
}
Widget _buildClassClearanceCarousel(
BuildContext context,
DataService data,
ColorScheme colorScheme,
) {
final items = data.classClearanceProgress;
final loading = data.isClassClearanceProgressLoading;
_refreshCarouselItems(items);
_startCarouselIfNeeded(_carouselItems.length);
if (loading && _carouselItems.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: Center(
child: SizedBox( child: SizedBox(
height: 24, height: 16,
width: 24, width: 16,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
), ),
), ),
],
); );
} }
List<Widget> _buildMetricChips( if (_carouselItems.isEmpty) {
BuildContext context, { return Text(
required double totalMileage, 'No class clearance progress yet.',
required double currentYearMileage, style: Theme.of(context).textTheme.labelSmall?.copyWith(
required int trips, color: colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
}) { ),
final textTheme = Theme.of(context).textTheme; );
Widget metricCard(String label, String value) { }
return Card(
elevation: 1, return Column(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
label.toUpperCase(), 'Class clearance (in progress)',
style: textTheme.labelSmall?.copyWith( style: Theme.of(context).textTheme.labelSmall?.copyWith(
letterSpacing: 0.7, color: colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
color: textTheme.bodySmall?.color?.withOpacity(0.7),
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 6),
Text( SizedBox(
value, height: 58,
style: textTheme.titleMedium?.copyWith( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 450),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
final tween = Tween<Offset>(
begin: const Offset(0, 0.4),
end: Offset.zero,
);
return ClipRect(
child: SlideTransition(
position: animation.drive(tween),
child: FadeTransition(opacity: animation, child: child),
),
);
},
child: _buildClassClearanceSlide(
context,
_carouselItems[_carouselIndex % _carouselItems.length],
colorScheme,
key: ValueKey(_carouselIndex),
),
),
),
],
);
}
Widget _buildClassClearanceSlide(
BuildContext context,
ClassClearanceProgress progress,
ColorScheme colorScheme,
{Key? key}
) {
final pct = progress.percentComplete.clamp(0, 100);
final textTheme = Theme.of(context).textTheme;
final ratio = progress.total == 0
? 0.0
: (progress.completed / progress.total).clamp(0.0, 1.0);
final activeRatio = progress.total == 0
? 0.0
: (progress.activeTotal / progress.total).clamp(0.0, 1.0);
return TweenAnimationBuilder<double>(
key: key ?? ValueKey(progress.className),
tween: Tween(begin: 0, end: ratio),
duration: const Duration(milliseconds: 1200),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
final ratioValue = ratio == 0 ? 0.0 : (value / ratio).clamp(0.0, 1.0);
final animatedHad = (progress.completed * ratioValue).round();
final animatedActive = (progress.activeTotal * ratioValue).round();
final animatedActiveRatio =
(activeRatio * ratioValue).clamp(0.0, activeRatio);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
progress.className,
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
'${pct.toStringAsFixed(0)}% • $animatedHad/$animatedActive/${progress.total}',
style: textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer
.withValues(alpha: 0.8),
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(
height: 8,
child: Stack(
children: [
Container(
color: colorScheme.onPrimaryContainer.withValues(
alpha: 0.2,
),
),
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: animatedActiveRatio.isNaN
? 0
: animatedActiveRatio,
child: Container(
color:
colorScheme.onPrimaryContainer.withValues(alpha: 0.5),
),
),
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: value.isNaN ? 0 : value,
child: Container(
color: colorScheme.onPrimaryContainer,
),
), ),
], ],
), ),
), ),
),
],
);
},
); );
} }
return [ void _startCarouselIfNeeded(int count) {
metricCard('Total mileage', '${totalMileage.toStringAsFixed(1)} mi'), if (count <= 1) {
metricCard('This year', '${currentYearMileage.toStringAsFixed(1)} mi'), _stopCarousel();
metricCard('Trips logged', trips.toString()), return;
]; }
if (_carouselItemCount != count) {
_carouselItemCount = count;
_carouselIndex = 0;
}
if (_carouselTimer != null) return;
_carouselTimer = Timer.periodic(const Duration(seconds: 8), (_) {
if (!mounted || _carouselItemCount == 0) return;
setState(() {
_carouselIndex = (_carouselIndex + 1) % _carouselItemCount;
});
});
}
void _stopCarousel() {
_carouselTimer?.cancel();
_carouselTimer = null;
}
void _refreshCarouselItems(List<ClassClearanceProgress> items) {
final signature = items
.map((item) =>
'${item.className}:${item.completed}:${item.activeTotal}:${item.total}')
.join('|');
if (signature == _carouselSignature) return;
_carouselSignature = signature;
_carouselItems = List<ClassClearanceProgress>.from(items)
..shuffle(_carouselRandom);
}
@override
void dispose() {
_stopCarousel();
super.dispose();
}
Widget _animatedMetricTile(
BuildContext context, {
required String label,
required double value,
required String Function(double) formatter,
required IconData icon,
required Color color,
}) {
final bg = Colors.white.withValues(alpha: 0.14);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withValues(alpha: 0.18)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color.withValues(alpha: 0.85),
letterSpacing: 0.4,
),
),
AnimatedCountText(
value: value,
formatter: formatter,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
color: color,
),
),
],
),
],
),
);
}
Widget _buildTiles(
BuildContext context,
DataService data,
DistanceUnitService distanceUnits,
double maxWidth,
double spacing,
) {
final isWide = maxWidth >= 1200;
if (isWide) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildOnThisDayCard(context, data, distanceUnits),
const SizedBox(height: 16),
_buildTripsCard(context, data, distanceUnits),
const SizedBox(height: 16),
const LatestLocoChangesPanel(expanded: true),
],
),
),
const SizedBox(width: 16),
Expanded(
flex: 1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: const [
TopTractionPanel(),
SizedBox(height: 16),
LeaderboardPanel(),
],
),
),
],
);
} }
Widget _buildMainColumn(BuildContext context, DataService data) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildCard( _buildOnThisDayCard(context, data, distanceUnits),
context, const SizedBox(height: 16),
title: 'On this day', const TopTractionPanel(),
action: const SizedBox(height: 16),
data.onThisDay const LeaderboardPanel(),
const SizedBox(height: 16),
_buildTripsCard(context, data, distanceUnits),
const SizedBox(height: 16),
const LatestLocoChangesPanel(),
],
);
}
Widget _heroHeading(
BuildContext context,
String greetingName,
ColorScheme colorScheme,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Dashboard',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onPrimaryContainer,
letterSpacing: 0.4,
),
),
const SizedBox(height: 2),
Text(
'Welcome back, $greetingName',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w800,
),
),
],
);
}
Widget _buildOnThisDayCard(
BuildContext context, DataService data, DistanceUnitService distanceUnits) {
final filtered = data.onThisDay
.where((leg) => leg.beginTime.year != DateTime.now().year) .where((leg) => leg.beginTime.year != DateTime.now().year)
.length > .toList();
5 final textTheme = Theme.of(context).textTheme;
? TextButton( final showMore = filtered.length > 5;
onPressed: () => setState(() { final visible = _showAllOnThisDay ? filtered : filtered.take(6).toList();
_showAllOnThisDay = !_showAllOnThisDay; return _panel(
}), context,
child: Text(_showAllOnThisDay ? 'Show less' : 'Show more'), icon: Icons.history_toggle_off,
) title: 'On this day',
: null,
trailing: data.isOnThisDayLoading trailing: data.isOnThisDayLoading
? const SizedBox( ? const SizedBox(
height: 18, height: 18,
@@ -203,33 +520,150 @@ class _DashboardState extends State<Dashboard> {
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: null, : null,
child: _buildLegList( action: showMore
context, ? TextButton(
data.onThisDay, onPressed: () =>
showAll: _showAllOnThisDay, setState(() => _showAllOnThisDay = !_showAllOnThisDay),
emptyMessage: 'No historical moves for today yet.', child: Text(_showAllOnThisDay ? 'Show less' : 'Show more'),
), )
), : null,
const SizedBox(height: 12), child: filtered.isEmpty
_buildQuickCalcCard(context), ? Text(
const SizedBox(height: 12), 'No historical moves for today yet.',
_buildTripsCard(context, data), style: textTheme.bodyMedium,
], )
); : Column(
}
Widget _buildSidebar(BuildContext context, DataService data) {
return Column(
children: [ children: [
TopTractionPanel(), for (int idx = 0; idx < visible.length; idx++) ...[
const SizedBox(height: 12), _otdRow(context, visible[idx], textTheme, distanceUnits),
LeaderboardPanel(), if (idx != visible.length - 1) const Divider(height: 12),
],
],
),
);
}
Widget _otdRow(BuildContext context, Leg leg, TextTheme textTheme,
DistanceUnitService distanceUnits) {
final traction = leg.locos;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 64,
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
leg.beginTime.year.toString(),
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(_formatTime(leg.beginTime), style: textTheme.labelSmall),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${leg.start}${leg.end}',
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (leg.headcode.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
children: [
Text(
leg.headcode,
style: textTheme.labelSmall?.copyWith(
color: textTheme.bodySmall?.color?.withValues(
alpha: 0.7,
),
),
),
],
),
],
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 6),
Expanded(
child: traction.isEmpty
? Text(
'No traction recorded',
style: textTheme.labelSmall?.copyWith(
color: textTheme.bodySmall?.color?.withValues(
alpha: 0.7,
),
),
)
: Wrap(
spacing: 8,
runSpacing: 4,
children: traction.map((loco) {
final iconColor = loco.powering
? Theme.of(context).colorScheme.primary
: Theme.of(context).hintColor;
final label = '${loco.locoClass} ${loco.number}';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.train, size: 14, color: iconColor),
const SizedBox(width: 4),
Text(
label,
style: textTheme.labelSmall?.copyWith(
color: textTheme.bodySmall?.color
?.withValues(alpha: 0.85),
),
),
],
);
}).toList(),
),
),
],
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
distanceUnits.format(leg.mileage, decimals: 1),
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
],
),
], ],
); );
} }
Widget _buildCard( Widget _panel(
BuildContext context, { BuildContext context, {
required IconData icon,
required String title, required String title,
required Widget child, required Widget child,
Widget? trailing, Widget? trailing,
@@ -238,29 +672,29 @@ class _DashboardState extends State<Dashboard> {
return Card( return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Icon(icon, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
title, title,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w800,
), ),
), ),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (action != null) action,
if (trailing != null) ...[
const SizedBox(width: 8),
trailing,
],
],
), ),
if (action != null)
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: action,
),
if (trailing != null) trailing,
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -271,75 +705,19 @@ class _DashboardState extends State<Dashboard> {
); );
} }
Widget _buildLegList( Widget _buildTripsCard(
BuildContext context, BuildContext context, DataService data, DistanceUnitService distanceUnits) {
List<Leg> legs, {
required String emptyMessage,
bool showAll = false,
}) {
final filtered = legs
.where((leg) => leg.beginTime.year != DateTime.now().year)
.toList();
if (filtered.isEmpty) {
return Text(emptyMessage, style: Theme.of(context).textTheme.bodyMedium);
}
final toShow = showAll ? filtered : filtered.take(5).toList();
return Column(
children: toShow.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 tripsUnsorted = data.trips; final tripsUnsorted = data.trips;
List trips = []; List<TripSummary> trips = [];
if (tripsUnsorted.isNotEmpty) { if (tripsUnsorted.isNotEmpty) {
trips = [...tripsUnsorted]..sort((a, b) => b.tripId.compareTo(a.tripId)); trips = [...tripsUnsorted]..sort(TripSummary.compareByDateDesc);
} }
return _buildCard( return _panel(
context, context,
icon: Icons.bookmark,
title: 'Trips', title: 'Trips',
action: TextButton( action: TextButton(
onPressed: () => context.push('/trips'), onPressed: () => context.push('/logbook/trips'),
child: const Text('View all'), child: const Text('View all'),
), ),
child: trips.isEmpty child: trips.isEmpty
@@ -348,20 +726,75 @@ class _DashboardState extends State<Dashboard> {
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
) )
: Column( : Column(
children: trips.take(5).map((trip) { children: trips.take(6).map((trip) {
return ListTile( return Padding(
contentPadding: EdgeInsets.zero, padding: const EdgeInsets.symmetric(vertical: 6.0),
title: Text(trip.tripName), child: Row(
subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'), children: [
trailing: const Icon(Icons.chevron_right), Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.book, size: 18),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
trip.tripName,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w700),
),
AnimatedCountText(
value: trip.tripMileage,
formatter: (val) =>
distanceUnits.format(val, decimals: 1),
style: Theme.of(context).textTheme.labelMedium,
),
],
),
),
],
),
); );
}).toList(), }).toList(),
), ),
); );
} }
String _formatDate(DateTime? dt) { String _formatTime(DateTime date) {
if (dt == null) return ''; return DateFormat('HH:mm').format(date);
return '${dt.year.toString().padLeft(4, '0')}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; }
Future<void> _refreshDashboardData({bool force = false}) async {
final data = context.read<DataService>();
await data.fetchHomepageStats();
await Future.wait([
data.fetchOnThisDay(),
data.fetchTripDetails(),
data.fetchHadTraction(),
data.fetchLatestLocoChanges(),
data.fetchClassClearanceProgress(limit: 75, onlyIncomplete: true),
]);
}
void _handleRouteFocus() {
final isCurrent = ModalRoute.of(context)?.isCurrent ?? true;
if (isCurrent && !_isCurrent) {
_isCurrent = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_refreshDashboardData();
});
return;
}
if (!isCurrent && _isCurrent) {
_isCurrent = false;
}
} }
} }

View File

@@ -1,9 +1,9 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:mileograph_flutter/components/legs/leg_card.dart';
import 'package:mileograph_flutter/components/widgets/multi_select_filter.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class LegsPage extends StatefulWidget { class LegsPage extends StatefulWidget {
@@ -18,6 +18,11 @@ class _LegsPageState extends State<LegsPage> {
DateTime? _startDate; DateTime? _startDate;
DateTime? _endDate; DateTime? _endDate;
bool _initialised = false; bool _initialised = false;
bool _unallocatedOnly = false;
bool _showMoreFilters = false;
bool _loadingNetworks = false;
List<String> _availableNetworks = [];
List<String> _selectedNetworks = [];
@override @override
void didChangeDependencies() { void didChangeDependencies() {
@@ -25,15 +30,29 @@ class _LegsPageState extends State<LegsPage> {
if (!_initialised) { if (!_initialised) {
_initialised = true; _initialised = true;
_refreshLegs(); _refreshLegs();
_loadNetworks();
} }
} }
Future<void> _loadNetworks() async {
setState(() => _loadingNetworks = true);
final data = context.read<DataService>();
await data.fetchStationNetworks();
if (!mounted) return;
setState(() {
_availableNetworks = data.stationNetworks;
_loadingNetworks = false;
});
}
Future<void> _refreshLegs() async { Future<void> _refreshLegs() async {
final data = context.read<DataService>(); final data = context.read<DataService>();
await data.fetchLegs( await data.fetchLegs(
sortDirection: _sortDirection, sortDirection: _sortDirection,
dateRangeStart: _formatDate(_startDate), dateRangeStart: _formatDate(_startDate),
dateRangeEnd: _formatDate(_endDate), dateRangeEnd: _formatDate(_endDate),
unallocatedOnly: _unallocatedOnly,
networkFilter: _selectedNetworks,
); );
} }
@@ -45,6 +64,8 @@ class _LegsPageState extends State<LegsPage> {
dateRangeEnd: _formatDate(_endDate), dateRangeEnd: _formatDate(_endDate),
offset: data.legs.length, offset: data.legs.length,
append: true, append: true,
unallocatedOnly: _unallocatedOnly,
networkFilter: _selectedNetworks,
); );
} }
@@ -85,6 +106,9 @@ class _LegsPageState extends State<LegsPage> {
_startDate = null; _startDate = null;
_endDate = null; _endDate = null;
_sortDirection = 0; _sortDirection = 0;
_unallocatedOnly = false;
_showMoreFilters = false;
_selectedNetworks = [];
}); });
_refreshLegs(); _refreshLegs();
} }
@@ -92,6 +116,7 @@ class _LegsPageState extends State<LegsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final legs = data.legs; final legs = data.legs;
final pageMileage = _pageMileage(legs); final pageMileage = _pageMileage(legs);
@@ -123,7 +148,7 @@ class _LegsPageState extends State<LegsPage> {
children: [ children: [
Text('Page mileage', Text('Page mileage',
style: Theme.of(context).textTheme.labelSmall), style: Theme.of(context).textTheme.labelSmall),
Text('${pageMileage.toStringAsFixed(1)} mi', Text(distanceUnits.format(pageMileage, decimals: 1),
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.titleMedium .titleMedium
@@ -177,8 +202,65 @@ class _LegsPageState extends State<LegsPage> {
: _formatDate(_endDate!)!, : _formatDate(_endDate!)!,
), ),
), ),
TextButton.icon(
onPressed: () => setState(
() => _showMoreFilters = !_showMoreFilters,
),
icon: Icon(
_showMoreFilters
? Icons.expand_less
: Icons.expand_more,
),
label: Text(
_showMoreFilters ? 'Hide filters' : 'More filters',
),
),
], ],
), ),
AnimatedCrossFade(
crossFadeState: _showMoreFilters
? 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: [
MultiSelectFilter(
label: 'Networks',
options: _availableNetworks,
selected: _selectedNetworks,
onChanged: (vals) async {
setState(() => _selectedNetworks = vals);
await _refreshLegs();
},
onRefresh: _loadingNetworks ? null : _loadNetworks,
),
FilterChip(
avatar: const Icon(Icons.flash_off),
label: const Text('Unallocated only'),
selected: _unallocatedOnly,
onSelected: (selected) async {
setState(() => _unallocatedOnly = selected);
await _refreshLegs();
},
),
if (_loadingNetworks)
const Padding(
padding: EdgeInsets.only(left: 8.0),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
),
),
secondChild: const SizedBox.shrink(),
),
], ],
), ),
), ),
@@ -214,7 +296,7 @@ class _LegsPageState extends State<LegsPage> {
else else
Column( Column(
children: [ children: [
...legs.map((leg) => _buildLegCard(context, leg)), ..._buildLegsWithDividers(context, legs, distanceUnits),
const SizedBox(height: 8), const SizedBox(height: 8),
if (data.legsHasMore || data.isLegsLoading) if (data.legsHasMore || data.isLegsLoading)
Align( Align(
@@ -241,164 +323,63 @@ class _LegsPageState extends State<LegsPage> {
); );
} }
List<Widget> _buildLegsWithDividers(
BuildContext context,
List<Leg> legs,
DistanceUnitService distanceUnits,
) {
final widgets = <Widget>[];
String? currentDate;
double dayMileage = 0;
final dayLegs = <Leg>[];
void flushDay() {
final date = currentDate;
if (date == null) return;
widgets.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Expanded(
child: Text(
date,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
Text(distanceUnits.format(dayMileage, decimals: 1),
style: Theme.of(context).textTheme.labelMedium),
],
),
),
);
widgets.add(const Divider());
widgets.addAll(
dayLegs.map((leg) => LegCard(leg: leg, showDate: false)),
);
dayLegs.clear();
}
for (final leg in legs) {
final dateStr = _formatDate(leg.beginTime) ?? '';
if (currentDate != null && dateStr != currentDate) {
flushDay();
dayMileage = 0;
}
currentDate = dateStr;
dayLegs.add(leg);
dayMileage += leg.mileage;
}
flushDay();
return widgets;
}
String? _formatDate(DateTime? date) { String? _formatDate(DateTime? date) {
if (date == null) return null; if (date == null) return null;
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; 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';
}
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: [
IconButton(
tooltip: 'Edit entry',
icon: const Icon(Icons.edit),
onPressed: () => context.push('/legs/edit/${leg.id}'),
),
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

@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/components/legs/leg_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
class LocoLegsPage extends StatefulWidget {
const LocoLegsPage({
super.key,
required this.locoId,
required this.locoLabel,
});
final int locoId;
final String locoLabel;
@override
State<LocoLegsPage> createState() => _LocoLegsPageState();
}
class _LocoLegsPageState extends State<LocoLegsPage> {
bool _includeNonPowering = false;
late Future<List<Leg>> _future;
@override
void initState() {
super.initState();
_future = _fetch();
}
Future<List<Leg>> _fetch() {
return context.read<DataService>().fetchLegsForLoco(
widget.locoId,
includeNonPowering: _includeNonPowering,
);
}
Future<void> _refresh() async {
final items = await _fetch();
if (!mounted) return;
setState(() {
_future = Future.value(items);
});
}
@override
Widget build(BuildContext context) {
final titleLabel =
widget.locoLabel.trim().isEmpty ? 'Loco ${widget.locoId}' : widget.locoLabel;
return Scaffold(
appBar: AppBar(
title: Text('Legs · $titleLabel'),
actions: [
IconButton(
tooltip: 'Refresh',
onPressed: _refresh,
icon: const Icon(Icons.refresh),
),
],
),
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Card(
child: SwitchListTile(
title: const Text('Include non-powering (dead-in-tow)'),
subtitle: const Text('Off by default'),
value: _includeNonPowering,
onChanged: (val) {
setState(() {
_includeNonPowering = val;
_future = _fetch();
});
},
),
),
),
Expanded(
child: FutureBuilder<List<Leg>>(
future: _future,
builder: (context, snapshot) {
final items = snapshot.data ?? const <Leg>[];
final isLoading =
snapshot.connectionState == ConnectionState.waiting;
if (isLoading && items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (!isLoading && items.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text('No legs found for this loco.'),
),
);
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
physics: const AlwaysScrollableScrollPhysics(),
itemCount: items.length,
itemBuilder: (context, index) => LegCard(leg: items[index]),
),
);
},
),
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,464 @@
part of 'package:mileograph_flutter/components/pages/loco_timeline.dart';
class _EventEditor extends StatelessWidget {
const _EventEditor({
required this.eventFields,
required this.drafts,
required this.onAddEvent,
required this.onChange,
required this.onSave,
required this.onRemoveDraft,
required this.isSaving,
required this.canSave,
});
final List<EventField> eventFields;
final List<_EventDraft> drafts;
final VoidCallback onAddEvent;
final VoidCallback onChange;
final Future<void> Function() onSave;
final void Function(int index) onRemoveDraft;
final bool isSaving;
final bool canSave;
@override
Widget build(BuildContext context) {
final hasDrafts = drafts.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Add events',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
Row(
children: [
if (!hasDrafts) ...[
OutlinedButton.icon(
onPressed: onAddEvent,
icon: const Icon(Icons.add),
label: const Text('New event'),
),
const SizedBox(width: 8),
],
FilledButton.icon(
onPressed: (!canSave || isSaving) ? null : onSave,
icon: isSaving
? const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: Text(isSaving ? 'Saving...' : 'Save all'),
),
],
),
],
),
const SizedBox(height: 12),
if (drafts.isEmpty)
const Text('No events yet. Add one to propose new values.')
else
...drafts.asMap().entries.map(
(entry) {
final idx = entry.key;
final draft = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Event ${idx + 1}',
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
IconButton(
tooltip: 'Remove',
onPressed: () => onRemoveDraft(idx),
icon: const Icon(Icons.delete_outline),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: draft.dateController,
onChanged: (_) => onChange(),
decoration: const InputDecoration(
labelText: 'Date (YYYY-MM-DD, MM/DD can be XX)',
border: OutlineInputBorder(),
),
),
),
IconButton(
tooltip: 'Pick date',
onPressed: () async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: draft.date ?? now,
firstDate: DateTime(1900),
lastDate: DateTime(now.year + 10),
);
if (picked != null) {
draft.date = picked;
draft.dateController.text =
DateFormat('yyyy-MM-dd').format(picked);
onChange();
}
},
icon: const Icon(Icons.calendar_month),
),
],
),
const SizedBox(height: 8),
_FieldList(
draft: draft,
eventFields: eventFields,
onChange: onChange,
),
const SizedBox(height: 12),
TextField(
controller: draft.detailsController,
onChanged: (val) {
draft.details = val;
onChange();
},
decoration: const InputDecoration(
labelText: 'Commit message / details',
border: OutlineInputBorder(),
),
),
],
),
),
),
);
},
),
if (hasDrafts) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: onAddEvent,
icon: const Icon(Icons.add),
label: const Text('New event'),
),
],
],
);
}
}
class _FieldList extends StatelessWidget {
const _FieldList({
required this.draft,
required this.eventFields,
required this.onChange,
});
final _EventDraft draft;
final List<EventField> eventFields;
final VoidCallback onChange;
@override
Widget build(BuildContext context) {
final usedNames = draft.fields.map((f) => f.field.name).toSet();
final availableFields =
eventFields.where((f) => !usedNames.contains(f.name)).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Fields',
style: Theme.of(context).textTheme.titleSmall,
),
const Spacer(),
DropdownButton<EventField>(
hint: const Text('Add field'),
value: null,
onChanged: (field) {
if (field == null) return;
draft.fields.add(
_FieldEntry(field: field)..unit = _defaultUnitForField(field),
);
onChange();
},
items: availableFields
.map(
(f) => DropdownMenuItem(
value: f,
child: Text(f.display),
),
)
.toList(),
),
],
),
const SizedBox(height: 8),
if (draft.fields.isEmpty)
const Text('No fields added yet.')
else
...draft.fields.asMap().entries.map(
(entry) {
final idx = entry.key;
final field = entry.value;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
field.field.display,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
_FieldInput(
entry: field,
onChanged: (val, {String? unit}) {
field.value = val;
if (unit != null) field.unit = unit;
onChange();
},
),
],
),
),
IconButton(
onPressed: () {
draft.fields.removeAt(idx);
onChange();
},
icon: const Icon(Icons.close),
),
],
),
);
},
),
],
);
}
}
class _FieldInput extends StatelessWidget {
const _FieldInput({
required this.entry,
required this.onChanged,
});
final _FieldEntry entry;
final void Function(dynamic value, {String? unit}) onChanged;
@override
Widget build(BuildContext context) {
final field = entry.field;
final value = entry.value;
if (field.enumValues != null && field.enumValues!.isNotEmpty) {
final options = field.enumValues!;
return DropdownButtonFormField<String>(
value: value is String && options.contains(value) ? value : null,
decoration: const InputDecoration(border: OutlineInputBorder()),
items: options
.map((v) => DropdownMenuItem<String>(value: v, child: Text(v)))
.toList(),
onChanged: (val) => onChanged(val),
hint: const Text('Select value'),
);
}
final type = field.type?.toLowerCase();
if (type == 'bool' || type == 'boolean') {
final bool? current =
value is bool ? value : (value is String ? value == 'true' : null);
return DropdownButtonFormField<bool>(
value: current,
decoration: const InputDecoration(border: OutlineInputBorder()),
items: const [
DropdownMenuItem(value: true, child: Text('Yes')),
DropdownMenuItem(value: false, child: Text('No')),
],
onChanged: (val) => onChanged(val),
hint: const Text('Select'),
);
}
final name = field.name.toLowerCase();
if (name == 'max_speed') {
final unit = entry.unit ?? 'kph';
return Row(
children: [
Expanded(
child: TextFormField(
initialValue: value?.toString(),
onChanged: (val) {
final parsed = double.tryParse(val);
onChanged(parsed, unit: unit);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter value',
suffixText: 'kph/mph',
),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 8),
SizedBox(
width: 88,
child: DropdownButtonFormField<String>(
value: unit,
items: const [
DropdownMenuItem(value: 'kph', child: Text('kph')),
DropdownMenuItem(value: 'mph', child: Text('mph')),
],
onChanged: (val) {
if (val == null) return;
onChanged(value, unit: val);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Unit',
),
),
),
],
);
}
if ({
'height',
'length',
'width',
'track_gauge',
}.contains(name)) {
return TextFormField(
initialValue: value?.toString(),
onChanged: (val) {
final parsed = double.tryParse(val);
onChanged(parsed ?? val);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter value',
suffixText: 'mm',
),
keyboardType: TextInputType.number,
);
}
if (name == 'weight') {
return TextFormField(
initialValue: value?.toString(),
onChanged: (val) {
final parsed = double.tryParse(val);
onChanged(parsed ?? val);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter value',
suffixText: 'tonnes',
),
keyboardType: TextInputType.number,
);
}
if (name == 'power') {
return TextFormField(
initialValue: value?.toString(),
onChanged: (val) {
final parsed = double.tryParse(val);
onChanged(parsed ?? val);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter value',
suffixText: 'kW',
),
keyboardType: TextInputType.number,
);
}
if (name == 'tractive_effort') {
return TextFormField(
initialValue: value?.toString(),
onChanged: (val) {
final parsed = double.tryParse(val);
onChanged(parsed ?? val);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter value',
suffixText: 'kN',
),
keyboardType: TextInputType.number,
);
}
final isNumber = type == 'int' || type == 'integer';
return TextFormField(
initialValue: value?.toString(),
onChanged: (val) {
if (isNumber) {
final parsed = int.tryParse(val);
onChanged(parsed);
} else {
onChanged(val);
}
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter value',
),
keyboardType: isNumber ? TextInputType.number : TextInputType.text,
);
}
}
class _EventDraft {
DateTime? date;
String details = '';
final TextEditingController detailsController = TextEditingController();
final TextEditingController dateController = TextEditingController();
final List<_FieldEntry> fields = [];
_EventDraft();
}
class _FieldEntry {
final EventField field;
dynamic value;
String? unit;
_FieldEntry({required this.field});
}
String? _defaultUnitForField(EventField field) {
final name = field.name.toLowerCase();
if (name == 'max_speed') return 'kph';
return null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/pages/legs.dart';
import 'package:mileograph_flutter/components/pages/trips.dart';
enum LogbookTab { entries, trips }
class LogbookPage extends StatelessWidget {
const LogbookPage({super.key, this.initialTab = LogbookTab.entries});
final LogbookTab initialTab;
@override
Widget build(BuildContext context) {
final initialIndex = initialTab == LogbookTab.trips ? 1 : 0;
return DefaultTabController(
key: ValueKey(initialTab),
initialIndex: initialIndex,
length: 2,
child: Column(
children: [
TabBar(
onTap: (index) {
final dest = index == 0 ? '/logbook/entries' : '/logbook/trips';
final current = GoRouterState.of(context).uri.path;
if (current != dest) {
context.go(dest);
}
},
tabs: const [
Tab(text: 'Entries'),
Tab(text: 'Trips'),
],
),
Expanded(
child: TabBarView(children: const [LegsPage(), TripsPage()]),
),
],
),
);
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/components/pages/more/more_home_page.dart';
export 'more/admin_page.dart';
class MorePage extends StatelessWidget {
const MorePage({super.key});
@override
Widget build(BuildContext context) {
return const MoreHomePage();
}
}

View File

@@ -0,0 +1,766 @@
import 'package:flutter/material.dart';
import 'package:file_selector/file_selector.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/authservice.dart';
class AdminPage extends StatefulWidget {
const AdminPage({super.key});
@override
State<AdminPage> createState() => _AdminPageState();
}
class _AdminPageState extends State<AdminPage> {
final TextEditingController _titleController = TextEditingController();
final TextEditingController _bodyController = TextEditingController();
final List<UserSummary> _selectedUsers = [];
List<UserSummary> _userOptions = [];
List<String> _channels = [];
String? _selectedChannel;
String? _channelError;
bool _loadingChannels = false;
String? _userError;
bool _sending = false;
List<XFile> _routeFiles = [];
bool _routeUploading = false;
String? _routeStatus;
String? _routeStatusMessage;
String? _routeErrorMessage;
int? _routeProcessed;
int? _routeTotal;
double? _routeProgress;
Map<String, dynamic>? _routeResult;
@override
void initState() {
super.initState();
_loadChannels();
}
@override
void dispose() {
_titleController.dispose();
_bodyController.dispose();
super.dispose();
}
Future<void> _loadChannels() async {
setState(() {
_loadingChannels = true;
_channelError = null;
});
try {
final api = context.read<ApiService>();
final json = await api.get('/notifications/channels');
List<dynamic>? list;
if (json is List) {
list = json;
} else if (json is Map) {
for (final key in ['channels', 'data']) {
final value = json[key];
if (value is List) {
list = value;
break;
}
}
}
final parsed =
list?.map((e) => e.toString()).where((e) => e.isNotEmpty).toList() ??
const [];
setState(() {
_channels = parsed;
_selectedChannel = parsed.isNotEmpty ? parsed.first : null;
});
} catch (e) {
setState(() {
_channelError = 'Failed to load channels';
});
} finally {
if (mounted) setState(() => _loadingChannels = false);
}
}
Future<List<UserSummary>> _fetchUserSuggestions(
ApiService api,
String query,
) async {
final encoded = Uri.encodeComponent(query);
final candidates = [
'/users/search?q=$encoded',
'/users/search?query=$encoded',
'/users?search=$encoded',
];
for (final path in candidates) {
try {
final json = await api.get(path);
List<dynamic>? list;
if (json is List) {
list = json;
} else if (json is Map) {
for (final key in ['users', 'data', 'results', 'items']) {
final value = json[key];
if (value is List) {
list = value;
break;
}
}
}
if (list != null) {
return list
.whereType<Map>()
.map((e) => UserSummary.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList();
}
} catch (_) {
// Try next endpoint
}
}
return const [];
}
void _removeUser(UserSummary user) {
setState(() {
_selectedUsers.removeWhere((u) => u.userId == user.userId);
});
}
Future<void> _openUserPicker() async {
final api = context.read<ApiService>();
var tempSelected = List<UserSummary>.from(_selectedUsers);
var options = List<UserSummary>.from(_userOptions);
String query = '';
bool loading = false;
String? error = _userError;
Future<void> runSearch(String q, void Function(void Function()) setModalState) async {
setModalState(() {
query = q;
loading = true;
error = null;
});
try {
final results = await _fetchUserSuggestions(api, q);
setModalState(() {
options = results;
loading = false;
error = null;
});
if (mounted) {
setState(() {
_userOptions = results;
_userError = null;
});
}
} catch (e) {
setModalState(() {
loading = false;
error = 'Failed to search users';
});
if (mounted) {
setState(() {
_userError = 'Failed to search users';
});
}
}
}
var initialFetchTriggered = false;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setModalState) {
if (!initialFetchTriggered && !loading && options.isEmpty) {
initialFetchTriggered = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => runSearch('', setModalState),
);
}
final lowerQuery = query.toLowerCase();
final filtered = lowerQuery.isEmpty
? options
: options.where((u) {
return u.displayName.toLowerCase().contains(lowerQuery) ||
u.username.toLowerCase().contains(lowerQuery) ||
u.email.toLowerCase().contains(lowerQuery);
}).toList();
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(ctx).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Select recipients',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const Spacer(),
TextButton(
onPressed: () {
setModalState(() {
tempSelected.clear();
});
setState(() => _selectedUsers.clear());
},
child: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: 'Search users',
border: OutlineInputBorder(),
),
onChanged: (val) => runSearch(val, setModalState),
),
if (loading)
const Padding(
padding: EdgeInsets.only(top: 8.0),
child: LinearProgressIndicator(minHeight: 2),
),
if (error != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
error!,
style:
TextStyle(color: Theme.of(context).colorScheme.error),
),
),
const SizedBox(height: 12),
SizedBox(
height: 340,
child: filtered.isEmpty
? const Center(child: Text('No users yet.'))
: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, index) {
final user = filtered[index];
final selected =
tempSelected.any((u) => u.userId == user.userId);
return CheckboxListTile(
value: selected,
title: Text(user.displayName),
subtitle: user.email.isNotEmpty
? Text(user.email)
: (user.username.isNotEmpty
? Text(user.username)
: null),
onChanged: (val) {
setModalState(() {
if (val == true) {
if (!tempSelected
.any((u) => u.userId == user.userId)) {
tempSelected.add(user);
}
} else {
tempSelected.removeWhere(
(u) => u.userId == user.userId);
}
});
if (mounted) {
setState(() {
_selectedUsers
..clear()
..addAll(tempSelected);
});
}
},
);
},
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Done'),
),
),
],
),
),
);
},
);
},
);
}
Future<void> _sendNotification() async {
final channel = _selectedChannel;
if (channel == null || channel.isEmpty) {
_showSnack('Select a channel first.');
return;
}
if (_selectedUsers.isEmpty) {
_showSnack('Select at least one user.');
return;
}
final title = _titleController.text.trim();
final body = _bodyController.text.trim();
if (title.isEmpty || body.isEmpty) {
_showSnack('Title and body are required.');
return;
}
setState(() => _sending = true);
try {
final api = context.read<ApiService>();
await api.post('/notifications/new', {
'user_ids': _selectedUsers.map((e) => e.userId).toList(),
'channel': channel,
'title': title,
'body': body,
});
if (!mounted) return;
_showSnack('Notification sent');
setState(() {
_selectedUsers.clear();
_titleController.clear();
_bodyController.clear();
_userOptions.clear();
});
} catch (e) {
_showSnack('Failed to send: $e');
} finally {
if (mounted) setState(() => _sending = false);
}
}
int? _parseCount(dynamic value) {
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '');
}
double? _parsePercent(
dynamic value, {
required int? processed,
required int? total,
}) {
if (value is num) {
final raw = value.toDouble();
final normalized = raw > 1 ? raw / 100 : raw;
return normalized.clamp(0, 1);
}
if (processed != null && total != null && total > 0) {
return (processed / total).clamp(0, 1);
}
return null;
}
Duration _pollDelay(int attempt) {
const delays = [
Duration(seconds: 1),
Duration(seconds: 2),
Duration(seconds: 2),
Duration(seconds: 5),
Duration(seconds: 5),
Duration(seconds: 8),
Duration(seconds: 10),
];
if (attempt < delays.length) return delays[attempt];
return const Duration(seconds: 10);
}
String _routeStatusLabel() {
final status = _routeStatus ?? '';
final lower = status.toLowerCase();
final base = switch (lower) {
'queued' => 'Queued',
'running' => 'Processing',
'succeeded' => 'Completed',
'failed' => 'Failed',
_ => status,
};
final parts = <String>[base];
if (_routeProcessed != null && _routeTotal != null) {
parts.add('Files $_routeProcessed of $_routeTotal');
}
if (_routeProgress != null) {
parts.add('${(_routeProgress! * 100).toStringAsFixed(0)}%');
}
return parts.join(' · ');
}
Future<void> _pickRouteFiles() async {
final files = await openFiles(
acceptedTypeGroups: const [
XTypeGroup(
label: 'XLSX spreadsheets',
extensions: ['xlsx'],
),
],
);
if (files.isEmpty) return;
setState(() {
_routeFiles = files;
_routeStatus = null;
_routeStatusMessage = null;
_routeErrorMessage = null;
_routeProcessed = null;
_routeTotal = null;
_routeProgress = null;
_routeResult = null;
});
}
Future<void> _uploadRouteFiles() async {
if (_routeFiles.isEmpty || _routeUploading) return;
setState(() {
_routeUploading = true;
_routeStatus = null;
_routeStatusMessage = null;
_routeErrorMessage = null;
_routeProcessed = null;
_routeTotal = null;
_routeProgress = null;
_routeResult = null;
});
try {
final api = context.read<ApiService>();
final payloads = <MultipartFilePayload>[];
for (final file in _routeFiles) {
final bytes = await file.readAsBytes();
payloads.add(
MultipartFilePayload(
bytes: bytes,
filename: file.name,
),
);
}
final response = await api.postMultipartFiles(
'/route/update',
files: payloads,
headers: const {'accept': 'application/json'},
);
if (!mounted) return;
final parsed = response is Map
? Map<String, dynamic>.from(response)
: null;
final jobId = parsed?['job_id']?.toString();
if (jobId == null || jobId.isEmpty) {
setState(() {
_routeErrorMessage = 'Upload failed to start.';
});
return;
}
setState(() {
_routeStatus = parsed?['status']?.toString() ?? 'queued';
});
var attempt = 0;
while (mounted) {
final statusResponse = await api.get('/uploads/$jobId');
if (!mounted) return;
final statusMap = statusResponse is Map
? Map<String, dynamic>.from(statusResponse)
: null;
if (statusMap == null) {
setState(() {
_routeErrorMessage = 'Upload status unavailable.';
});
return;
}
final status = statusMap['status']?.toString() ?? 'queued';
final processed = _parseCount(statusMap['processed']);
final total = _parseCount(statusMap['total']);
final percent = _parsePercent(
statusMap['percent'],
processed: processed,
total: total,
);
setState(() {
_routeStatus = status;
_routeProcessed = processed;
_routeTotal = total;
_routeProgress = percent;
});
if (status == 'succeeded') {
final result = statusMap['result'];
setState(() {
if (result is Map) {
_routeResult = Map<String, dynamic>.from(result);
}
final message = _routeResult?['message']?.toString();
_routeStatusMessage = message != null && message.isNotEmpty
? message
: 'Route update complete.';
});
return;
}
if (status == 'failed') {
setState(() {
_routeErrorMessage =
statusMap['error']?.toString() ?? 'Route update failed.';
});
return;
}
await Future.delayed(_pollDelay(attempt));
attempt += 1;
}
} on ApiException catch (e) {
if (!mounted) return;
setState(() {
_routeErrorMessage = e.message;
});
} catch (e) {
if (!mounted) return;
setState(() {
_routeErrorMessage = e.toString();
});
} finally {
if (mounted) setState(() => _routeUploading = false);
}
}
void _showSnack(String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final isAdmin = context.select<AuthService, bool>((auth) => auth.isElevated);
if (!isAdmin) {
return const Scaffold(
body: Center(child: Text('You do not have access to this page.')),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Admin'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.maybePop();
} else {
context.go('/more');
}
},
tooltip: 'Back',
),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Send notification',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
_buildUserPicker(),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _selectedChannel,
decoration: const InputDecoration(
labelText: 'Channel',
border: OutlineInputBorder(),
),
items: _channels
.map(
(c) => DropdownMenuItem(
value: c,
child: Text(c),
),
)
.toList(),
onChanged:
_loadingChannels ? null : (val) => setState(() => _selectedChannel = val),
),
if (_loadingChannels)
const Padding(
padding: EdgeInsets.only(top: 8.0),
child: LinearProgressIndicator(minHeight: 2),
),
if (_channelError != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
_channelError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
const SizedBox(height: 12),
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _bodyController,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(
labelText: 'Body',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _sending ? null : _sendNotification,
icon: _sending
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
label: Text(_sending ? 'Sending...' : 'Send notification'),
),
),
const SizedBox(height: 32),
const Divider(height: 32),
Text(
'Route update uploads',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Upload one or more XLSX sheets to update route distances.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Text(
_routeFiles.isEmpty
? 'No files selected'
: '${_routeFiles.length} file(s) selected',
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _routeUploading ? null : _pickRouteFiles,
icon: const Icon(Icons.upload_file),
label: const Text('Choose files'),
),
],
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed:
_routeFiles.isEmpty || _routeUploading ? null : _uploadRouteFiles,
icon: _routeUploading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.file_upload),
label: Text(_routeUploading ? 'Uploading...' : 'Upload files'),
),
if (_routeStatus != null) ...[
const SizedBox(height: 12),
Text(
_routeStatusLabel(),
style: Theme.of(context).textTheme.bodyMedium,
),
if (_routeProgress != null) ...[
const SizedBox(height: 6),
LinearProgressIndicator(value: _routeProgress),
],
],
if (_routeStatusMessage != null) ...[
const SizedBox(height: 12),
Text(
_routeStatusMessage!,
style: Theme.of(context).textTheme.bodyMedium,
),
],
if (_routeErrorMessage != null) ...[
const SizedBox(height: 12),
Text(
_routeErrorMessage!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
if ((_routeStatus == 'failed' || _routeErrorMessage != null) &&
_routeFiles.isNotEmpty) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _routeUploading ? null : _uploadRouteFiles,
icon: const Icon(Icons.refresh),
label: const Text('Retry upload'),
),
],
],
),
);
}
Widget _buildUserPicker() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Recipients',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _selectedUsers
.map(
(u) => InputChip(
label: Text(u.displayName),
onDeleted: () => _removeUser(u),
),
)
.toList(),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _openUserPicker,
icon: const Icon(Icons.person_search),
label: const Text('Select users'),
),
if (_userError != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
_userError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
);
}
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:mileograph_flutter/services/authservice.dart';
class MoreHomePage extends StatelessWidget {
const MoreHomePage({super.key});
@override
Widget build(BuildContext context) {
final isAdmin = context.select<AuthService, bool>((auth) => auth.isElevated);
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'More',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 12),
Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.person),
title: const Text('Profile'),
onTap: () => context.go('/more/profile'),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.emoji_events),
title: const Text('Badges'),
onTap: () => context.go('/more/badges'),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.bar_chart),
title: const Text('Stats'),
onTap: () => context.go('/more/stats'),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Settings'),
onTap: () => context.go('/more/settings'),
),
if (isAdmin) const Divider(height: 1),
if (isAdmin)
ListTile(
leading: const Icon(Icons.admin_panel_settings),
title: const Text('Admin'),
onTap: () => context.go('/more/admin'),
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,529 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:mileograph_flutter/components/legs/leg_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart';
class UserProfilePage extends StatefulWidget {
const UserProfilePage({super.key, this.userId, this.initialUser});
final String? userId;
final UserSummary? initialUser;
@override
State<UserProfilePage> createState() => _UserProfilePageState();
}
class _UserProfilePageState extends State<UserProfilePage> {
static const int _pageSize = 22;
UserProfileDetail? _profile;
List<Leg> _legs = const [];
bool _loading = false;
bool _loadingMore = false;
bool _hasMore = false;
bool _lastFetchReturnedData = true;
Friendship? _friendship;
bool _actionsLoading = false;
String? get _userId => widget.initialUser?.userId ?? widget.userId;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadProfile();
});
}
Future<void> _loadProfile() async {
final userId = _userId;
if (userId == null || userId.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('No user selected.')));
context.pop();
}
return;
}
setState(() {
_loading = true;
_hasMore = false;
_legs = const [];
});
final data = context.read<DataService>();
try {
final profile = await data.fetchUserProfileDetail(userId);
final friendship = await data.fetchFriendshipStatus(userId);
if (!mounted) return;
if (profile == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to load user profile.')),
);
return;
}
final legs = profile.legs;
setState(() {
_profile = profile;
_legs = legs;
_lastFetchReturnedData = legs.isNotEmpty;
_hasMore = _lastFetchReturnedData && _legs.length >= _pageSize;
_friendship = friendship;
});
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadMore() async {
final userId = _userId;
if (userId == null || userId.isEmpty || _loadingMore || !_hasMore) return;
setState(() => _loadingMore = true);
final data = context.read<DataService>();
try {
final more = await data.fetchUserLegs(
userId: userId,
offset: _legs.length,
limit: _pageSize,
);
if (!mounted) return;
setState(() {
_legs = [..._legs, ...more];
_lastFetchReturnedData = more.isNotEmpty;
_hasMore = _lastFetchReturnedData && _legs.length >= _pageSize;
});
} finally {
if (mounted) setState(() => _loadingMore = false);
}
}
void _handleBack() {
final router = GoRouter.of(context);
if (router.canPop()) {
router.pop();
} else {
router.go('/more/profile');
}
}
Widget _buildProfileHeader(ThemeData theme) {
final profile = _profile;
final username = profile?.username ?? widget.initialUser?.username ?? '';
final fullName = profile?.fullName ?? widget.initialUser?.fullName ?? '';
final mileage = profile?.mileage;
final privacy = profile?.privacyInfo;
final mileageHidden =
(mileage == null || mileage == 0) &&
privacy != null &&
privacy.isNotEmpty;
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const CircleAvatar(child: Icon(Icons.person)),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
fullName.isNotEmpty ? fullName : username,
style: theme.textTheme.titleMedium,
),
if (username.isNotEmpty)
Text('@$username', style: theme.textTheme.bodySmall),
],
),
],
),
const SizedBox(height: 12),
Text(
mileageHidden
? 'Mileage hidden'
: 'Mileage: ${(mileage ?? 0).toStringAsFixed(1)}',
),
],
),
),
);
}
Widget _buildTopLocos() {
final profile = _profile;
if (profile == null || profile.topLocos.isEmpty) {
return const SizedBox.shrink();
}
final topTen = [...profile.topLocos]
..sort((a, b) => (b.mileage ?? 0).compareTo(a.mileage ?? 0));
final displayLocos = topTen.take(10).toList();
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Top locos by mileage',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
ListView.separated(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: displayLocos.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final loco = displayLocos[index];
final mileage = loco.mileage ?? 0;
return ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
child: Text(
'${index + 1}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
title: Text(
loco.number.isNotEmpty ? loco.number : 'Unknown',
style: Theme.of(context).textTheme.bodyLarge,
),
subtitle: Text(loco.locoClass),
trailing: Text(
'${mileage.toStringAsFixed(1)} mi',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
);
},
),
],
),
),
);
}
List<Widget> _buildLegsWithDividers(
BuildContext context,
List<Leg> legs, {
required bool showEditButton,
}) {
final widgets = <Widget>[];
String? currentDate;
final dayLegs = <Leg>[];
void flushDay() {
final date = currentDate;
if (date == null) return;
widgets.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
date,
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700),
),
),
);
widgets.add(const Divider());
widgets.addAll(
dayLegs.map(
(leg) => LegCard(
leg: leg,
showDate: false,
showEditButton: showEditButton,
),
),
);
dayLegs.clear();
}
for (final leg in legs) {
final dateStr = _formatDate(leg.beginTime) ?? '';
if (currentDate != null && dateStr != currentDate) {
flushDay();
}
currentDate = dateStr;
dayLegs.add(leg);
}
flushDay();
return widgets;
}
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')}';
}
Widget _buildFriendSection(AuthService auth) {
final friendship = _friendship;
if (friendship == null) {
return const SizedBox.shrink();
}
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Friendship',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(width: 8),
_buildStatusChip(friendship, auth),
],
),
const SizedBox(height: 8),
_buildActions(friendship, auth),
],
),
),
);
}
Widget _buildStatusChip(Friendship status, AuthService auth) {
String label = status.status;
Color color = Colors.grey;
switch (status.status.toLowerCase()) {
case 'accepted':
label = 'Friends';
color = Colors.green;
break;
case 'pending':
final isRequester = status.requesterId == auth.userId;
label = isRequester
? 'Pending (you sent)'
: 'Pending (needs your reply)';
color = Colors.orange;
break;
case 'blocked':
color = Colors.red;
label = 'Blocked';
break;
case 'declined':
case 'rejected':
label = 'Declined';
break;
default:
label = 'Not friends';
}
final bg = Color.alphaBlend(
color.withValues(alpha: 0.15),
Theme.of(context).colorScheme.surface,
);
return Container(
margin: const EdgeInsets.only(left: 6),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(20),
),
child: Text(label),
);
}
Widget _buildActions(Friendship status, AuthService auth) {
final targetUserId = _userId;
final isSelf = targetUserId != null && targetUserId == auth.userId;
if (isSelf) return const Text('This is you.');
final isRequester = status.requesterId == auth.userId;
final id = status.id;
final buttons = <Widget>[];
Future<void> run(Future<void> Function() action) async {
setState(() => _actionsLoading = true);
try {
await action();
} finally {
if (mounted) setState(() => _actionsLoading = false);
}
}
final data = context.read<DataService>();
if (status.isNone || status.isDeclined) {
buttons.add(
ElevatedButton.icon(
onPressed: _actionsLoading
? null
: () => run(() async {
final updated = await data.requestFriendship(
status.addresseeId,
);
if (!mounted) return;
setState(() => _friendship = updated);
}),
icon: const Icon(Icons.person_add),
label: const Text('Send friend request'),
),
);
} else if (status.isPending) {
if (isRequester) {
buttons.add(
OutlinedButton(
onPressed: _actionsLoading || id == null || id.isEmpty
? null
: () => run(() async {
await data.cancelFriendship(id);
if (!mounted) return;
setState(
() => _friendship = status.copyWith(status: 'none'),
);
}),
child: const Text('Cancel request'),
),
);
} else {
buttons.add(
ElevatedButton(
onPressed: _actionsLoading || id == null || id.isEmpty
? null
: () => run(() async {
final updated = await data.acceptFriendship(id);
if (!mounted) return;
setState(() => _friendship = updated);
}),
child: const Text('Accept'),
),
);
buttons.add(
OutlinedButton(
onPressed: _actionsLoading || id == null || id.isEmpty
? null
: () => run(() async {
final updated = await data.rejectFriendship(id);
if (!mounted) return;
setState(() => _friendship = updated);
}),
child: const Text('Reject'),
),
);
}
} else if (status.isAccepted) {
buttons.add(
ElevatedButton.icon(
onPressed: _actionsLoading || id == null || id.isEmpty
? null
: () => run(() async {
await data.deleteFriendship(id);
if (!mounted) return;
setState(() => _friendship = status.copyWith(status: 'none'));
}),
icon: const Icon(Icons.person_remove),
label: const Text('Unfriend'),
),
);
}
if (buttons.isEmpty) return const SizedBox.shrink();
return Wrap(spacing: 8, runSpacing: 8, children: buttons);
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthService>();
final theme = Theme.of(context);
final canEdit = auth.userId != null && auth.userId == _userId;
return Scaffold(
appBar: AppBar(
title: const Text('User profile'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _handleBack,
),
),
body: RefreshIndicator(
onRefresh: _loadProfile,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildProfileHeader(theme),
const SizedBox(height: 12),
_buildFriendSection(auth),
const SizedBox(height: 12),
_buildTopLocos(),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Entries', style: theme.textTheme.titleMedium),
if (_loading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
const SizedBox(height: 8),
if (_loading && _legs.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 16.0),
child: CircularProgressIndicator(),
),
)
else if (_legs.isEmpty)
Text(
(_profile?.privacyInfo.isNotEmpty ?? false)
? 'Hidden due to privacy settings.'
: 'No entries found.',
)
else ...[
..._buildLegsWithDividers(
context,
_legs,
showEditButton: canEdit,
),
const SizedBox(height: 8),
if ((_hasMore || _loadingMore) && _legs.isNotEmpty)
Align(
alignment: Alignment.center,
child: OutlinedButton.icon(
onPressed: _loadingMore ? null : _loadMore,
icon: _loadingMore
? const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.expand_more),
label: Text(
_loadingMore ? 'Loading...' : 'Load more',
),
),
),
],
],
),
),
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.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/api_service.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/navigation_guard.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'new_entry_page.dart';
part 'new_entry_drafts.dart';
part 'new_entry_picker_pages.dart';
part 'new_entry_models.dart';
part 'new_entry_draft_logic.dart';
part 'new_entry_submit_logic.dart';
part 'new_entry_traction_logic.dart';
const String _kDraftPrefsKey = 'new_entry_draft';
const String _kDraftListPrefsKey = 'new_entry_drafts_list';

View File

@@ -0,0 +1,514 @@
part of 'new_entry.dart';
extension _NewEntryDraftLogic on _NewEntryPageState {
Future<bool> _handleExitIntent() async {
if (!mounted) return false;
if (_isEditing) return true;
if (_activeLegShare != null) return true;
if (_formIsEmpty()) return true;
if (_activeDraftId != null && !_draftChangedFromBaseline()) {
return true;
}
final currentSnapshot = _currentSubmissionSnapshot();
if (_lastSubmittedSnapshot != null &&
_snapshotEquality.equals(_lastSubmittedSnapshot, currentSnapshot)) {
return true;
}
final choice = await _promptSaveDraft();
if (choice == _ExitChoice.cancel) return false;
if (choice == _ExitChoice.save) {
try {
await _saveDraftEntry(draftId: _activeDraftId);
} catch (_) {
return true;
}
} else if (choice == _ExitChoice.discard) {
// Delay reset to avoid setState during the dialog/build phase.
await Future<void>.delayed(Duration.zero);
if (!mounted) return false;
await _resetFormState(clearDraft: true);
_activeDraftId = null;
}
return true;
}
bool _draftChangedFromBaseline() {
if (_loadedDraftSnapshot == null) return true;
final current = _buildDraftSnapshot(
id: _activeDraftId ?? 'temp',
includeTimestamp: false,
);
return !_snapshotEquality.equals(_loadedDraftSnapshot, current);
}
bool _formIsEmpty() {
final beginDelayVal = _parseDelayMinutes(_beginDelayController.text);
final endDelayVal = _parseDelayMinutes(_endDelayController.text);
return _startController.text.trim().isEmpty &&
_endController.text.trim().isEmpty &&
_headcodeController.text.trim().isEmpty &&
_notesController.text.trim().isEmpty &&
_networkController.text.trim().isEmpty &&
_mileageController.text.trim().isEmpty &&
_originController.text.trim().isEmpty &&
_destinationController.text.trim().isEmpty &&
beginDelayVal == 0 &&
endDelayVal == 0 &&
!_hasOriginTime &&
!_hasDestinationTime &&
!_hasEndTime &&
_routeResult == null &&
_tractionItems.length <= 1;
}
Future<_ExitChoice> _promptSaveDraft() async {
if (!mounted) return _ExitChoice.cancel;
try {
final result = await showDialog<_ExitChoice>(
context: context,
barrierDismissible: false,
useRootNavigator: false,
builder: (_) => AlertDialog(
title: const Text('Save draft?'),
content: const Text(
'Do you want to save this entry as a draft before leaving?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(_ExitChoice.discard),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(_ExitChoice.save),
child: const Text('Yes'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(_ExitChoice.cancel),
child: const Text('Cancel'),
),
],
),
);
if (!mounted) return _ExitChoice.cancel;
return result ?? _ExitChoice.cancel;
} catch (_) {
return _ExitChoice.cancel;
}
}
Future<void> _openDrafts() async {
if (_activeLegShare != null) return;
final selected = await Navigator.of(context).push<_StoredDraft>(
MaterialPageRoute(
builder: (_) => _DraftListPage(
loadDrafts: _loadSavedDrafts,
onDeleteDraft: _deleteDraft,
),
),
);
if (selected != null) {
_activeDraftId = selected.id;
await _loadDraftEntry(selected.data);
}
}
Future<void> _saveDraftManually() async {
if (_activeLegShare != null) return;
if (_savingDraft) return;
if (_formIsEmpty()) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
const SnackBar(content: Text('Nothing to save yet.')),
);
return;
}
final hadDraft = _activeDraftId != null;
_setState(() => _savingDraft = true);
try {
await _saveDraftEntry(draftId: _activeDraftId);
if (!mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text(hadDraft ? 'Draft updated' : 'Draft saved')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text('Failed to save draft: $e')),
);
} finally {
if (mounted) _setState(() => _savingDraft = false);
}
}
Future<void> _saveDraft() async {
if (_restoringDraft || !_draftPersistenceEnabled || _activeLegShare != null) {
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,
"origin": _originController.text,
"destination": _destinationController.text,
"hasEndTime": _hasEndTime,
"hasOriginTime": _hasOriginTime,
"hasDestinationTime": _hasDestinationTime,
"endDate": _selectedEndDate.toIso8601String(),
"endTime": {
"hour": _selectedEndTime.hour,
"minute": _selectedEndTime.minute,
},
"originDate": _selectedOriginDate.toIso8601String(),
"originTime": {
"hour": _selectedOriginTime.hour,
"minute": _selectedOriginTime.minute,
},
"destinationDate": _selectedDestinationDate.toIso8601String(),
"destinationTime": {
"hour": _selectedDestinationTime.hour,
"minute": _selectedDestinationTime.minute,
},
"matchOriginToEntry": _matchOriginToEntry,
"matchDestinationToEntry": _matchDestinationToEntry,
"beginDelay": _parseDelayMinutes(_beginDelayController.text),
"endDelay": _parseDelayMinutes(_endDelayController.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(),
"shareUserIds": _shareUserIds.toList(),
"shareUsers": _shareUsers
.map((u) => {
"user_id": u.userId,
"username": u.username,
"full_name": u.fullName,
})
.toList(),
};
await prefs.setString(_kDraftPrefsKey, jsonEncode(draft));
}
Future<void> _clearDraft() async {
if (!_draftPersistenceEnabled) return;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kDraftPrefsKey);
}
Future<List<_StoredDraft>> _loadSavedDrafts() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_kDraftListPrefsKey);
if (raw == null || raw.isEmpty) return [];
try {
final decoded = jsonDecode(raw);
if (decoded is! List) return [];
return decoded
.whereType<Map>()
.map((e) => _StoredDraft.fromJson(Map<String, dynamic>.from(e)))
.toList()
..sort((a, b) => b.savedAt.compareTo(a.savedAt));
} catch (_) {
return [];
}
}
Future<void> _deleteDraft(String id) async {
final drafts = await _loadSavedDrafts();
drafts.removeWhere((d) => d.id == id);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_kDraftListPrefsKey,
jsonEncode(drafts.map((e) => e.toJson()).toList()),
);
if (_activeDraftId == id) {
_activeDraftId = null;
}
}
Future<String> _saveDraftEntry({String? draftId}) async {
final id = draftId ?? DateTime.now().microsecondsSinceEpoch.toString();
final snapshot = _buildDraftSnapshot(id: id);
final drafts = await _loadSavedDrafts();
final now = DateTime.now();
final existingIndex = drafts.indexWhere((d) => d.id == id);
final newDraft = _StoredDraft(id: id, savedAt: now, data: snapshot);
if (existingIndex >= 0) {
drafts[existingIndex] = newDraft;
} else {
drafts.insert(0, newDraft);
}
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_kDraftListPrefsKey,
jsonEncode(drafts.map((e) => e.toJson()).toList()),
);
_activeDraftId = id;
_loadedDraftSnapshot = _buildDraftSnapshot(id: id, includeTimestamp: false);
return id;
}
Map<String, dynamic> _buildDraftSnapshot({
required String id,
bool includeTimestamp = true,
}) {
final units = _distanceUnits(context);
final routeStations = _routeResult?.calculatedRoute ?? [];
final endTime = _legEndDateTime;
final originTime = _originDateTime;
final destinationTime = _destinationDateTime;
final beginDelay = _parseDelayMinutes(_beginDelayController.text);
final endDelay =
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
final startVal = _useManualMileage
? _startController.text.trim()
: (routeStations.isNotEmpty ? routeStations.first : '');
final endVal = _useManualMileage
? _endController.text.trim()
: (routeStations.isNotEmpty ? routeStations.last : '');
final mileageVal = _useManualMileage
? (units.milesFromInput(_mileageController.text.trim()) ?? 0)
: (_routeResult?.distance ?? 0);
final tractionPayload = _buildTractionPayload();
final commonPayload = {
"leg_trip": _selectedTripId,
"leg_begin_time": _legDateTime.toIso8601String(),
if (endTime != null) "leg_end_time": endTime.toIso8601String(),
if (originTime != null) "leg_origin_time": originTime.toIso8601String(),
if (destinationTime != null)
"leg_destination_time": destinationTime.toIso8601String(),
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
"leg_network": _networkController.text.trim(),
"leg_origin": _originController.text.trim(),
"leg_destination": _destinationController.text.trim(),
"leg_begin_delay": beginDelay,
if (_hasEndTime) "leg_end_delay": endDelay,
"locos": tractionPayload,
};
final payload = _useManualMileage
? {
...commonPayload,
"leg_start": startVal,
"leg_end": endVal,
"leg_distance": mileageVal,
"isKilometers": false,
}
: {
...commonPayload,
"leg_route": routeStations,
"leg_mileage": _routeResult?.distance ?? mileageVal,
};
return {
"id": id,
if (includeTimestamp) "saved_at": DateTime.now().toIso8601String(),
"mode": _useManualMileage ? 'manual' : 'auto',
"payload": payload,
"mileageText": _mileageController.text.trim(),
"routeResult": _routeResult == null
? null
: {
"input_route": _routeResult!.inputRoute,
"calculated_route": _routeResult!.calculatedRoute,
"costs": _routeResult!.costs,
"distance": _routeResult!.distance,
},
"tractionItems": _serializeTractionItems(),
"shareUserIds": _shareUserIds.toList(),
"shareUsers": _shareUsers
.map((u) => {
"user_id": u.userId,
"username": u.username,
"full_name": u.fullName,
})
.toList(),
};
}
Future<void> _loadDraftEntry(Map<String, dynamic> data) async {
if (!mounted) return;
final payloadRaw = data['payload'];
if (payloadRaw is! Map) return;
final payload = Map<String, dynamic>.from(payloadRaw);
final mode = data['mode'] as String?;
final useManual =
mode == 'manual' ||
(payload.containsKey('leg_distance') &&
!payload.containsKey('leg_route'));
final beginStr = payload['leg_begin_time'] as String?;
final beginTime = beginStr == null
? DateTime.now()
: DateTime.tryParse(beginStr) ?? DateTime.now();
final originTimeStr = payload['leg_origin_time'] as String?;
final destinationTimeStr = payload['leg_destination_time'] as String?;
final originTime =
originTimeStr == null ? null : DateTime.tryParse(originTimeStr);
final destinationTime = destinationTimeStr == null
? null
: DateTime.tryParse(destinationTimeStr);
final endStr = payload['leg_end_time'] as String?;
final endTime =
endStr == null ? null : DateTime.tryParse(endStr);
final beginDelay =
_parseDelayMinutes('${payload['leg_begin_delay'] ?? ''}');
final endDelay =
_parseDelayMinutes('${payload['leg_end_delay'] ?? ''}');
final hasEndTime = endTime != null || endDelay != 0;
final matchOrigin = data['matchOriginToEntry'] == true;
final matchDestination = data['matchDestinationToEntry'] == true;
final hasOriginTime =
originTime != null || data['hasOriginTime'] == true;
final hasDestinationTime =
destinationTime != null || data['hasDestinationTime'] == true;
final origin = payload['leg_origin'] as String? ?? '';
final destination = payload['leg_destination'] as String? ?? '';
final tripRaw = payload['leg_trip'];
final tripId = tripRaw is num ? tripRaw.toInt() : null;
final units = _distanceUnits(context);
List<String> routeStations = [];
RouteResult? restoredRouteResult;
if (!useManual) {
if (payload['leg_route'] is List) {
routeStations = (payload['leg_route'] as List)
.map((e) => e.toString())
.toList();
}
final rr = data['routeResult'];
if (rr is Map<String, dynamic>) {
restoredRouteResult = RouteResult(
inputRoute:
(rr['input_route'] as List?)?.map((e) => e.toString()).toList() ??
routeStations,
calculatedRoute:
(rr['calculated_route'] as List?)
?.map((e) => e.toString())
.toList() ??
routeStations,
costs:
(rr['costs'] as List?)
?.map((e) => (e as num).toDouble())
.toList() ??
[],
distance:
(rr['distance'] as num?)?.toDouble() ??
(payload['leg_mileage'] as num?)?.toDouble() ??
0,
);
} else if (routeStations.isNotEmpty) {
restoredRouteResult = RouteResult(
inputRoute: routeStations,
calculatedRoute: routeStations,
costs: const [],
distance: (payload['leg_mileage'] as num?)?.toDouble() ?? 0,
);
}
}
_restoringDraft = true;
_setState(() {
_useManualMileage = useManual;
_selectedDate = beginTime;
_selectedTime = TimeOfDay.fromDateTime(beginTime);
_selectedEndDate = endTime ?? beginTime;
_selectedEndTime = TimeOfDay.fromDateTime(endTime ?? beginTime);
_hasEndTime = hasEndTime;
_matchOriginToEntry = matchOrigin;
_matchDestinationToEntry = matchDestination;
_selectedOriginDate = originTime ?? beginTime;
_selectedOriginTime =
TimeOfDay.fromDateTime(originTime ?? beginTime);
_selectedDestinationDate =
destinationTime ?? endTime ?? beginTime;
_selectedDestinationTime = TimeOfDay.fromDateTime(
destinationTime ?? endTime ?? beginTime,
);
_hasOriginTime = hasOriginTime;
_hasDestinationTime = hasDestinationTime;
_selectedTripId = tripId == null || tripId == 0 ? null : tripId;
_routeResult = restoredRouteResult;
_headcodeController.text = (payload['leg_headcode'] as String? ?? '')
.toUpperCase();
_networkController.text = (payload['leg_network'] as String? ?? '')
.toUpperCase();
_notesController.text = payload['leg_notes'] ?? '';
_originController.text = origin;
_destinationController.text = destination;
_beginDelayController.text = beginDelay.toString();
_endDelayController.text = endDelay.toString();
if (useManual) {
_startController.text = payload['leg_start'] ?? '';
_endController.text = payload['leg_end'] ?? '';
final miles = (payload['leg_distance'] as num?)?.toDouble();
_mileageController.text = miles == null || miles == 0
? ''
: units.format(
miles,
decimals: 2,
includeUnit: false,
);
} else {
_startController.text =
routeStations.isNotEmpty ? routeStations.first : '';
_endController.text =
routeStations.isNotEmpty ? routeStations.last : '';
final dist = _routeResult?.distance ?? 0;
_mileageController.text = dist == 0
? ''
: units.format(dist, decimals: 2, includeUnit: false);
}
final tractionRaw = data['tractionItems'];
if (tractionRaw is List) {
_restoreTractionItems(
List<Map<String, dynamic>>.from(tractionRaw.cast<Map>()),
);
} else {
_tractionItems
..clear()
..add(_TractionItem.marker());
}
final shareIdsRaw = data['shareUserIds'];
final shareUsersRaw = data['shareUsers'];
_shareUserIds = shareIdsRaw is List
? shareIdsRaw.map((e) => e.toString()).toSet()
: {};
_shareUsers = shareUsersRaw is List
? shareUsersRaw
.whereType<Map>()
.map((e) => UserSummary.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList()
: [];
_lastSubmittedSnapshot = null;
final idRaw = data['id'];
if (idRaw != null) {
_activeDraftId = idRaw.toString();
}
});
final baselineId =
_activeDraftId ?? data['id']?.toString() ?? DateTime.now().toString();
_loadedDraftSnapshot = _buildDraftSnapshot(
id: baselineId,
includeTimestamp: false,
);
_restoringDraft = false;
_scheduleMatchUpdate();
}
Future<void> _loadDraft() async {
// legacy single draft no-op
}
}

View File

@@ -0,0 +1,179 @@
part of 'new_entry.dart';
enum _ExitChoice { save, discard, cancel }
class _StoredDraft {
final String id;
final DateTime savedAt;
final Map<String, dynamic> data;
_StoredDraft({required this.id, required this.savedAt, required this.data});
factory _StoredDraft.fromJson(Map<String, dynamic> json) {
final savedAt = DateTime.tryParse(json['saved_at'] ?? '') ?? DateTime.now();
final data = Map<String, dynamic>.from(json['data'] as Map? ?? {});
final embeddedId = data['id']?.toString();
return _StoredDraft(
id:
json['id']?.toString() ??
embeddedId ??
savedAt.microsecondsSinceEpoch.toString(),
savedAt: savedAt,
data: data,
);
}
Map<String, dynamic> toJson() {
return {"id": id, "saved_at": savedAt.toIso8601String(), "data": data};
}
}
class _DraftListPage extends StatelessWidget {
const _DraftListPage({required this.loadDrafts, required this.onDeleteDraft});
final Future<List<_StoredDraft>> Function() loadDrafts;
final Future<void> Function(String id) onDeleteDraft;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Drafts')),
body: FutureBuilder<List<_StoredDraft>>(
future: loadDrafts(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final drafts = snapshot.data ?? const [];
if (drafts.isEmpty) {
return const Center(child: Text('No drafts saved yet.'));
}
return _DraftListBody(drafts: drafts, onDelete: onDeleteDraft);
},
),
);
}
}
class _DraftListBody extends StatefulWidget {
const _DraftListBody({required this.drafts, required this.onDelete});
final List<_StoredDraft> drafts;
final Future<void> Function(String id) onDelete;
@override
State<_DraftListBody> createState() => _DraftListBodyState();
}
class _DraftListBodyState extends State<_DraftListBody> {
late final List<_StoredDraft> _drafts = List.of(widget.drafts);
@override
Widget build(BuildContext context) {
return ListView.separated(
itemCount: _drafts.length,
separatorBuilder: (context, _) => const Divider(height: 0),
itemBuilder: (context, index) {
final draft = _drafts[index];
final routeLine = _draftSubtitle(draft);
final metaLine = _draftMetaLine(draft);
return ListTile(
title: Text(DateFormat.yMMMd().add_jm().format(draft.savedAt)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (routeLine != null) Text(routeLine),
if (metaLine.isNotEmpty) Text(metaLine),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Delete draft',
icon: const Icon(Icons.delete),
onPressed: () => _confirmDelete(context, draft),
),
const Icon(Icons.chevron_right),
],
),
onTap: () => Navigator.of(context).pop(draft),
);
},
);
}
Future<void> _confirmDelete(BuildContext context, _StoredDraft draft) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Delete draft?'),
content: const Text('This draft will be removed permanently.'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true) return;
await widget.onDelete(draft.id);
if (!mounted) return;
setState(() {
_drafts.removeWhere((d) => d.id == draft.id);
});
}
String? _draftSubtitle(_StoredDraft draft) {
final payload = draft.data['payload'];
if (payload is! Map) return null;
final map = Map<String, dynamic>.from(payload);
String start = map['leg_start']?.toString() ?? '';
String end = map['leg_end']?.toString() ?? '';
if (start.isEmpty && end.isEmpty) {
if (map['leg_route'] is List && (map['leg_route'] as List).isNotEmpty) {
start = (map['leg_route'] as List).first.toString();
end = (map['leg_route'] as List).last.toString();
}
}
if (start.isEmpty && end.isEmpty) return null;
if (start.isNotEmpty && end.isNotEmpty) {
return '$start$end';
}
return start.isNotEmpty ? start : end;
}
String _draftMetaLine(_StoredDraft draft) {
final payload = draft.data['payload'];
if (payload is! Map) return '';
final map = Map<String, dynamic>.from(payload);
final units = context.read<DistanceUnitService>();
final parts = <String>[];
if ((map['leg_trip'] as int? ?? 0) != 0) {
parts.add('Trip ${map['leg_trip']}');
}
final headcode = (map['leg_headcode'] as String? ?? '').trim();
if (headcode.isNotEmpty) parts.add('Headcode $headcode');
final network = (map['leg_network'] as String? ?? '').trim();
if (network.isNotEmpty) parts.add('Network $network');
final notes = (map['leg_notes'] as String? ?? '').trim();
if (notes.isNotEmpty) parts.add('Notes');
final mileage =
(map['leg_distance'] as num?)?.toDouble() ??
(map['leg_mileage'] as num?)?.toDouble();
if (mileage != null && mileage > 0) {
parts.add(units.format(mileage, decimals: 1));
} else if (map['leg_route'] is List &&
(map['leg_route'] as List).isNotEmpty) {
parts.add('Route ${(map['leg_route'] as List).length} stops');
}
final locos = map['locos'];
if (locos is List && locos.isNotEmpty) {
parts.add('${locos.length} traction');
}
return parts.join('');
}
}

View File

@@ -0,0 +1,25 @@
part of 'new_entry.dart';
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,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
part of 'new_entry.dart';
class _CalculatorPickerPage extends StatelessWidget {
const _CalculatorPickerPage({
required this.onResult,
this.initialStations,
});
final ValueChanged<RouteResult> onResult;
final List<String>? initialStations;
@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,
initialStations: initialStations,
),
);
}
}

View File

@@ -0,0 +1,307 @@
part of 'new_entry.dart';
extension _NewEntrySubmitLogic on _NewEntryPageState {
Future<bool> _validateRequiredFields() async {
final missing = <String>[];
final units = _distanceUnits(context);
if (_useManualMileage) {
if (_startController.text.trim().isEmpty) missing.add('From');
if (_endController.text.trim().isEmpty) missing.add('To');
final mileageText = _mileageController.text.trim();
if (mileageText.isEmpty || units.milesFromInput(mileageText) == null) {
missing.add('Mileage');
}
} else {
if (_routeResult == null || _routeResult!.calculatedRoute.isEmpty) {
missing.add('Route');
}
}
if (_useManualMileage && _networkController.text.trim().isEmpty) {
missing.add('Network');
}
if (missing.isEmpty) return true;
if (!mounted) return false;
final fieldList = missing.join(', ');
await showDialog<void>(
context: context,
useRootNavigator: false,
builder: (dialogCtx) => AlertDialog(
title: const Text('Required field missing'),
content: Text(
missing.length == 1
? 'Please fill the following field: $fieldList.'
: 'Please fill the following fields: $fieldList.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('OK'),
),
],
),
);
return false;
}
Future<void> _submit() async {
final form = _formKey.currentState;
if (form == null) return;
if (!form.validate()) return;
if (!await _validateRequiredFields()) return;
if (!mounted) return;
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 units = _distanceUnits(context);
final mileageVal = _useManualMileage
? (units.milesFromInput(_mileageController.text.trim()) ?? 0)
: (_routeResult?.distance ?? 0);
final tractionPayload = _buildTractionPayload();
final endTime = _legEndDateTime;
final originTime = _originDateTime;
final destinationTime = _destinationDateTime;
final beginDelay = _parseDelayMinutes(_beginDelayController.text);
final endDelay =
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
final snapshot = _currentSubmissionSnapshot(
routeStations: routeStations,
startVal: startVal,
endVal: endVal,
mileageVal: mileageVal,
tractionPayload: tractionPayload,
beginDelay: beginDelay,
endDelay: endDelay,
);
if (_lastSubmittedSnapshot != null &&
_snapshotEquality.equals(_lastSubmittedSnapshot, snapshot)) {
final confirmed = await _confirmDuplicateSubmission();
if (!confirmed) return;
}
if (!mounted) return;
final api = context.read<ApiService>();
final dataService = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context);
_setState(() => _submitting = true);
final isEditingExisting = _isEditing && widget.editLegId != null;
try {
final commonPayload = {
if (isEditingExisting) "leg_id": widget.editLegId,
"leg_trip": _selectedTripId,
"leg_begin_time": _legDateTime.toIso8601String(),
if (endTime != null) "leg_end_time": endTime.toIso8601String(),
if (originTime != null)
"leg_origin_time": originTime.toIso8601String(),
if (destinationTime != null)
"leg_destination_time": destinationTime.toIso8601String(),
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
if (_useManualMileage)
"leg_network": _networkController.text.trim(),
"leg_origin": _originController.text.trim(),
"leg_destination": _destinationController.text.trim(),
"leg_begin_delay": beginDelay,
if (_hasEndTime) "leg_end_delay": endDelay,
"locos": tractionPayload,
if (_activeLegShare != null) "leg_share_id": _activeLegShare!.id,
"share_user_ids": _shareUserIds.toList(),
};
if (_useManualMileage) {
final body = {
...commonPayload,
"leg_start": startVal,
"leg_end": endVal,
"leg_distance": mileageVal,
"isKilometers": false,
};
if (isEditingExisting) {
await api.put('/update', body);
} else {
await api.post('/add/manual', body);
}
} else {
final body = {
...commonPayload,
"leg_route": routeStations,
};
if (isEditingExisting) {
await api.put('/update', body);
} else {
await api.post('/add', body);
}
}
if (!mounted) return;
dataService.refreshLegs();
await dataService.fetchNotifications();
if (_shareNotificationId != null) {
await dataService.dismissNotifications([_shareNotificationId!]);
}
if (!mounted) return;
messenger?.showSnackBar(
SnackBar(
content: Text(isEditingExisting ? 'Entry updated' : 'Entry submitted'),
),
);
_lastSubmittedSnapshot = snapshot;
_activeDraftId = null;
} catch (e, st) {
debugPrint('Leg submit/update failed: $e');
debugPrintStack(stackTrace: st);
if (!mounted) return;
messenger?.showSnackBar(
SnackBar(content: Text('Failed to submit: $e')),
);
} finally {
if (mounted) _setState(() => _submitting = false);
}
}
Map<String, dynamic> _currentSubmissionSnapshot({
List<String>? routeStations,
String? startVal,
String? endVal,
double? mileageVal,
List<Map<String, dynamic>>? tractionPayload,
int? beginDelay,
int? endDelay,
}) {
final stations = routeStations ?? (_routeResult?.calculatedRoute ?? []);
final start = startVal ??
(_useManualMileage
? _startController.text.trim()
: (stations.isNotEmpty ? stations.first : ''));
final end = endVal ??
(_useManualMileage
? _endController.text.trim()
: (stations.isNotEmpty ? stations.last : ''));
final mileage = mileageVal ??
(_useManualMileage
? (_distanceUnits(context)
.milesFromInput(_mileageController.text.trim()) ??
0)
: (_routeResult?.distance ?? 0));
final traction = tractionPayload ?? _buildTractionPayload();
final begin = beginDelay ?? _parseDelayMinutes(_beginDelayController.text);
final endDelayVal =
endDelay ?? (_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0);
return {
"legId": widget.editLegId,
"useManualMileage": _useManualMileage,
"tripId": _selectedTripId,
"legDateTime": _legDateTime.toIso8601String(),
"legEndTime": _legEndDateTime?.toIso8601String(),
"hasEndTime": _hasEndTime,
"legOriginTime": _originDateTime?.toIso8601String(),
"hasOriginTime": _hasOriginTime,
"legDestinationTime": _destinationDateTime?.toIso8601String(),
"hasDestinationTime": _hasDestinationTime,
"start": start,
"end": end,
"origin": _originController.text.trim(),
"destination": _destinationController.text.trim(),
"routeStations": stations,
"mileage": mileage,
"network": _networkController.text.trim(),
"notes": _notesController.text.trim(),
"headcode": _headcodeController.text.trim(),
"beginDelay": begin,
"endDelay": endDelayVal,
"legShareId": _activeLegShare?.id,
"shareUserIds": _shareUserIds.toList(),
"locos": traction,
"routeResult": _routeResult == null
? null
: {
"input_route": _routeResult!.inputRoute,
"calculated_route": _routeResult!.calculatedRoute,
"costs": _routeResult!.costs,
"distance": _routeResult!.distance,
},
};
}
Future<bool> _confirmDuplicateSubmission() async {
if (!mounted) return false;
final result = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Duplicate entry?'),
content: const Text('Entry already added, are you sure?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Submit anyway'),
),
],
),
);
return result ?? false;
}
Future<void> _resetFormState({bool clearDraft = false}) async {
final hadShare = _activeLegShare != null || widget.legShare != null;
_formKey.currentState?.reset();
_startController.clear();
_endController.clear();
_headcodeController.clear();
_notesController.clear();
_mileageController.clear();
_networkController.clear();
_originController.clear();
_destinationController.clear();
_beginDelayController.text = '0';
_endDelayController.text = '0';
final now = DateTime.now();
_setState(() {
_selectedDate = now;
_selectedTime = TimeOfDay.fromDateTime(now);
_selectedEndDate = now;
_selectedEndTime = TimeOfDay.fromDateTime(now);
_selectedOriginDate = now;
_selectedOriginTime = TimeOfDay.fromDateTime(now);
_selectedDestinationDate = now;
_selectedDestinationTime = TimeOfDay.fromDateTime(now);
_useManualMileage = false;
_hasEndTime = false;
_hasOriginTime = false;
_hasDestinationTime = false;
_matchOriginToEntry = false;
_matchDestinationToEntry = false;
_matchUpdateScheduled = false;
_routeResult = null;
_activeLegShare = null;
_sharedFromUser = null;
_shareNotificationId = null;
_shareUserIds.clear();
_shareUsers.clear();
_tractionItems
..clear()
..add(_TractionItem.marker());
_selectedTripId = null;
_submitting = false;
_activeDraftId = null;
_savingDraft = false;
_loadedDraftSnapshot = null;
});
if (hadShare && mounted) {
// Clear any share params from the URL when resetting.
GoRouter.of(context).go('/add');
}
if (clearDraft) {
await _clearDraft();
}
}
}

View File

@@ -0,0 +1,148 @@
part of 'new_entry.dart';
extension _NewEntryTractionLogic on _NewEntryPageState {
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),
);
}
});
_saveDraft();
},
),
),
);
}
List<_TractionItem> _buildTractionFromApi(
List<Map<String, dynamic>> locoData,
) {
if (locoData.isEmpty) return [_TractionItem.marker()];
final sorted = [...locoData]
..sort((a, b) {
return _allocPos(b).compareTo(_allocPos(a));
});
final leading = sorted.where((e) => _allocPos(e) >= 0);
final trailing = sorted.where((e) => _allocPos(e) < 0);
return [
...leading.map(_mapLocoToTractionItem),
_TractionItem.marker(),
...trailing.map(_mapLocoToTractionItem),
];
}
int _allocPos(Map<String, dynamic> loco) =>
(loco['alloc_pos'] as num?)?.toInt() ?? 0;
_TractionItem _mapLocoToTractionItem(Map<String, dynamic> loco) {
final poweringRaw = loco['alloc_powering'];
final powering = poweringRaw == true || poweringRaw == 1;
return _TractionItem(loco: LocoSummary.fromJson(loco), powering: powering);
}
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;
final locoId = item.loco!.id;
if (locoId == 0) continue;
int allocPos;
if (i > markerIndex) {
allocPos = -(i - markerIndex);
} else {
allocPos = (markerIndex - 1) - i;
}
payload.add({
"loco_id": locoId,
"alloc_pos": allocPos,
"alloc_powering": item.powering ? 1 : 0,
});
}
return payload;
}
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);
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/data_service.dart';
enum _SpeedUnit { kph, mph } enum _SpeedUnit { kph, mph }
@@ -84,6 +84,13 @@ class _NewTractionPageState extends State<NewTractionPage> {
'traction_motors': TextEditingController(), 'traction_motors': TextEditingController(),
'build_date': TextEditingController(), 'build_date': TextEditingController(),
}; };
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final data = context.read<DataService>();
if (data.locoClasses.isEmpty) {
data.fetchClassList();
}
});
} }
@override @override
@@ -254,6 +261,10 @@ class _NewTractionPageState extends State<NewTractionPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isActive = _statusIsActive; final isActive = _statusIsActive;
final data = context.watch<DataService>();
final classOptions = [...data.locoClasses]..sort(
(a, b) => a.toLowerCase().compareTo(b.toLowerCase()),
);
final size = MediaQuery.of(context).size; final size = MediaQuery.of(context).size;
final isNarrow = size.width < 720; final isNarrow = size.width < 720;
final fieldWidth = isNarrow ? double.infinity : 340.0; final fieldWidth = isNarrow ? double.infinity : 340.0;
@@ -269,6 +280,89 @@ class _NewTractionPageState extends State<NewTractionPage> {
double? widthOverride, double? widthOverride,
String? Function(String?)? validator, String? Function(String?)? validator,
}) { }) {
// Special autocomplete for class field using existing loco classes.
if (key == 'class' && classOptions.isNotEmpty) {
return SizedBox(
width: widthOverride ?? fieldWidth,
child: Autocomplete<String>(
optionsBuilder: (TextEditingValue value) {
final query = value.text.trim().toLowerCase();
if (query.isEmpty) return classOptions;
return classOptions.where(
(c) => c.toLowerCase().contains(query),
);
},
onSelected: (selection) {
_controllers[key]?.text = selection;
_formKey.currentState?.validate();
},
fieldViewBuilder:
(context, textEditingController, focusNode, onFieldSubmitted) {
if (textEditingController.text != _controllers[key]?.text) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (textEditingController.text != _controllers[key]?.text) {
textEditingController.value =
_controllers[key]?.value ?? textEditingController.value;
}
});
}
return TextFormField(
controller: textEditingController,
focusNode: focusNode,
decoration: InputDecoration(
labelText: required ? '$label *' : label,
helperText: helper,
suffixText: suffixText,
border: const OutlineInputBorder(),
),
keyboardType: keyboardType,
maxLines: maxLines,
validator: (val) {
if (required && (val == null || val.trim().isEmpty)) {
return 'Required';
}
return validator?.call(val);
},
onChanged: (_) {
_controllers[key]?.text = textEditingController.text;
_formKey.currentState?.validate();
},
onFieldSubmitted: (_) => onFieldSubmitted(),
);
},
optionsViewBuilder: (context, onSelected, options) {
final opts = options.toList();
if (opts.isEmpty) return const SizedBox.shrink();
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: 280,
maxWidth: widthOverride ?? fieldWidth,
),
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: opts.length,
itemBuilder: (context, index) {
final option = opts[index];
return ListTile(
dense: true,
title: Text(option),
onTap: () => onSelected(option),
);
},
),
),
),
);
},
),
);
}
return SizedBox( return SizedBox(
width: widthOverride ?? fieldWidth, width: widthOverride ?? fieldWidth,
child: TextFormField( child: TextFormField(

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,380 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'package:mileograph_flutter/services/accent_color_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/endpoint_service.dart';
import 'package:mileograph_flutter/services/theme_mode_service.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController _endpointController;
bool _saving = false;
static const List<Color> _accentPalette = [
Colors.red,
Colors.pink,
Colors.orange,
Colors.amber,
Colors.green,
Colors.teal,
Colors.blue,
Colors.indigo,
Colors.purple,
Colors.cyan,
];
@override
void initState() {
super.initState();
final endpoint = context.read<EndpointService>().baseUrl;
_endpointController = TextEditingController(text: endpoint);
}
@override
void dispose() {
_endpointController.dispose();
super.dispose();
}
Future<String?> _probeVersion(String url) async {
try {
var uri = Uri.parse(url.trim());
if (uri.scheme.isEmpty) {
uri = Uri.parse('https://$url');
}
// Probe the provided API endpoint as-is.
final target = uri;
final res = await http.get(target).timeout(const Duration(seconds: 10));
debugPrint(
'Endpoint probe ${target.toString()} -> ${res.statusCode} ${res.body}',
);
if (res.statusCode < 200 || res.statusCode >= 300) return null;
final body = res.body.trim();
debugPrint('Endpoint probe body: $body');
// Try JSON first
String? version;
try {
final parsed = jsonDecode(body);
debugPrint('Endpoint probe parsed: $parsed');
if (parsed is Map && parsed['version'] is String) {
version = parsed['version'] as String;
} else if (parsed is String) {
final candidate = parsed.trim().replaceAll('"', '');
if (RegExp(r'^\d+\.\d+\.\d+$').hasMatch(candidate)) {
version = candidate;
}
}
} catch (_) {
// fall back to raw body parsing
}
version ??= body.split(RegExp(r'\s+')).firstWhere(
(part) => RegExp(r'^\d+\.\d+\.\d+$').hasMatch(part),
orElse: () => '',
);
if (version.isEmpty) return null;
final isValid = RegExp(r'^\d+\.\d+\.\d+$').hasMatch(version);
return isValid ? version : null;
} catch (_) {
return null;
}
}
Future<void> _save() async {
final endpointService = context.read<EndpointService>();
final dataService = context.read<DataService>();
final messenger = ScaffoldMessenger.of(context);
final value = _endpointController.text.trim();
if (value.isEmpty) {
messenger.showSnackBar(
const SnackBar(content: Text('Please enter an endpoint URL.')),
);
return;
}
setState(() => _saving = true);
try {
final version = await _probeVersion(value);
if (version == null) {
if (mounted) {
messenger.showSnackBar(
const SnackBar(
content: Text('Endpoint test failed: no valid version returned.'),
),
);
}
return;
}
await endpointService.setBaseUrl(value);
if (mounted) {
messenger.showSnackBar(
SnackBar(content: Text('Endpoint set to "$value" ($version)')),
);
await Future.wait([
dataService.fetchHomepageStats(),
dataService.fetchOnThisDay(),
dataService.fetchTrips(),
dataService.fetchHadTraction(),
dataService.fetchLatestLocoChanges(),
dataService.fetchLegs(),
]);
}
} catch (e) {
if (mounted) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to save endpoint: $e')),
);
}
} finally {
if (mounted) {
setState(() => _saving = false);
}
}
}
@override
Widget build(BuildContext context) {
final endpointService = context.watch<EndpointService>();
final distanceUnitService = context.watch<DistanceUnitService>();
final accentService = context.watch<AccentColorService>();
final themeModeService = context.watch<ThemeModeService>();
if (!endpointService.isLoaded ||
!distanceUnitService.isLoaded ||
!accentService.isLoaded ||
!themeModeService.isLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop();
} else {
context.go('/more');
}
},
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Distance units',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Choose how distances are displayed across the app.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
SegmentedButton<DistanceUnit>(
segments: DistanceUnit.values
.map(
(unit) => ButtonSegment<DistanceUnit>(
value: unit,
label: Text(unit.label),
),
)
.toList(),
selected: {distanceUnitService.unit},
onSelectionChanged: (selection) {
final next = selection.first;
distanceUnitService.setUnit(next);
},
),
const SizedBox(height: 24),
Text(
'Accent colour',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Choose your preferred accent colour or use system colours.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton.icon(
onPressed:
accentService.useSystem ? null : () => accentService.setUseSystem(true),
icon: const Icon(Icons.phone_android),
label: const Text('Use system colours'),
),
..._accentPalette.map(
(color) => _AccentSwatchButton(
color: color,
selected:
!accentService.useSystem &&
accentService.seedColor.toARGB32() == color.toARGB32(),
onTap: () => accentService.setSeedColor(color),
),
),
],
),
const SizedBox(height: 24),
Text(
'Theme mode',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.system,
icon: Icon(Icons.settings_suggest),
label: Text('System'),
),
ButtonSegment(
value: ThemeMode.light,
icon: Icon(Icons.light_mode),
label: Text('Light'),
),
ButtonSegment(
value: ThemeMode.dark,
icon: Icon(Icons.dark_mode),
label: Text('Dark'),
),
],
selected: {themeModeService.mode},
onSelectionChanged: (selection) {
final mode = selection.first;
themeModeService.setMode(mode);
},
),
const SizedBox(height: 24),
Text(
'API endpoint',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Set the base URL for the Mileograph API. Leave blank to use the default.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
TextField(
controller: _endpointController,
decoration: const InputDecoration(
labelText: 'Endpoint URL',
hintText: 'https://mileograph.co.uk/api/v1',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Row(
children: [
FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: const Text('Save endpoint'),
),
const SizedBox(width: 12),
TextButton(
onPressed: _saving
? null
: () {
_endpointController.text =
EndpointService.defaultBaseUrl;
},
child: const Text('Reset to default'),
),
],
),
const SizedBox(height: 12),
Text(
'Current: ${endpointService.baseUrl}',
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
);
}
}
class _AccentSwatchButton extends StatelessWidget {
const _AccentSwatchButton({
required this.color,
required this.selected,
required this.onTap,
});
final Color color;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final borderColor = selected
? Theme.of(context).colorScheme.onSurface
: Colors.black26;
return InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: borderColor,
width: selected ? 3 : 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: selected
? const Center(
child: Icon(
Icons.check,
size: 18,
color: Colors.white,
),
)
: null,
),
);
}
}

View File

@@ -0,0 +1,333 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class StatsPage extends StatefulWidget {
const StatsPage({super.key});
@override
State<StatsPage> createState() => _StatsPageState();
}
class _StatsPageState extends State<StatsPage> {
final NumberFormat _countFormat = NumberFormat.decimalPattern();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadStats();
});
}
Future<void> _loadStats({bool force = false}) {
return context.read<DataService>().fetchAboutStats(force: force);
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
final nav = Navigator.of(context);
if (nav.canPop()) {
nav.maybePop();
} else {
context.go('/more');
}
},
tooltip: 'Back',
),
title: const Text('Stats'),
),
body: RefreshIndicator(
onRefresh: () => _loadStats(force: true),
child: _buildContent(data, distanceUnits),
),
);
}
Widget _buildContent(
DataService data,
DistanceUnitService distanceUnits,
) {
final stats = data.aboutStats;
final loading = data.isAboutStatsLoading;
if (loading && stats == null) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 140),
Center(child: CircularProgressIndicator()),
SizedBox(height: 140),
],
);
}
if (stats == null || stats.sortedYears.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(24),
children: [
const SizedBox(height: 40),
const Center(child: Text('No stats available yet.')),
const SizedBox(height: 12),
Center(
child: OutlinedButton(
onPressed: () => _loadStats(force: true),
child: const Text('Retry'),
),
),
],
);
}
final years = stats.sortedYears;
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: years.length,
itemBuilder: (context, index) {
return Padding(
padding: EdgeInsets.only(bottom: index == years.length - 1 ? 0 : 12),
child: _buildYearCard(context, years[index], distanceUnits),
);
},
);
}
Widget _buildYearCard(
BuildContext context, StatsYear year, DistanceUnitService distanceUnits) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
year.year.toString(),
style: theme.textTheme.titleLarge,
),
const Spacer(),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
children: [
_buildInfoChip(
context,
label: 'Mileage',
value: distanceUnits.format(year.mileage, decimals: 1),
),
_buildInfoChip(
context,
label: 'Winners',
value: _countFormat.format(year.winnerCount),
),
],
),
],
),
const SizedBox(height: 8),
_buildTypeCountsSection(context, year),
_buildSection<StatsClassMileage>(
context,
title: 'Top classes',
items: year.topClasses,
emptyLabel: 'No class data',
itemBuilder: (item, index) => ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: Text(item.locoClass),
trailing: Text(
distanceUnits.format(item.mileage, decimals: 1),
),
),
),
_buildSection<StatsNetworkMileage>(
context,
title: 'Top networks',
items: year.topNetworks,
emptyLabel: 'No network data',
itemBuilder: (item, index) => ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: Text(item.network),
trailing: Text(
distanceUnits.format(item.mileage, decimals: 1),
),
),
),
if (year.topCountries.isNotEmpty)
_buildSection<StatsCountryMileage>(
context,
title: 'Top countries',
items: year.topCountries,
emptyLabel: 'No country data',
itemBuilder: (item, index) => ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: Text(item.country),
trailing: Text(
distanceUnits.format(item.mileage, decimals: 1),
),
),
),
_buildSection<StatsStationVisits>(
context,
title: 'Top stations',
items: year.topStations,
emptyLabel: 'No station data',
itemBuilder: (item, index) => ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: Text(item.station),
trailing: Text(
'${_countFormat.format(item.visits)} visit${item.visits == 1 ? '' : 's'}',
),
),
),
],
),
),
);
}
Widget _buildInfoChip(BuildContext context,
{required String label, required String value}) {
final theme = Theme.of(context);
return Chip(
padding: const EdgeInsets.symmetric(horizontal: 8),
label: Text(
'$label: $value',
style: theme.textTheme.labelLarge,
),
);
}
List<MapEntry<String, int>> _sortedTypeCounts(Map<String, int> counts) {
final entries = counts.entries.toList();
entries.sort((a, b) {
final countCompare = b.value.compareTo(a.value);
if (countCompare != 0) return countCompare;
return a.key.compareTo(b.key);
});
return entries;
}
Widget _buildTypeCountsSection(BuildContext context, StatsYear year) {
if (year.winnerTypeCounts.isEmpty && year.totalTypeCounts.isEmpty) {
return const SizedBox.shrink();
}
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Type counts',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 6),
_buildTypeCountsRow(
context,
label: 'Winners',
counts: year.winnerTypeCounts,
),
const SizedBox(height: 8),
_buildTypeCountsRow(
context,
label: 'Total',
counts: year.totalTypeCounts,
),
],
),
);
}
Widget _buildTypeCountsRow(
BuildContext context, {
required String label,
required Map<String, int> counts,
}) {
final theme = Theme.of(context);
final entries = _sortedTypeCounts(counts);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelLarge,
),
const SizedBox(height: 4),
if (entries.isEmpty)
Text(
'No data',
style: theme.textTheme.bodySmall,
)
else
Wrap(
spacing: 6,
runSpacing: 6,
children: entries
.map((entry) => _buildInfoChip(
context,
label: entry.key,
value: _countFormat.format(entry.value),
))
.toList(),
),
],
);
}
Widget _buildSection<T>(
BuildContext context, {
required String title,
required List<T> items,
required Widget Function(T item, int index) itemBuilder,
String emptyLabel = 'No data',
}) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 4),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 8),
childrenPadding:
const EdgeInsets.only(left: 8, right: 8, bottom: 8),
title: Text(
title,
style: theme.textTheme.titleMedium,
),
children: items.isEmpty
? [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
emptyLabel,
style: theme.textTheme.bodySmall,
),
),
]
: items
.asMap()
.entries
.map((entry) => itemBuilder(entry.value, entry.key))
.toList(),
),
);
}
}

View File

@@ -1,907 +1,2 @@
import 'package:flutter/material.dart'; export 'traction/traction.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/dataService.dart';
import 'package:provider/provider.dart';
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();
final _nameController = TextEditingController();
bool _mileageFirst = true;
bool _initialised = false;
bool _showAdvancedFilters = false;
String? _selectedClass;
late Set<String> _selectedKeys;
final Map<String, TextEditingController> _dynamicControllers = {};
final Map<String, String?> _enumSelections = {};
@override
void initState() {
super.initState();
_classController.addListener(_onClassTextChanged);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialised) {
_initialised = true;
_selectedKeys = {...widget.selectedKeys};
WidgetsBinding.instance.addPostFrameCallback((_) {
final data = context.read<DataService>();
data.fetchClassList();
data.fetchEventFields();
_refreshTraction();
});
}
}
@override
void dispose() {
_classController.removeListener(_onClassTextChanged);
_classController.dispose();
_classFocusNode.dispose();
_numberController.dispose();
_nameController.dispose();
for (final controller in _dynamicControllers.values) {
controller.dispose();
}
super.dispose();
}
bool get _hasFilters {
final dynamicFieldsUsed =
_dynamicControllers.values.any(
(controller) => controller.text.trim().isNotEmpty,
) ||
_enumSelections.values.any(
(value) => (value ?? '').toString().trim().isNotEmpty,
);
return [
_selectedClass,
_classController.text,
_numberController.text,
_nameController.text,
].any((value) => (value ?? '').toString().trim().isNotEmpty) ||
dynamicFieldsUsed;
}
Future<void> _refreshTraction({bool append = false}) async {
final data = context.read<DataService>();
final filters = <String, dynamic>{};
final name = _nameController.text.trim();
if (name.isNotEmpty) filters['name'] = name;
_dynamicControllers.forEach((key, controller) {
final value = controller.text.trim();
if (value.isNotEmpty) filters[key] = value;
});
_enumSelections.forEach((key, value) {
if (value != null && value.toString().trim().isNotEmpty) {
filters[key] = value;
}
});
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,
]) {
controller.clear();
}
for (final controller in _dynamicControllers.values) {
controller.clear();
}
_enumSelections.clear();
setState(() {
_selectedClass = null;
_mileageFirst = true;
});
_refreshTraction();
}
void _onClassTextChanged() {
if (_selectedClass != null &&
_classController.text.trim() != (_selectedClass ?? '')) {
setState(() {
_selectedClass = null;
});
}
}
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
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final traction = data.traction;
final classOptions = data.locoClasses;
final isMobile = MediaQuery.of(context).size.width < 700;
_ensureControllersForFields(data.eventFields);
final extraFields = _activeEventFields(data.eventFields);
final listView = RefreshIndicator(
onRefresh: _refreshTraction,
child: ListView(
padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: 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,
),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Refresh',
onPressed: _refreshTraction,
icon: const Icon(Icons.refresh),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: () async {
final createdClass = await context.push<String>(
'/traction/new',
);
if (createdClass != null && createdClass.isNotEmpty) {
_classController.text = createdClass;
_selectedClass = createdClass;
if (mounted) {
_refreshTraction();
}
} else if (mounted && createdClass == '') {
_refreshTraction();
}
},
icon: const Icon(Icons.add),
label: const Text('New Traction'),
),
],
),
],
),
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(
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: 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,
runSpacing: 12,
children: extraFields
.map(
(field) => _buildFilterInput(
context,
field,
isMobile,
),
)
.toList(),
),
),
secondChild: const SizedBox.shrink(),
),
],
),
),
),
const SizedBox(height: 12),
Stack(
children: [
if (data.isTractionLoading && traction.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Center(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 (data.isTractionLoading)
Positioned.fill(
child: IgnorePointer(
child: Container(
color: Theme.of(context).colorScheme.surface.withOpacity(0.6),
child: const Center(child: CircularProgressIndicator()),
),
),
),
],
),
],
),
);
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 ?? '';
final hasMileageOrTrips = _hasMileageOrTrips(loco);
final statusColors = _statusChipColors(context, status);
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: [
Row(
children: [
Text(
loco.number,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.w800),
),
if (hasMileageOrTrips)
Padding(
padding: const EdgeInsets.only(left: 6.0),
child: Icon(
Icons.check_circle,
size: 18,
color: Colors.green.shade600,
),
),
],
),
Text(
loco.locoClass,
style: Theme.of(context).textTheme.labelMedium,
),
if ((loco.name ?? '').isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
loco.name ?? '',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(fontStyle: FontStyle.italic),
),
),
],
),
Chip(
label: Text(status),
backgroundColor: statusColors.$1,
labelStyle: TextStyle(color: statusColors.$2),
),
],
),
const SizedBox(height: 8),
Row(
children: [
TextButton.icon(
onPressed: () => _showLocoInfo(loco),
icon: const Icon(Icons.info_outline),
label: const Text('Details'),
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: () => _openTimeline(loco),
icon: const Icon(Icons.timeline),
label: const Text('Timeline'),
),
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),
),
],
),
);
}
(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);
}
void _openTimeline(LocoSummary loco) {
final label = '${loco.locoClass} ${loco.number}'.trim();
context.push(
'/traction/${loco.id}/timeline',
extra: {'label': label},
);
}
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),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
loco.number,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w800),
),
if (_hasMileageOrTrips(loco))
Padding(
padding: const EdgeInsets.only(left: 6.0),
child: Icon(
Icons.check_circle,
size: 18,
color: Colors.green.shade600,
),
),
],
),
Text(
loco.locoClass,
style: Theme.of(context).textTheme.labelMedium,
),
],
),
],
),
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);
}
bool _hasMileageOrTrips(LocoSummary loco) {
final mileage = loco.mileage ?? 0;
final trips = loco.trips ?? loco.journeys ?? 0;
return mileage > 0 || trips > 0;
}
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)),
),
],
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

@@ -0,0 +1,21 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/traction/traction_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/utils/download_helper.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'traction_page.dart';
part 'traction_persistence.dart';
const String _kTractionSearchPrefsKey = 'traction_search_state_v1';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,152 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/traction/traction_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:provider/provider.dart';
class TractionPendingChangesPage extends StatefulWidget {
const TractionPendingChangesPage({super.key});
@override
State<TractionPendingChangesPage> createState() =>
_TractionPendingChangesPageState();
}
class _TractionPendingChangesPageState extends State<TractionPendingChangesPage> {
bool _isLoading = false;
String? _error;
List<LocoSummary> _locos = const [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final api = context.read<ApiService>();
final json = await api.get('/event/pending/locos');
if (json is List) {
setState(() {
_locos = json
.whereType<Map>()
.map((e) => LocoSummary.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList();
});
} else {
setState(() {
_error = 'Unexpected response';
_locos = const [];
});
}
} catch (e) {
setState(() {
_error = e.toString();
_locos = const [];
});
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final isElevated = context.select<AuthService, bool>((auth) => auth.isElevated);
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
title: const Text('Pending changes'),
),
body: isElevated
? RefreshIndicator(
onRefresh: _load,
child: _buildBody(context),
)
: ListView(
padding: const EdgeInsets.all(16),
children: const [
Text('Admin access required.'),
],
),
);
}
Widget _buildBody(BuildContext context) {
if (_isLoading && _locos.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Failed to load pending changes: $_error',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
);
}
if (_locos.isEmpty) {
return ListView(
padding: const EdgeInsets.all(16),
children: const [
Text('No pending changes found.'),
],
);
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _locos.length,
itemBuilder: (context, index) {
final loco = _locos[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: TractionCard(
loco: loco,
selectionMode: false,
isSelected: false,
onShowInfo: () => showTractionDetails(
context,
loco,
onActionComplete: _load,
),
onOpenTimeline: () => context.push(
'/traction/${loco.id}/timeline',
extra: {
'label': '${loco.locoClass} ${loco.number}'.trim(),
'showPending': true,
},
),
onOpenLegs: () => context.push('/traction/${loco.id}/legs'),
onActionComplete: _load,
),
);
},
);
}
}

View File

@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/traction/traction_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:provider/provider.dart';
class TractionPendingPage extends StatefulWidget {
const TractionPendingPage({super.key});
@override
State<TractionPendingPage> createState() => _TractionPendingPageState();
}
class _TractionPendingPageState extends State<TractionPendingPage> {
bool _isLoading = false;
String? _error;
List<LocoSummary> _locos = const [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final api = context.read<ApiService>();
final params = '?limit=200&offset=0';
final json = await api.get('/loco/pending$params');
if (json is List) {
setState(() {
_locos = json
.whereType<Map>()
.map((e) => LocoSummary.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList();
});
} else {
setState(() {
_error = 'Unexpected response';
_locos = const [];
});
}
} catch (e) {
setState(() {
_error = e.toString();
_locos = const [];
});
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
title: const Text('Pending traction'),
),
body: RefreshIndicator(
onRefresh: _load,
child: _buildBody(context),
),
);
}
Widget _buildBody(BuildContext context) {
if (_isLoading && _locos.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Failed to load pending traction: $_error',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
);
}
if (_locos.isEmpty) {
return ListView(
padding: const EdgeInsets.all(16),
children: const [
Text('No pending traction found.'),
],
);
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _locos.length,
itemBuilder: (context, index) {
final loco = _locos[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: TractionCard(
loco: loco,
selectionMode: false,
isSelected: false,
onShowInfo: () => showTractionDetails(
context,
loco,
onActionComplete: _load,
),
onOpenTimeline: () => context.push(
'/traction/${loco.id}/timeline',
extra: {'label': '${loco.locoClass} ${loco.number}'.trim()},
),
onOpenLegs: () => context.push('/traction/${loco.id}/legs'),
onActionComplete: _load,
),
);
},
);
}
}

View File

@@ -0,0 +1,100 @@
part of 'traction.dart';
extension _TractionPersistence on _TractionPageState {
Future<void> _restoreSearchState() async {
if (widget.selectionMode) return;
if (_restoredFromPrefs) return;
_restoredFromPrefs = true;
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_kTractionSearchPrefsKey);
if (raw == null || raw.trim().isEmpty) return;
final decoded = jsonDecode(raw);
if (decoded is! Map) return;
final classText = decoded['classText']?.toString();
final numberText = decoded['number']?.toString();
final nameText = decoded['name']?.toString();
final selectedClass = decoded['selectedClass']?.toString();
final mileageFirst = decoded['mileageFirst'];
final showAdvanced = decoded['showAdvancedFilters'];
if (classText != null) _classController.text = classText;
if (numberText != null) _numberController.text = numberText;
if (nameText != null) _nameController.text = nameText;
final dynamicValues = <String, String>{};
final enumValues = <String, String?>{};
final dynamicRaw = decoded['dynamic'];
if (dynamicRaw is Map) {
for (final entry in dynamicRaw.entries) {
final key = entry.key.toString();
final val = entry.value?.toString() ?? '';
dynamicValues[key] = val;
}
}
final enumRaw = decoded['enum'];
if (enumRaw is Map) {
for (final entry in enumRaw.entries) {
enumValues[entry.key.toString()] = entry.value?.toString();
}
}
final lastOffsetRaw = decoded['lastOffset'];
if (lastOffsetRaw is int) {
_lastTractionOffset = lastOffsetRaw;
} else if (lastOffsetRaw is num) {
_lastTractionOffset = lastOffsetRaw.toInt();
}
final lastSig = decoded['querySignature']?.toString();
if (lastSig != null && lastSig.isNotEmpty) {
_lastQuerySignature = lastSig;
}
for (final entry in dynamicValues.entries) {
_dynamicControllers.putIfAbsent(
entry.key,
() => TextEditingController(text: entry.value),
);
_dynamicControllers[entry.key]?.text = entry.value;
}
for (final entry in enumValues.entries) {
_enumSelections[entry.key] = entry.value;
}
if (!mounted) return;
_setState(() {
_selectedClass =
(selectedClass != null && selectedClass.trim().isNotEmpty)
? selectedClass
: null;
if (mileageFirst is bool) _mileageFirst = mileageFirst;
if (showAdvanced is bool) _showAdvancedFilters = showAdvanced;
});
} catch (_) {
// Ignore preference restore failures.
}
}
Future<void> _persistSearchState() async {
if (widget.selectionMode) return;
final payload = <String, dynamic>{
'classText': _classController.text,
'number': _numberController.text,
'name': _nameController.text,
'selectedClass': _selectedClass,
'mileageFirst': _mileageFirst,
'showAdvancedFilters': _showAdvancedFilters,
'dynamic': _dynamicControllers.map((k, v) => MapEntry(k, v.text)),
'enum': _enumSelections,
'lastOffset': _lastTractionOffset,
'querySignature': _lastQuerySignature,
};
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kTractionSearchPrefsKey, jsonEncode(payload));
} catch (_) {
// Ignore persistence failures.
}
}
}

View File

@@ -1,6 +1,7 @@
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:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class TripsPage extends StatefulWidget { class TripsPage extends StatefulWidget {
@@ -12,6 +13,7 @@ class TripsPage extends StatefulWidget {
class _TripsPageState extends State<TripsPage> { class _TripsPageState extends State<TripsPage> {
bool _initialised = false; bool _initialised = false;
final Map<int, Future<List<TripLocoStat>>> _tripLocoStatsFutures = {};
@override @override
void didChangeDependencies() { void didChangeDependencies() {
@@ -23,22 +25,104 @@ class _TripsPageState extends State<TripsPage> {
} }
Future<void> _refreshTrips() async { Future<void> _refreshTrips() async {
await context.read<DataService>().fetchTripDetails(); _tripLocoStatsFutures.clear();
final data = context.read<DataService>();
await data.fetchTripDetails();
}
Future<void> _renameTrip(TripDetail trip, String newName) async {
final data = context.read<DataService>();
final api = data.api;
final messenger = ScaffoldMessenger.maybeOf(context);
try {
await api.post('/trips/rename', {
"trip_id": trip.id,
"trip_name": newName,
});
await data.fetchTripDetails();
} catch (e) {
messenger?.showSnackBar(
SnackBar(content: Text('Failed to rename trip: $e')),
);
rethrow;
}
}
List<TripLocoStat> _cachedTripStats(
TripDetail trip,
TripSummary? summary,
) {
if (trip.locoStats.isNotEmpty) return trip.locoStats;
if (summary?.locoStats.isNotEmpty == true) return summary!.locoStats;
return const [];
}
Future<List<TripLocoStat>> _loadTripStats(
TripDetail trip,
TripSummary? summary,
) {
final cached = _cachedTripStats(trip, summary);
if (cached.isNotEmpty) return Future.value(cached);
return _tripLocoStatsFutures.putIfAbsent(
trip.id,
() => context.read<DataService>().fetchTripLocoStats(trip.id),
);
}
Future<String?> _promptTripName(BuildContext context, String initial) async {
final controller = TextEditingController(text: initial);
final newName = await showDialog<String>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: const Text('Rename trip'),
content: TextField(
controller: controller,
decoration: const InputDecoration(labelText: 'Trip name'),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () =>
Navigator.of(dialogCtx).pop(controller.text.trim()),
child: const Text('Save'),
),
],
),
);
controller.dispose();
return newName;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final tripDetails = data.tripDetails; final tripDetails = data.tripDetails;
final tripSummaries = data.trips; final tripSummaries = data.tripList;
final isMobile = MediaQuery.of(context).size.width < 700; final summaryById = {
for (final summary in tripSummaries) summary.tripId: summary,
};
final showLoading = data.isTripDetailsLoading && tripDetails.isEmpty; final showLoading = data.isTripDetailsLoading && tripDetails.isEmpty;
return RefreshIndicator( return RefreshIndicator(
onRefresh: _refreshTrips, onRefresh: _refreshTrips,
child: ListView( child: ListView.builder(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
itemCount: () {
if (showLoading) return 2;
if (tripDetails.isEmpty && tripSummaries.isEmpty) return 2;
if (tripDetails.isEmpty) return 1 + tripSummaries.length;
return 1 + tripDetails.length;
}(),
itemBuilder: (context, index) {
if (index == 0) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -57,8 +141,6 @@ class _TripsPageState extends State<TripsPage> {
), ),
], ],
), ),
Row(
children: [
IconButton( IconButton(
onPressed: _refreshTrips, onPressed: _refreshTrips,
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
@@ -66,18 +148,22 @@ class _TripsPageState extends State<TripsPage> {
), ),
], ],
), ),
],
),
const SizedBox(height: 12), const SizedBox(height: 12),
if (showLoading) ],
const Center( );
}
if (showLoading) {
return const Center(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(vertical: 24.0), padding: EdgeInsets.symmetric(vertical: 24.0),
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
), ),
) );
else if (tripDetails.isEmpty && tripSummaries.isEmpty) }
Card(
if (tripDetails.isEmpty && tripSummaries.isEmpty) {
return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
@@ -96,106 +182,124 @@ class _TripsPageState extends State<TripsPage> {
], ],
), ),
), ),
) );
else if (tripDetails.isEmpty) }
Column(
children: tripSummaries if (tripDetails.isEmpty) {
.map( final trip = tripSummaries[index - 1];
(trip) => Card( return Card(
child: ListTile( child: ListTile(
title: Text(trip.tripName), title: Text(trip.tripName),
subtitle: Text( subtitle:
'${trip.tripMileage.toStringAsFixed(1)} mi', Text(distanceUnits.format(trip.tripMileage, decimals: 1)),
),
),
),
)
.toList(),
)
else
Column(
children: tripDetails
.map((trip) => _buildTripCard(context, trip, isMobile))
.toList(),
),
],
), ),
); );
} }
Widget _buildTripCard(BuildContext context, TripDetail trip, bool isMobile) { final trip = tripDetails[index - 1];
final summary = summaryById[trip.id];
return _buildTripCard(context, trip, summary);
},
),
);
}
Widget _buildTripCard(
BuildContext context,
TripDetail trip,
TripSummary? summary,
) {
final distanceUnits = context.watch<DistanceUnitService>();
final legs = trip.legs; final legs = trip.legs;
final legCount =
trip.legCount > 0 ? trip.legCount : summary?.legCount ?? legs.length;
final dateRange = _formatDateRange(legs);
final endpoints = _formatEndpoints(legs);
final stats = _cachedTripStats(trip, summary);
final winnerCount = stats.where((e) => e.won).length;
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Trip #${trip.id}',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 4),
Text( Text(
trip.name, trip.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text( Text(
'${trip.mileage.toStringAsFixed(1)} mi · ${trip.legCount} legs', distanceUnits.format(trip.mileage, decimals: 1),
style: Theme.of(context).textTheme.bodyMedium, style:
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
), ),
], ],
), ),
Row( ],
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
icon: const Icon(Icons.train),
tooltip: 'Traction',
onPressed: () => _showTripWinners(context, trip),
), ),
IconButton( const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildMetaChip(context, Icons.timeline, '$legCount legs'),
if (dateRange != null)
_buildMetaChip(context, Icons.calendar_month, dateRange),
if (endpoints != null)
_buildMetaChip(context, Icons.route, endpoints),
if (stats.isNotEmpty) ...[
_buildMetaChip(context, Icons.train, '${stats.length} had'),
_buildMetaChip(
context,
Icons.emoji_events_outlined,
'$winnerCount winners',
),
] else
_buildMetaChip(context, Icons.train, 'No traction yet'),
],
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
children: [
OutlinedButton.icon(
icon: const Icon(Icons.train),
label: const Text('Locos'),
onPressed: () => _showTripWinners(context, trip, summary),
),
FilledButton.icon(
icon: const Icon(Icons.open_in_new), icon: const Icon(Icons.open_in_new),
tooltip: 'Details', label: const Text('Details'),
onPressed: () => _showTripDetail(context, trip), 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,
),
), ),
], ],
), ),
@@ -203,16 +307,141 @@ class _TripsPageState extends State<TripsPage> {
); );
} }
Widget _buildMetaChip(BuildContext context, IconData icon, String label) {
return Chip(
avatar: Icon(icon, size: 16),
label: Text(label),
visualDensity: const VisualDensity(horizontal: -2, vertical: -2),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}
String? _formatDateRange(List<TripLeg> legs) {
final beginTimes =
legs.map((e) => e.beginTime).whereType<DateTime>().toList();
if (beginTimes.isEmpty) return null;
final start = beginTimes.first;
final end = beginTimes.last;
final startStr = _formatFriendlyDate(start);
final endStr = _formatFriendlyDate(end);
if (startStr == endStr) return startStr;
return '$startStr - $endStr';
}
String _formatFriendlyDate(DateTime date) {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
final day = date.day.toString().padLeft(2, '0');
final monthIndex = (date.month - 1).clamp(0, months.length - 1).toInt();
final month = months[monthIndex];
return '$day $month ${date.year}';
}
String? _formatEndpoints(List<TripLeg> legs) {
if (legs.isEmpty) return null;
final start = legs.first.start;
final end = legs.last.end;
if (start.isEmpty && end.isEmpty) return null;
final startLabel = start.isNotEmpty ? start : '';
final endLabel = end.isNotEmpty ? end : '';
return '$startLabel$endLabel';
}
String _formatDate(DateTime? date) { String _formatDate(DateTime? date) {
if (date == null) return ''; if (date == null) return '';
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; 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) { void _showTripDetail(BuildContext context, TripDetail trip) {
final distanceUnits = context.read<DistanceUnitService>();
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (_) { builder: (_) {
bool renaming = false;
bool deleting = false;
String tripName = trip.name;
return StatefulBuilder(
builder: (sheetCtx, setSheetState) {
Future<void> handleRename() async {
final newName =
await _promptTripName(sheetCtx, tripName) ?? tripName;
if (newName.isEmpty || newName == tripName) return;
setSheetState(() => renaming = true);
try {
await _renameTrip(trip, newName);
tripName = newName;
setSheetState(() {});
} finally {
if (mounted) setSheetState(() => renaming = false);
}
}
Future<void> handleDelete() async {
if (deleting || trip.legs.isNotEmpty) return;
final data = context.read<DataService>();
final api = data.api;
final messenger = ScaffoldMessenger.maybeOf(sheetCtx);
final navigator = Navigator.of(sheetCtx);
final ok = await showDialog<bool>(
context: sheetCtx,
builder: (ctx) {
return AlertDialog(
title: const Text('Delete trip?'),
content: Text(
'This will delete "${trip.name}". This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Delete'),
),
],
);
},
);
if (ok != true || !mounted) return;
setSheetState(() => deleting = true);
try {
await api.delete('/trips/delete/${trip.id}');
await Future.wait([
data.fetchTripDetails(),
data.fetchTrips(),
]);
_tripLocoStatsFutures.remove(trip.id);
if (!mounted) return;
messenger?.showSnackBar(
SnackBar(content: Text('Deleted "${trip.name}"')),
);
navigator.pop();
} catch (e) {
if (!mounted) return;
messenger?.showSnackBar(
SnackBar(content: Text('Failed to delete trip: $e')),
);
} finally {
if (mounted) setSheetState(() => deleting = false);
}
}
return SafeArea( return SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@@ -224,16 +453,46 @@ class _TripsPageState extends State<TripsPage> {
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(sheetCtx).pop(),
), ),
Expanded(
child: Text(
tripName,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
),
IconButton(
icon: renaming
? const SizedBox(
width: 18,
height: 18,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.edit),
tooltip: 'Rename trip',
onPressed: renaming ? null : handleRename,
),
if (trip.legs.isEmpty) ...[
const SizedBox(width: 4),
IconButton(
icon: deleting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.delete_outline),
tooltip: 'Delete trip',
onPressed: deleting ? null : handleDelete,
color: Theme.of(context).colorScheme.error,
),
],
const SizedBox(width: 4),
Text( Text(
trip.name, distanceUnits.format(trip.mileage, decimals: 1),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
), ),
),
const Spacer(),
Text('${trip.mileage.toStringAsFixed(1)} mi'),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -248,7 +507,12 @@ class _TripsPageState extends State<TripsPage> {
title: Text('${leg.start}${leg.end}'), title: Text('${leg.start}${leg.end}'),
subtitle: Text(_formatDate(leg.beginTime)), subtitle: Text(_formatDate(leg.beginTime)),
trailing: Text( trailing: Text(
leg.mileage?.toStringAsFixed(1) ?? '-', leg.mileage == null
? '-'
: distanceUnits.format(
leg.mileage!,
decimals: 1,
),
style: Theme.of(context).textTheme.labelLarge style: Theme.of(context).textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold), ?.copyWith(fontWeight: FontWeight.bold),
), ),
@@ -262,17 +526,24 @@ class _TripsPageState extends State<TripsPage> {
); );
}, },
); );
},
);
} }
void _showTripWinners(BuildContext context, TripDetail trip) { void _showTripWinners(
BuildContext context,
TripDetail trip,
TripSummary? summary,
) {
final distanceUnits = context.read<DistanceUnitService>();
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (_) { builder: (_) {
final data = context.read<DataService>();
return SafeArea( return SafeArea(
child: FutureBuilder<List<TripLocoStat>>( child: FutureBuilder<List<TripLocoStat>>(
future: data.fetchTripLocoStats(trip.id), future: _loadTripStats(trip, summary),
initialData: _cachedTripStats(trip, summary),
builder: (ctx, snapshot) { builder: (ctx, snapshot) {
final items = snapshot.data ?? []; final items = snapshot.data ?? [];
final loading = final loading =
@@ -297,7 +568,9 @@ class _TripsPageState extends State<TripsPage> {
?.copyWith(fontWeight: FontWeight.bold), ?.copyWith(fontWeight: FontWeight.bold),
), ),
const Spacer(), const Spacer(),
Text('${trip.mileage.toStringAsFixed(1)} mi'), Text(
distanceUnits.format(trip.mileage, decimals: 1),
),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
class AnimatedCountText extends StatefulWidget {
const AnimatedCountText({
super.key,
required this.value,
required this.formatter,
this.style,
this.duration = const Duration(milliseconds: 900),
this.curve = Curves.easeOutCubic,
this.animateFromZero = true,
});
final double value;
final String Function(double) formatter;
final TextStyle? style;
final Duration duration;
final Curve curve;
final bool animateFromZero;
@override
State<AnimatedCountText> createState() => _AnimatedCountTextState();
}
class _AnimatedCountTextState extends State<AnimatedCountText>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
late CurvedAnimation _curve;
double _currentValue = 0;
@override
void initState() {
super.initState();
_currentValue = widget.animateFromZero ? 0 : widget.value;
_controller = AnimationController(vsync: this, duration: widget.duration);
_curve = CurvedAnimation(parent: _controller, curve: widget.curve);
_controller.addListener(_handleTick);
_configureAnimation(from: _currentValue, to: widget.value);
if (_currentValue != widget.value) {
_controller.forward();
}
}
@override
void didUpdateWidget(covariant AnimatedCountText oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.curve != widget.curve) {
_curve = CurvedAnimation(parent: _controller, curve: widget.curve);
_configureAnimation(from: _currentValue, to: widget.value);
}
if (oldWidget.value != widget.value) {
_controller.duration = widget.duration;
_configureAnimation(from: _currentValue, to: widget.value);
_controller.forward(from: 0);
}
}
void _configureAnimation({required double from, required double to}) {
_animation = Tween<double>(begin: from, end: to).animate(_curve);
}
void _handleTick() {
setState(() => _currentValue = _animation.value);
}
@override
void dispose() {
_controller.removeListener(_handleTick);
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text(
widget.formatter(_currentValue),
style: widget.style,
);
}
}

View File

@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
class FriendRequestNotificationCard extends StatelessWidget {
const FriendRequestNotificationCard({super.key, required this.notification});
final UserNotification notification;
@override
Widget build(BuildContext context) {
final data = context.read<DataService>();
final friendshipId = notification.body.trim();
if (friendshipId.isEmpty) {
return const Text('Invalid friend request notification.');
}
final future = data.fetchFriendshipById(friendshipId);
return FutureBuilder<Friendship?>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 6.0),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
if (snapshot.hasError) {
return const Text('Failed to load request details.');
}
final friendship = snapshot.data;
final requester = friendship?.requester;
if (friendship == null || requester == null) {
return const Text('Friend request details unavailable.');
}
final buttonStyle = ElevatedButton.styleFrom(
minimumSize: const Size(0, 36),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.person_add_alt, size: 18),
const SizedBox(width: 6),
Text(
requester.displayName.isNotEmpty
? requester.displayName
: '@${requester.username}',
style: Theme.of(context).textTheme.titleSmall,
),
],
),
const SizedBox(height: 6),
Wrap(
spacing: 8,
children: [
ElevatedButton(
style: buttonStyle,
onPressed: () =>
_respond(context, friendship.id, accept: true),
child: const Text('Accept'),
),
ElevatedButton(
style: buttonStyle,
onPressed: () =>
_respond(context, friendship.id, accept: false),
child: const Text('Reject'),
),
],
),
],
);
},
);
}
Future<void> _respond(
BuildContext context,
String? friendshipId, {
required bool accept,
}) async {
if (friendshipId == null || friendshipId.isEmpty) return;
final data = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context);
try {
if (accept) {
await data.acceptFriendship(friendshipId);
if (!context.mounted) return;
messenger?.showSnackBar(
const SnackBar(content: Text('Friend request accepted')),
);
await _dismissNotification(context, messenger);
} else {
await data.rejectFriendship(friendshipId);
if (!context.mounted) return;
messenger?.showSnackBar(
const SnackBar(content: Text('Friend request rejected')),
);
await _dismissNotification(context, messenger);
}
await data.fetchPendingFriendships();
} catch (e) {
if (!context.mounted) return;
messenger?.showSnackBar(
SnackBar(content: Text('Failed to respond: $e')),
);
}
}
Future<void> _dismissNotification(
BuildContext context,
ScaffoldMessengerState? messenger,
) async {
try {
await context.read<DataService>().dismissNotifications([notification.id]);
} catch (e) {
messenger?.showSnackBar(
SnackBar(content: Text('Failed to dismiss notification: $e')),
);
}
}
}

View File

@@ -0,0 +1,641 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
class LegShareEditNotificationCard extends StatefulWidget {
const LegShareEditNotificationCard({super.key, required this.notification});
final UserNotification notification;
@override
State<LegShareEditNotificationCard> createState() => _LegShareEditNotificationCardState();
}
class _LegShareEditNotificationCardState extends State<LegShareEditNotificationCard> {
Map<String, dynamic>? _changes;
int? _legId;
int? _shareId;
Leg? _currentLeg;
LegShareData? _share;
Future<LegShareData?>? _shareFuture;
bool _loading = false;
static const int _summaryLimit = 3;
@override
void initState() {
super.initState();
_parseNotification();
}
@override
void didUpdateWidget(covariant LegShareEditNotificationCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.notification != widget.notification) {
_parseNotification(notify: true);
}
}
void _parseNotification({bool notify = false}) {
final rawBody = widget.notification.body.trim();
// Reset
_shareId = null;
_legId = null;
_currentLeg = null;
_changes = null;
_share = null;
_shareFuture = null;
final parsed = _decodeBody(rawBody);
if (parsed != null) {
_shareId = _parseInt(parsed['share_id']);
_legId = _parseInt(parsed['leg_id']);
_currentLeg = _findCurrentLeg(_legId);
final accepted = _asStringKeyedMap(parsed['accepted_changes']);
if (accepted != null) {
_changes = accepted;
}
}
// Fallback: extract share_id from raw string if still missing.
_shareId ??= _extractShareId(rawBody);
_prepareShareFuture();
if (notify) {
setState(() {});
}
}
int? _parseInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value.trim());
return null;
}
Map<String, dynamic>? _decodeBody(String rawBody) {
final attempts = <String>[
rawBody,
_stripWrappingQuotes(rawBody),
_replaceSingleQuotes(rawBody),
].where((s) => s.trim().isNotEmpty).toSet();
for (final attempt in attempts) {
final parsed = _decodeJsonToMap(attempt);
if (parsed != null) return parsed;
}
return null;
}
Map<String, dynamic>? _decodeJsonToMap(String source) {
dynamic parsed = source;
for (int i = 0; i < 3 && parsed is String; i++) {
try {
parsed = jsonDecode(parsed);
} catch (e) {
parsed = null;
break;
}
}
if (parsed is Map) {
final map = parsed.map((k, v) => MapEntry(k.toString(), v));
return map;
}
return null;
}
String _stripWrappingQuotes(String input) {
final trimmed = input.trim();
if (trimmed.length >= 2 &&
((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'")))) {
return trimmed.substring(1, trimmed.length - 1);
}
return input;
}
String _replaceSingleQuotes(String input) {
if (!input.contains("'")) return input;
return input.replaceAll(RegExp(r"(?<!\\)'"), '"');
}
Map<String, dynamic>? _asStringKeyedMap(dynamic value) {
if (value is Map) {
return value.map((k, v) => MapEntry(k.toString(), v));
}
if (value is String && value.trim().isNotEmpty) {
for (final attempt in [value, _replaceSingleQuotes(value)]) {
try {
final decoded = jsonDecode(attempt);
if (decoded is Map) {
return decoded.map((k, v) => MapEntry(k.toString(), v));
}
} catch (_) {
// Ignore; handled by caller.
}
}
}
return null;
}
int? _extractShareId(String raw) {
final patterns = [
RegExp(r'"share_id"\s*:\s*(\d+)'),
RegExp(r"'share_id'\s*:\s*(\d+)"),
RegExp(r'share_id\s*:\s*(\d+)'),
RegExp(r'"share_id"\s*:\s*"(\d+)"'),
];
for (final pattern in patterns) {
final match = pattern.firstMatch(raw);
if (match != null) {
final parsed = int.tryParse(match.group(1)!);
return parsed;
}
}
return null;
}
Future<void> _loadLegIdIfNeeded() async {
if (_legId != null) return;
if (_shareId == null) {
return;
}
try {
final share = await context.read<DataService>().fetchLegShare(_shareId!.toString());
if (!mounted) return;
_legId = share?.entry.id;
_currentLeg ??= _findCurrentLeg(_legId);
} catch (e) {
// ignore: avoid_empty_catches
}
}
void _prepareShareFuture() {
if (_shareId == null) return;
_shareFuture = context.read<DataService>().fetchLegShare(_shareId!.toString());
}
Future<void> _loadShareIfNeeded() async {
if (_share != null) return;
if (_shareId == null) return;
try {
final future = _shareFuture ??
context.read<DataService>().fetchLegShare(_shareId!.toString());
final share = await future;
if (!mounted) return;
_share = share;
} catch (e) {
// ignore: avoid_empty_catches
}
}
Leg? _findCurrentLeg(int? legId) {
if (legId == null) return null;
final data = context.read<DataService>();
try {
return data.legs.firstWhere((l) => l.id == legId);
} catch (_) {
return null;
}
}
String _formatDateTime(DateTime dateTime) {
return '${dateTime.year.toString().padLeft(4, '0')}-'
'${dateTime.month.toString().padLeft(2, '0')}-'
'${dateTime.day.toString().padLeft(2, '0')} '
'${dateTime.hour.toString().padLeft(2, '0')}:'
'${dateTime.minute.toString().padLeft(2, '0')}';
}
Widget _buildLegSummary(BuildContext context) {
final future = _shareFuture;
if (future == null) {
final leg = _currentLeg;
return leg == null ? const SizedBox.shrink() : _legSummaryRow(context, leg);
}
return FutureBuilder<LegShareData?>(
future: future,
builder: (context, snapshot) {
final share = snapshot.data;
if (share != null) {
_share = share;
}
final leg = share?.entry ?? _currentLeg;
if (leg == null) {
return const SizedBox.shrink();
}
return _legSummaryRow(context, leg);
},
);
}
Widget _legSummaryRow(BuildContext context, Leg leg) {
final start = leg.route.isNotEmpty ? leg.route.first : leg.start;
final end = leg.route.isNotEmpty ? leg.route.last : leg.end;
return Text('${_formatDateTime(leg.beginTime)}$start$end');
}
String _asString(dynamic value) {
if (value == null) return '';
return value.toString();
}
Loco? _resolveLocoById(int locoId, {Leg? shareLeg}) {
for (final loco in shareLeg?.locos ?? const <Loco>[]) {
if (loco.id == locoId) return loco;
}
for (final loco in _currentLeg?.locos ?? const <Loco>[]) {
if (loco.id == locoId) return loco;
}
for (final loco in context.read<DataService>().traction) {
if (loco.id == locoId) return loco;
}
return null;
}
String _locoDisplayName(Map<String, dynamic> loco, {Leg? shareLeg}) {
final locoId = _parseInt(loco['loco_id']);
var locoClass = _asString(loco['class'] ?? loco['loco_class']);
var number = _asString(loco['number'] ?? loco['loco_number']);
if ((locoClass.isEmpty || number.isEmpty) && locoId != null) {
final resolved = _resolveLocoById(locoId, shareLeg: shareLeg);
if (resolved != null) {
if (locoClass.isEmpty) locoClass = resolved.locoClass;
if (number.isEmpty) number = resolved.number;
}
}
final parts = <String>[];
if (locoClass.isNotEmpty) parts.add(locoClass);
if (number.isNotEmpty) parts.add(number);
if (parts.isNotEmpty) return parts.join(' ');
return 'Loco ${locoId ?? '?'}';
}
@override
Widget build(BuildContext context) {
final changes = _changes;
if (changes == null || changes.isEmpty) {
return const Text('No changes supplied.');
}
final entries = changes.entries.toList();
final shown = entries.take(_summaryLimit).toList();
final remaining = entries.length - shown.length;
final legSummary = _buildLegSummary(context);
final hasSummary = _shareFuture != null || _currentLeg != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hasSummary) legSummary,
if (hasSummary) const SizedBox(height: 8),
...shown.map((e) => _changePreview(context, e)),
if (remaining > 0)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text('+$remaining others…', style: Theme.of(context).textTheme.bodySmall),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ElevatedButton(
onPressed: _loading ? null : () => _openDrawer(changes),
child: const Text('View changes'),
),
TextButton(
onPressed: _loading ? null : _dismiss,
child: const Text('Dismiss changes'),
),
],
),
],
);
}
Widget _changePreview(BuildContext context, MapEntry<String, dynamic> change) {
final key = _prettyField(change.key);
final value = change.value;
String display;
if (change.key == 'locos' && value is List) {
display = '${value.length} traction update${value.length == 1 ? '' : 's'}';
} else {
display = _stringify(value);
}
return Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text('$key: $display'),
);
}
String _prettyField(String raw) {
switch (raw) {
case 'leg_notes':
return 'Notes';
case 'locos':
return 'Traction';
default:
return raw.replaceAll('_', ' ');
}
}
dynamic _currentValueForField(Leg leg, String key) {
switch (key) {
case 'leg_begin_time':
return leg.beginTime;
case 'leg_end_time':
return leg.endTime;
case 'leg_origin_time':
return leg.originTime;
case 'leg_destination_time':
return leg.destinationTime;
case 'leg_notes':
return leg.notes;
case 'leg_headcode':
return leg.headcode;
case 'leg_network':
return leg.network;
case 'leg_start':
return leg.start;
case 'leg_end':
return leg.end;
case 'leg_origin':
return leg.origin;
case 'leg_destination':
return leg.destination;
case 'leg_route':
return leg.route;
case 'leg_mileage':
return leg.mileage;
case 'leg_begin_delay':
return leg.beginDelayMinutes;
case 'leg_end_delay':
return leg.endDelayMinutes;
case 'locos':
return leg.locos;
default:
return null;
}
}
Widget _buildChangeValueWidget(
String key,
dynamic newValue,
Leg? currentLeg,
Widget Function(List<dynamic>) buildLocos,
) {
final currentValue = currentLeg == null ? null : _currentValueForField(currentLeg, key);
if (key == 'locos' && newValue is List) {
final currentCount = (currentValue is List) ? currentValue.length : 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Current: $currentCount locos'),
const SizedBox(height: 4),
const Text('New:'),
buildLocos(newValue),
],
);
}
final currentStr = _stringify(currentValue);
final newStr = _stringify(newValue);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: Text(currentStr, maxLines: 3, overflow: TextOverflow.ellipsis)),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6.0),
child: Icon(Icons.arrow_right_alt, size: 18),
),
Expanded(child: Text(newStr, maxLines: 3, overflow: TextOverflow.ellipsis)),
],
);
}
String _stringify(dynamic value) {
if (value is DateTime) return value.toIso8601String();
if (value == null) return '';
if (value is List || value is Map) {
return jsonEncode(value);
}
return value.toString();
}
Future<void> _openDrawer(Map<String, dynamic> changes) async {
setState(() => _loading = true);
await _loadLegIdIfNeeded();
await _loadShareIfNeeded();
_currentLeg ??= _findCurrentLeg(_legId);
if (!mounted) return;
setState(() => _loading = false);
final legId = _legId;
if (legId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Unable to load shared leg.')),
);
return;
}
final selected = Map<String, bool>.fromEntries(
changes.keys.map((k) => MapEntry(k, false)),
);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (ctx) {
return StatefulBuilder(
builder: (context, setSheetState) {
Future<void> apply() async {
final payload = <String, dynamic>{};
for (final entry in changes.entries) {
if (selected[entry.key] == true) {
payload[entry.key] = entry.value;
}
}
if (payload.isEmpty) {
Navigator.of(context).pop();
return;
}
final messenger = ScaffoldMessenger.of(context);
setSheetState(() => _loading = true);
try {
final data = context.read<DataService>();
await data.applyLegPartialUpdates(
legId: legId,
updates: payload,
);
if (!context.mounted) return;
await data.dismissNotifications([widget.notification.id]);
if (!context.mounted) return;
messenger.showSnackBar(
const SnackBar(content: Text('Changes applied.')),
);
Navigator.of(context).pop();
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to apply changes: $e')),
);
} finally {
setSheetState(() => _loading = false);
}
}
Widget buildLocos(List<dynamic> locos) {
final parsed = locos
.whereType<Map>()
.map((e) => e.map((k, v) => MapEntry(k.toString(), v)))
.toList();
parsed.sort((a, b) => (b['alloc_pos'] ?? 0).compareTo(a['alloc_pos'] ?? 0));
final leading = parsed.where((e) => (e['alloc_pos'] ?? 0) > 0).toList();
final trailing = parsed.where((e) => (e['alloc_pos'] ?? 0) <= 0).toList();
List<Widget> chipsFor(List<Map<String, dynamic>> list) {
return list
.map(
(loco) => Chip(
backgroundColor:
(loco['alloc_powering'] == 1 || loco['alloc_powering'] == true)
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12)
: Theme.of(context).colorScheme.surfaceContainerHighest,
label: Text(
'${_locoDisplayName(loco, shareLeg: _share?.entry)} (pos ${loco['alloc_pos'] ?? '?'})',
),
avatar: Icon(
Icons.train,
size: 16,
color: (loco['alloc_powering'] == 1 || loco['alloc_powering'] == true)
? Theme.of(context).colorScheme.primary
: Theme.of(context).hintColor,
),
),
)
.toList();
}
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
...chipsFor(leading),
if (leading.isNotEmpty && trailing.isNotEmpty)
const SizedBox(
width: 24,
child: Center(child: Divider(height: 16)),
),
...chipsFor(trailing),
],
);
}
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
top: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Review changes', style: Theme.of(context).textTheme.titleMedium),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 12),
Row(
children: [
TextButton(
onPressed: () => setSheetState(() {
for (final key in selected.keys) {
selected[key] = true;
}
}),
child: const Text('Select all'),
),
const Spacer(),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
],
),
const SizedBox(height: 8),
...changes.entries.map((entry) {
final key = entry.key;
final prettyKey = _prettyField(key);
final value = entry.value;
final currentLeg = _currentLeg ?? _findCurrentLeg(_legId);
final valueWidget = _buildChangeValueWidget(
key,
value,
currentLeg,
buildLocos,
);
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Checkbox(
value: selected[key] ?? false,
onChanged: (v) => setSheetState(() {
selected[key] = v ?? false;
}),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(prettyKey, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
valueWidget,
],
),
),
],
),
);
}),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: _loading ? null : apply,
child: _loading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Apply changes'),
),
),
],
),
),
);
},
);
},
);
}
Future<void> _dismiss() async {
await context.read<DataService>().dismissNotifications([widget.notification.id]);
}
}

View File

@@ -0,0 +1,193 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
class LegShareNotificationCard extends StatefulWidget {
const LegShareNotificationCard({super.key, required this.notification});
final UserNotification notification;
@override
State<LegShareNotificationCard> createState() => _LegShareNotificationCardState();
}
class _LegShareNotificationCardState extends State<LegShareNotificationCard> {
bool _accepting = false;
bool _rejecting = false;
@override
Widget build(BuildContext context) {
final data = context.read<DataService>();
final legShareId = _extractLegShareId(widget.notification.body);
if (legShareId == null) {
return const Text('Invalid leg share notification.');
}
final future = data.fetchLegShare(legShareId);
return FutureBuilder<LegShareData?>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 6.0),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
if (snapshot.hasError) {
return const Text('Failed to load shared entry.');
}
final share = snapshot.data;
final entry = share?.entry;
if (share == null || entry == null) {
return const Text('Shared entry unavailable.');
}
final begin = entry.beginTime;
final beginStr =
'${begin.year.toString().padLeft(4, '0')}-${begin.month.toString().padLeft(2, '0')}-${begin.day.toString().padLeft(2, '0')} ${begin.hour.toString().padLeft(2, '0')}:${begin.minute.toString().padLeft(2, '0')}';
final start = entry.route.isNotEmpty ? entry.route.first : entry.start;
final end = entry.route.isNotEmpty ? entry.route.last : entry.end;
final sharedAt = share.sharedAt;
final sharedAtStr = sharedAt == null
? null
: '${sharedAt.year.toString().padLeft(4, '0')}-${sharedAt.month.toString().padLeft(2, '0')}-${sharedAt.day.toString().padLeft(2, '0')} ${sharedAt.hour.toString().padLeft(2, '0')}:${sharedAt.minute.toString().padLeft(2, '0')}';
final from = share.sharedFromName;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$beginStr$start$end'),
if (from.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'From $from',
style: Theme.of(context).textTheme.bodySmall,
),
),
if (sharedAtStr != null)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'Shared at $sharedAtStr',
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ElevatedButton(
onPressed: _accepting ? null : () => _accept(context, share),
child: _accepting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Accept'),
),
OutlinedButton(
onPressed: () => _inspect(context, share),
child: const Text('Inspect'),
),
TextButton(
onPressed: _rejecting ? null : () => _reject(context, share),
child: _rejecting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Reject'),
),
],
),
],
);
},
);
}
Future<void> _accept(BuildContext context, LegShareData share) async {
setState(() => _accepting = true);
final data = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context);
try {
await data.acceptLegShare(share);
if (!context.mounted) return;
await data.dismissNotifications([widget.notification.id]);
// Refresh legs in the background.
unawaited(data.refreshLegs());
messenger?.showSnackBar(
const SnackBar(content: Text('Shared entry added to logbook')),
);
} catch (e) {
messenger?.showSnackBar(
SnackBar(content: Text('Failed to add shared entry: $e')),
);
} finally {
if (mounted) {
setState(() => _accepting = false);
}
}
}
Future<void> _reject(BuildContext context, LegShareData share) async {
setState(() => _rejecting = true);
final data = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context);
try {
await data.rejectLegShare(share.id);
if (!context.mounted) return;
await data.dismissNotifications([widget.notification.id]);
messenger?.showSnackBar(
const SnackBar(content: Text('Share rejected')),
);
} catch (e) {
messenger?.showSnackBar(
SnackBar(content: Text('Failed to reject share: $e')),
);
} finally {
if (mounted) {
setState(() => _rejecting = false);
}
}
}
Future<void> _inspect(BuildContext context, LegShareData share) async {
final router = GoRouter.of(context);
// Close notifications panel if open.
if (Navigator.canPop(context)) {
Navigator.of(context).pop();
}
await Future<void>.delayed(Duration.zero);
final target = share.copyWith(notificationId: widget.notification.id);
final ts = DateTime.now().millisecondsSinceEpoch;
final path = '/add?share=${Uri.encodeComponent(share.id)}&ts=$ts';
router.go(path, extra: target);
}
String? _extractLegShareId(String rawBody) {
final trimmed = rawBody.trim();
if (trimmed.isEmpty) return null;
if (RegExp(r'^[0-9]+$').hasMatch(trimmed)) return trimmed;
try {
final decoded = jsonDecode(trimmed);
if (decoded is Map) {
final id = decoded['share_id'] ?? decoded['leg_share_id'];
final str = id?.toString() ?? '';
if (RegExp(r'^[0-9]+$').hasMatch(str)) return str;
}
} catch (_) {}
return null;
}
}

View File

@@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
class MultiSelectFilter extends StatefulWidget {
const MultiSelectFilter({
super.key,
required this.label,
required this.options,
required this.selected,
required this.onChanged,
this.onRefresh,
});
final String label;
final List<String> options;
final List<String> selected;
final ValueChanged<List<String>> onChanged;
final VoidCallback? onRefresh;
@override
State<MultiSelectFilter> createState() => _MultiSelectFilterState();
}
class _MultiSelectFilterState extends State<MultiSelectFilter> {
late List<String> _tempSelected;
String _query = '';
@override
void initState() {
super.initState();
_tempSelected = List.from(widget.selected);
}
@override
void didUpdateWidget(covariant MultiSelectFilter oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selected != widget.selected) {
_tempSelected = List.from(widget.selected);
}
}
void _openPicker() async {
_tempSelected = List.from(widget.selected);
_query = '';
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setModalState) {
final filtered = widget.options
.where((opt) =>
_query.isEmpty || opt.toLowerCase().contains(_query.toLowerCase()))
.toList();
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Select ${widget.label.toLowerCase()}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const Spacer(),
if (widget.onRefresh != null)
IconButton(
tooltip: 'Refresh',
onPressed: widget.onRefresh,
icon: const Icon(Icons.refresh),
),
TextButton(
onPressed: () {
setModalState(() {
_tempSelected.clear();
});
Navigator.of(ctx).pop();
widget.onChanged(const []);
},
child: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: 'Search',
border: OutlineInputBorder(),
),
onChanged: (val) {
setModalState(() {
_query = val;
});
},
),
const SizedBox(height: 12),
SizedBox(
height: 320,
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, index) {
final option = filtered[index];
final selected = _tempSelected.contains(option);
return CheckboxListTile(
value: selected,
title: Text(option),
onChanged: (val) {
setModalState(() {
if (val == true) {
if (!_tempSelected.contains(option)) {
_tempSelected.add(option);
}
} else {
_tempSelected.removeWhere((e) => e == option);
}
});
widget.onChanged(List.from(_tempSelected.toSet()));
},
);
},
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: () {
widget.onChanged(List.from(_tempSelected.toSet()));
Navigator.of(ctx).pop();
},
icon: const Icon(Icons.check),
label: const Text('Apply'),
),
),
],
),
),
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final hasSelection = widget.selected.isNotEmpty;
final display =
hasSelection ? widget.selected.join(', ') : 'Any ${widget.label.toLowerCase()}';
return OutlinedButton.icon(
onPressed: _openPicker,
icon: const Icon(Icons.filter_alt),
label: SizedBox(
width: 180,
child: Text(
'${widget.label}: $display',
overflow: TextOverflow.ellipsis,
),
),
);
}
}

View File

@@ -1,327 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:mileograph_flutter/app.dart';
import 'package:mileograph_flutter/components/pages/calculator.dart';
import 'package:mileograph_flutter/components/pages/loco_timeline.dart';
import 'package:mileograph_flutter/components/pages/new_entry.dart';
import 'package:mileograph_flutter/components/pages/new_traction.dart';
import 'package:mileograph_flutter/components/pages/traction.dart';
import 'package:mileograph_flutter/components/pages/trips.dart';
import 'package:provider/provider.dart';
import 'package:mileograph_flutter/components/pages/legs.dart';
import 'package:mileograph_flutter/services/apiService.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/dataService.dart';
import 'package:mileograph_flutter/services/navigation_guard.dart';
import 'components/login/login.dart';
import 'components/pages/dashboard.dart';
import 'package:go_router/go_router.dart';
late ApiService api;
void main() { void main() {
runApp( runApp(const App());
MultiProvider(
providers: [
Provider<ApiService>(
create: (_) {
api = ApiService(baseUrl: 'https://mileograph.co.uk/api/v1');
return api;
},
),
ChangeNotifierProxyProvider<ApiService, AuthService>(
create: (context) => AuthService(api: context.read<ApiService>()),
update: (_, api, previous) {
return previous ?? AuthService(api: api);
},
),
ProxyProvider<AuthService, void>(
update: (_, auth, __) {
api.setTokenProvider(() => auth.token);
api.setUnauthorizedHandler(() => auth.handleTokenExpired());
},
),
ChangeNotifierProxyProvider<ApiService, DataService>(
create: (context) => DataService(api: context.read<ApiService>()),
update: (_, api, previous) => previous ?? DataService(api: api),
),
],
child: MyApp(),
),
);
} }
class MyApp extends StatelessWidget {
MyApp({super.key});
final ColorScheme defaultLight = ColorScheme.fromSeed(seedColor: Colors.red);
final ColorScheme defaultDark = ColorScheme.fromSeed(
seedColor: Colors.red,
brightness: Brightness.dark,
);
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
final GoRouter router = GoRouter(
refreshListenable: context
.read<AuthService>(), // `AuthService` extends `ChangeNotifier`
redirect: (context, state) {
final auth = Provider.of<AuthService>(context, listen: false);
final loggedIn = auth.isLoggedIn;
final loggingIn = state.uri.toString() == '/login';
// Redirect to login if not logged in and trying to access protected pages
if (!loggedIn && !loggingIn) return '/login';
// Redirect to home if already logged in and trying to go to login
if (loggedIn && loggingIn) return '/';
// No redirection
return null;
},
routes: [
ShellRoute(
builder: (context, state, child) {
return MyHomePage(child: child);
},
routes: [
GoRoute(path: '/', builder: (_, __) => const Dashboard()),
GoRoute(path: '/calculator', builder: (_, __) => CalculatorPage()),
GoRoute(
path: '/calculator/details',
builder: (_, __) => CalculatorPage(),
),
GoRoute(path: '/legs', builder: (_, __) => LegsPage()),
GoRoute(path: '/traction', builder: (_, __) => TractionPage()),
GoRoute(
path: '/traction/:id/timeline',
builder: (_, state) {
final idParam = state.pathParameters['id'];
final locoId = int.tryParse(idParam ?? '') ?? 0;
final extra = state.extra;
String label = state.uri.queryParameters['label'] ?? '';
if (extra is Map && extra['label'] is String) {
label = extra['label'] as String;
} else if (extra is String && extra.isNotEmpty) {
label = extra;
}
if (label.trim().isEmpty) {
label = 'Loco $locoId';
}
return LocoTimelinePage(
locoId: locoId,
locoLabel: label,
);
},
),
GoRoute(
path: '/traction/new',
builder: (_, __) => const NewTractionPage(),
),
GoRoute(path: '/trips', builder: (_, __) => TripsPage()),
GoRoute(path: '/add', builder: (_, __) => NewEntryPage()),
GoRoute(
path: '/legs/edit/:id',
builder: (_, state) {
final idParam = state.pathParameters['id'];
final legId = idParam == null ? null : int.tryParse(idParam);
return NewEntryPage(editLegId: legId);
},
),
],
),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
],
);
return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
return MaterialApp.router(
title: 'Flutter Demo',
routerConfig: router,
theme: ThemeData(
useMaterial3: true,
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//fullPage
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: lightDynamic ?? defaultLight,
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: darkDynamic ?? defaultDark,
),
themeMode: ThemeMode.system,
);
},
);
}
}
class MyHomePage extends StatefulWidget {
final Widget child;
const MyHomePage({super.key, required this.child});
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final List<String> contentPages = [
"/",
"/calculator",
"/legs",
"/traction",
"/trips",
"/add",
];
int _getIndexFromLocation(String location) {
int newIndex = contentPages.indexWhere((path) {
if (location == path) return true;
if (path == '/') return location == '/';
return location.startsWith('$path/');
});
if (newIndex < 0) {
return 0;
}
return newIndex;
}
Future<void> _onItemTapped(int index, int currentIndex) async {
if (index < 0 || index >= contentPages.length || index == currentIndex) {
return;
}
await NavigationGuard.attemptNavigation(() async {
if (!mounted) return;
context.go(contentPages[index]);
});
}
bool loggedIn = false;
bool _fetched = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_fetched) {
_fetched = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
Future(() async {
final data = context.read<DataService>();
final auth = context.read<AuthService>();
api.setTokenProvider(() => auth.token);
await auth.tryRestoreSession();
if (!auth.isLoggedIn) return;
data.fetchEventFields();
if (data.homepageStats == null) {
data.fetchHomepageStats();
}
if (data.legs.isEmpty) {
data.fetchLegs();
}
if (data.traction.isEmpty) {
data.fetchHadTraction();
}
if (data.onThisDay.isEmpty) {
data.fetchOnThisDay();
}
if (data.tripDetails.isEmpty) {
data.fetchTripDetails();
}
});
});
}
}
@override
Widget build(BuildContext context) {
Widget currentPage;
final location = GoRouterState.of(context).uri.toString();
final pageIndex = _getIndexFromLocation(location);
final data = context.watch<DataService>();
final auth = context.read<AuthService>();
if (data.homepageStats != null || !data.isHomepageLoading) {
currentPage = widget.child;
} else {
currentPage = Center(child: CircularProgressIndicator());
}
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text.rich(
TextSpan(
children: [
TextSpan(text: "Mile"),
TextSpan(
text: "O",
style: TextStyle(color: Colors.red),
),
TextSpan(text: "graph"),
],
style: TextStyle(
decoration: TextDecoration.none,
color: Colors.white,
fontFamily: "Tomatoes",
),
),
),
actions: [
IconButton(onPressed: null, icon: Icon(Icons.account_circle)),
IconButton(onPressed: auth.logout, icon: Icon(Icons.logout)),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: pageIndex,
onDestinationSelected: (int index) {
_onItemTapped(index, pageIndex);
},
destinations: [
NavigationDestination(icon: Icon(Icons.home), label: "Home"),
NavigationDestination(icon: Icon(Icons.route), label: "Calculator"),
NavigationDestination(icon: Icon(Icons.list), label: "Entries"),
NavigationDestination(icon: Icon(Icons.train), label: "Traction"),
NavigationDestination(icon: Icon(Icons.book), label: "Trips"),
NavigationDestination(icon: Icon(Icons.add), label: "Add"),
],
),
body: currentPage,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AccentColorService extends ChangeNotifier {
static const _prefsKeyUseSystem = 'accent_use_system';
static const _prefsKeySeed = 'accent_seed';
static const Color defaultSeed = Colors.red;
bool _useSystem = true;
Color _seedColor = defaultSeed;
bool _hasSavedSeed = false;
bool _loaded = false;
bool get useSystem => _useSystem;
Color get seedColor => _seedColor;
bool get hasSavedSeed => _hasSavedSeed;
bool get isLoaded => _loaded;
AccentColorService() {
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
_useSystem = prefs.getBool(_prefsKeyUseSystem) ?? true;
final seedValue = prefs.getInt(_prefsKeySeed);
if (seedValue != null) {
_seedColor = Color(seedValue);
_hasSavedSeed = true;
}
_loaded = true;
notifyListeners();
}
Future<void> setUseSystem(bool value) async {
_useSystem = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyUseSystem, _useSystem);
notifyListeners();
}
Future<void> setSeedColor(Color color) async {
_seedColor = color;
_useSystem = false;
_hasSavedSeed = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_prefsKeySeed, color.toARGB32());
await prefs.setBool(_prefsKeyUseSystem, _useSystem);
notifyListeners();
}
}

View File

@@ -1,106 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
typedef TokenProvider = String? Function();
typedef UnauthorizedHandler = Future<void> Function();
class ApiService {
final String baseUrl;
TokenProvider? _getToken;
UnauthorizedHandler? _onUnauthorized;
ApiService({required this.baseUrl});
void setTokenProvider(TokenProvider provider) {
_getToken = provider;
}
void setUnauthorizedHandler(UnauthorizedHandler handler) {
_onUnauthorized = handler;
}
Map<String, String> _buildHeaders(Map<String, String>? extra) {
final token = _getToken?.call();
final headers = {'accept': 'application/json', ...?extra};
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
return headers;
}
Future<dynamic> get(String endpoint, {Map<String, String>? headers}) async {
final response = await http.get(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers),
);
return _processResponse(response);
}
Future<dynamic> post(
String endpoint,
dynamic data, {
Map<String, String>? headers,
}) async {
final hasBody = data != null;
final response = await http.post(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers),
body: hasBody ? jsonEncode(data) : null,
);
return _processResponse(response);
}
Future<dynamic> postForm(String endpoint, Map<String, String> data) async {
final response = await http.post(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders({
'Content-Type': 'application/x-www-form-urlencoded',
'accept': 'application/json',
}),
body: data, // http package handles form-encoding for Map<String, String>
);
return _processResponse(response);
}
Future<dynamic> put(
String endpoint,
dynamic data, {
Map<String, String>? headers,
}) async {
final hasBody = data != null;
final response = await http.put(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers),
body: hasBody ? jsonEncode(data) : null,
);
return _processResponse(response);
}
Future<dynamic> delete(
String endpoint, {
Map<String, String>? headers,
}) async {
final response = await http.delete(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers),
);
return _processResponse(response);
}
Map<String, String> _jsonHeaders(Map<String, String>? extra) {
return {'Content-Type': 'application/json', if (extra != null) ...extra};
}
Future<dynamic> _processResponse(http.Response res) async {
final body = res.body.isNotEmpty ? jsonDecode(res.body) : null;
if (res.statusCode >= 200 && res.statusCode < 300) {
return body;
}
if (res.statusCode == 401 && _onUnauthorized != null) {
await _onUnauthorized!();
}
throw Exception('API error ${res.statusCode}: $body');
}
}

View File

@@ -0,0 +1,418 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
typedef TokenProvider = String? Function();
typedef UnauthorizedHandler = Future<bool> Function();
class MultipartFilePayload {
MultipartFilePayload({
required this.bytes,
required this.filename,
this.fieldName,
});
final List<int> bytes;
final String filename;
final String? fieldName;
}
class ApiService {
String _baseUrl;
final http.Client _client;
final Duration timeout;
TokenProvider? _getToken;
UnauthorizedHandler? _onUnauthorized;
ApiService({
required String baseUrl,
http.Client? client,
this.timeout = const Duration(seconds: 30),
}) : _baseUrl = baseUrl,
_client = client ?? http.Client();
String get baseUrl => _baseUrl;
void setBaseUrl(String url) {
_baseUrl = url;
}
void setTokenProvider(TokenProvider provider) {
_getToken = provider;
}
void setUnauthorizedHandler(UnauthorizedHandler handler) {
_onUnauthorized = handler;
}
void dispose() {
_client.close();
}
Map<String, String> _buildHeaders(
Map<String, String>? extra, {
bool includeAuth = true,
}) {
final token = _getToken?.call();
final headers = {'accept': 'application/json', ...?extra};
if (includeAuth && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
return headers;
}
Future<dynamic> get(
String endpoint, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
final response = await _sendWithRetry(
() => _client.get(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers, includeAuth: includeAuth),
),
allowRetry: allowRetry,
);
return _processResponse(response);
}
Future<ApiBinaryResponse> getBytes(
String endpoint, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
final response = await _sendWithRetry(
() => _client.get(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers, includeAuth: includeAuth),
),
allowRetry: allowRetry,
);
if (response.statusCode >= 200 && response.statusCode < 300) {
final contentDisposition = response.headers['content-disposition'];
return ApiBinaryResponse(
bytes: response.bodyBytes,
statusCode: response.statusCode,
contentType: response.headers['content-type'],
filename: _extractFilename(contentDisposition),
);
}
final body = _decodeBody(response);
final message = _extractErrorMessage(body);
throw ApiException(
statusCode: response.statusCode,
message: message,
body: body,
);
}
Future<dynamic> post(
String endpoint,
dynamic data, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
final hasBody = data != null;
final response = await _sendWithRetry(
() => _client.post(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(
hasBody ? _jsonHeaders(headers) : headers,
includeAuth: includeAuth,
),
body: hasBody ? jsonEncode(data) : null,
),
allowRetry: allowRetry,
);
return _processResponse(response);
}
Future<ApiBinaryResponse> postBytes(
String endpoint,
dynamic data, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
final hasBody = data != null;
final response = await _sendWithRetry(
() => _client.post(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(
hasBody ? _jsonHeaders(headers) : headers,
includeAuth: includeAuth,
),
body: hasBody ? jsonEncode(data) : null,
),
allowRetry: allowRetry,
);
if (response.statusCode >= 200 && response.statusCode < 300) {
final contentDisposition = response.headers['content-disposition'];
return ApiBinaryResponse(
bytes: response.bodyBytes,
statusCode: response.statusCode,
contentType: response.headers['content-type'],
filename: _extractFilename(contentDisposition),
);
}
final body = _decodeBody(response);
final message = _extractErrorMessage(body);
throw ApiException(
statusCode: response.statusCode,
message: message,
body: body,
);
}
Future<dynamic> postMultipartFile(
String endpoint, {
required List<int> bytes,
required String filename,
String fieldName = 'file',
Map<String, String>? fields,
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
Future<http.Response> send() async {
final request = http.MultipartRequest(
'POST',
Uri.parse('$baseUrl$endpoint'),
);
request.headers.addAll(_buildHeaders(headers, includeAuth: includeAuth));
if (fields != null && fields.isNotEmpty) {
request.fields.addAll(fields);
}
request.files.add(
http.MultipartFile.fromBytes(
fieldName,
bytes,
filename: filename,
),
);
final streamed = await _client.send(request);
return http.Response.fromStream(streamed);
}
final response = await _sendWithRetry(send, allowRetry: allowRetry);
return _processResponse(response);
}
Future<dynamic> postMultipartFiles(
String endpoint, {
required List<MultipartFilePayload> files,
String fieldName = 'files',
Map<String, String>? fields,
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
Future<http.Response> send() async {
final request = http.MultipartRequest(
'POST',
Uri.parse('$baseUrl$endpoint'),
);
request.headers.addAll(_buildHeaders(headers, includeAuth: includeAuth));
if (fields != null && fields.isNotEmpty) {
request.fields.addAll(fields);
}
for (final file in files) {
request.files.add(
http.MultipartFile.fromBytes(
file.fieldName ?? fieldName,
file.bytes,
filename: file.filename,
),
);
}
final streamed = await _client.send(request);
return http.Response.fromStream(streamed);
}
final response = await _sendWithRetry(send, allowRetry: allowRetry);
return _processResponse(response);
}
Future<dynamic> postForm(
String endpoint,
Map<String, String> data, {
bool includeAuth = true,
bool allowRetry = true,
}) async {
final response = await _sendWithRetry(
() => _client.post(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(
{
'Content-Type': 'application/x-www-form-urlencoded',
'accept': 'application/json',
},
includeAuth: includeAuth,
),
body: data, // http package handles form-encoding for Map<String, String>
),
allowRetry: allowRetry,
);
return _processResponse(response);
}
Future<dynamic> put(
String endpoint,
dynamic data, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
final hasBody = data != null;
final response = await _sendWithRetry(
() => _client.put(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(
hasBody ? _jsonHeaders(headers) : headers,
includeAuth: includeAuth,
),
body: hasBody ? jsonEncode(data) : null,
),
allowRetry: allowRetry,
);
return _processResponse(response);
}
Future<dynamic> delete(
String endpoint, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
final response = await _sendWithRetry(
() => _client.delete(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers, includeAuth: includeAuth),
),
allowRetry: allowRetry,
);
return _processResponse(response);
}
Map<String, String> _jsonHeaders(Map<String, String>? extra) {
return {'Content-Type': 'application/json', if (extra != null) ...extra};
}
Future<dynamic> _processResponse(http.Response res) async {
final body = _decodeBody(res);
if (res.statusCode >= 200 && res.statusCode < 300) {
return body;
}
final message = _extractErrorMessage(body);
throw ApiException(
statusCode: res.statusCode,
message: message,
body: body,
);
}
dynamic _decodeBody(http.Response res) {
if (res.body.isEmpty) return null;
final contentType = res.headers['content-type'] ?? '';
final shouldTryJson = contentType.contains('application/json') ||
contentType.contains('+json') ||
res.body.trimLeft().startsWith('{') ||
res.body.trimLeft().startsWith('[');
if (!shouldTryJson) return res.body;
try {
return jsonDecode(res.body);
} catch (_) {
// Avoid turning a server-side error body into a client-side crash.
return res.body;
}
}
String _extractErrorMessage(dynamic body) {
if (body == null) return 'No response body';
if (body is String) return body;
if (body is Map<String, dynamic>) {
for (final key in ['message', 'error', 'detail', 'msg']) {
final val = body[key];
if (val is String && val.trim().isNotEmpty) return val;
}
return body.toString();
}
if (body is List) {
final parts = body
.map((e) => e is Map
? _extractErrorMessage(Map<String, dynamic>.from(e))
: e.toString())
.where((e) => e.trim().isNotEmpty)
.toList();
if (parts.isNotEmpty) return parts.join('; ');
}
return body.toString();
}
Future<http.Response> _sendWithRetry(
Future<http.Response> Function() send, {
required bool allowRetry,
}) async {
var response = await send().timeout(timeout);
if (response.statusCode == 401 && allowRetry && _onUnauthorized != null) {
final refreshed = await _onUnauthorized!();
if (refreshed) {
response = await send().timeout(timeout);
}
}
return response;
}
String? _extractFilename(String? contentDisposition) {
if (contentDisposition == null || contentDisposition.isEmpty) return null;
final utf8Match =
RegExp(r"filename\\*=UTF-8''([^;]+)", caseSensitive: false)
.firstMatch(contentDisposition);
if (utf8Match != null) {
return Uri.decodeComponent(utf8Match.group(1) ?? '');
}
final match =
RegExp(r'filename="?([^\";]+)"?', caseSensitive: false)
.firstMatch(contentDisposition);
return match?.group(1);
}
}
class ApiException implements Exception {
final int statusCode;
final String message;
final dynamic body;
ApiException({
required this.statusCode,
required this.message,
this.body,
});
@override
String toString() => 'API error $statusCode: $message';
}
class ApiBinaryResponse {
final List<int> bytes;
final int statusCode;
final String? contentType;
final String? filename;
ApiBinaryResponse({
required this.bytes,
required this.statusCode,
this.contentType,
this.filename,
});
}

View File

@@ -1,23 +1,33 @@
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/api_service.dart';
import 'package:mileograph_flutter/services/tokenStorageService.dart'; import 'package:mileograph_flutter/services/token_storage_service.dart';
class AuthService extends ChangeNotifier { class AuthService extends ChangeNotifier {
final ApiService api; final ApiService api;
bool _restoring = false; bool _restoring = false;
String? _accessToken;
Future<bool>? _refreshFuture;
final TokenStorageService _tokenStorage = TokenStorageService(); final TokenStorageService _tokenStorage = TokenStorageService();
AuthService({required this.api}); AuthService({required this.api}) {
api.setTokenProvider(() => token);
api.setUnauthorizedHandler(_handleUnauthorized);
}
AuthenticatedUserData? _user; AuthenticatedUserData? _user;
bool get isLoggedIn => _user != null; bool get isLoggedIn => _user != null;
String? get token => _user?.access_token; String? get token => _accessToken;
String? get userId => _user?.user_id; String? get userId => _user?.userId;
String? get username => _user?.username; String? get username => _user?.username;
String? get fullName => _user?.full_name; String? get fullName => _user?.fullName;
String get entriesVisibility => _user?.entriesVisibility ?? 'private';
String get mileageVisibility => _user?.mileageVisibility ?? 'private';
bool get isElevated => _user?.isElevated ?? false;
bool get isAdmin => isElevated; // alias for old name
bool get isDisabled => _user?.disabled ?? false;
void setLoginData({ void setLoginData({
required String userId, required String userId,
@@ -25,15 +35,25 @@ class AuthService extends ChangeNotifier {
required String fullName, required String fullName,
required String accessToken, required String accessToken,
required String email, required String email,
String? refreshToken,
String entriesVisibility = 'private',
String mileageVisibility = 'private',
bool isElevated = false,
bool isDisabled = false,
}) { }) {
_accessToken = accessToken;
_user = AuthenticatedUserData( _user = AuthenticatedUserData(
user_id: userId, userId: userId,
username: username, username: username,
full_name: fullName, fullName: fullName,
access_token: accessToken, accessToken: accessToken,
email: email, email: email,
entriesVisibility: entriesVisibility,
mileageVisibility: mileageVisibility,
isElevated: isElevated,
disabled: isDisabled,
); );
_persistToken(accessToken); _persistTokens(accessToken, refreshToken);
notifyListeners(); notifyListeners();
} }
@@ -48,8 +68,9 @@ class AuthService extends ChangeNotifier {
}; };
// 1. Get token // 1. Get token
final tokenResponse = await api.postForm('/token', formData); final tokenResponse = await api.postForm('/token', formData, includeAuth: false);
final accessToken = tokenResponse['access_token']; final accessToken = tokenResponse['access_token'];
final refreshToken = tokenResponse['refresh_token'];
// 2. Get user details // 2. Get user details
final userResponse = await api.get( final userResponse = await api.get(
@@ -67,6 +88,17 @@ class AuthService extends ChangeNotifier {
fullName: userResponse['full_name'], fullName: userResponse['full_name'],
accessToken: accessToken, accessToken: accessToken,
email: userResponse['email'], email: userResponse['email'],
refreshToken: refreshToken,
entriesVisibility: _parseVisibility(
userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'],
'private',
),
mileageVisibility: _parseVisibility(
userResponse['user_mileage_visibility'] ?? userResponse['mileage_visibility'],
'private',
),
isElevated: _parseIsElevated(userResponse),
isDisabled: _parseIsDisabled(userResponse),
); );
} }
@@ -77,21 +109,29 @@ class AuthService extends ChangeNotifier {
// read token from secure storage (with fallback) // read token from secure storage (with fallback)
final token = await _tokenStorage.getToken(); final token = await _tokenStorage.getToken();
if (token == null || token.isEmpty) return; if (token == null || token.isEmpty) return;
_accessToken = token;
final userResponse = await api.get( final userResponse = await api.get(
'/users/me', '/users/me',
headers: {
'Authorization': 'Bearer $token',
'accept': 'application/json',
},
); );
final restoredAccessToken = _accessToken ?? token;
setLoginData( setLoginData(
userId: userResponse['user_id'], userId: userResponse['user_id'],
username: userResponse['username'], username: userResponse['username'],
fullName: userResponse['full_name'], fullName: userResponse['full_name'],
accessToken: token, accessToken: restoredAccessToken,
email: userResponse['email'], email: userResponse['email'],
entriesVisibility: _parseVisibility(
userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'],
'private',
),
mileageVisibility: _parseVisibility(
userResponse['user_mileage_visibility'] ?? userResponse['mileage_visibility'],
'private',
),
isElevated: _parseIsElevated(userResponse),
isDisabled: _parseIsDisabled(userResponse),
); );
} catch (_) { } catch (_) {
await _clearToken(); await _clearToken();
@@ -104,12 +144,9 @@ class AuthService extends ChangeNotifier {
final token = await _tokenStorage.getToken(); final token = await _tokenStorage.getToken();
if (token == null || token.isEmpty) return false; if (token == null || token.isEmpty) return false;
try { try {
_accessToken = token;
await api.get( await api.get(
'/validate', '/validate',
headers: {
'Authorization': 'Bearer $token',
'accept': 'application/json',
},
); );
return true; return true;
} catch (_) { } catch (_) {
@@ -118,11 +155,15 @@ class AuthService extends ChangeNotifier {
} }
} }
Future<void> _persistToken(String token) async { Future<void> _persistTokens(String accessToken, String? refreshToken) async {
await _tokenStorage.setToken(token); await _tokenStorage.setToken(accessToken);
if (refreshToken != null && refreshToken.isNotEmpty) {
await _tokenStorage.setRefreshToken(refreshToken);
}
} }
Future<void> _clearToken() async { Future<void> _clearToken() async {
_accessToken = null;
await _tokenStorage.clearToken(); await _tokenStorage.clearToken();
} }
@@ -145,6 +186,61 @@ class AuthService extends ChangeNotifier {
await api.postForm('/register', formData); await api.postForm('/register', formData);
} }
Future<bool> _handleUnauthorized() async {
if (_refreshFuture != null) {
return _refreshFuture!;
}
_refreshFuture = _refreshTokens();
final refreshed = await _refreshFuture!;
_refreshFuture = null;
return refreshed;
}
Future<bool> _refreshTokens() async {
final refreshToken = await _tokenStorage.getRefreshToken();
if (refreshToken == null || refreshToken.isEmpty) {
await handleTokenExpired();
return false;
}
try {
final response = await api.post(
'/token/refresh',
{'refresh_token': refreshToken},
includeAuth: false,
allowRetry: false,
);
final accessToken = response['access_token'];
final newRefreshToken = response['refresh_token'];
if (accessToken is! String ||
accessToken.isEmpty ||
newRefreshToken is! String ||
newRefreshToken.isEmpty) {
await handleTokenExpired();
return false;
}
_accessToken = accessToken;
await _persistTokens(accessToken, newRefreshToken);
if (_user != null) {
_user = AuthenticatedUserData(
userId: _user!.userId,
username: _user!.username,
fullName: _user!.fullName,
accessToken: accessToken,
email: _user!.email,
entriesVisibility: _user!.entriesVisibility,
mileageVisibility: _user!.mileageVisibility,
isElevated: _user!.isElevated,
isDisabled: _user!.disabled,
);
}
notifyListeners();
return true;
} catch (_) {
await handleTokenExpired();
return false;
}
}
Future<void> handleTokenExpired() async { Future<void> handleTokenExpired() async {
_user = null; _user = null;
await _clearToken(); await _clearToken();
@@ -154,4 +250,67 @@ class AuthService extends ChangeNotifier {
void logout() { void logout() {
handleTokenExpired(); // reuse handleTokenExpired(); // reuse
} }
bool _parseIsElevated(Map<String, dynamic> json) {
dynamic value = json['is_elevated'] ??
json['elevated'] ??
json['is_admin'] ??
json['admin'] ??
json['isAdmin'] ??
json['admin_user'] ??
json['role'] ??
json['roles'] ??
json['permissions'] ??
json['scopes'] ??
json['is_staff'] ??
json['staff'] ??
json['is_superuser'] ??
json['superuser'] ??
json['groups'];
bool parseBoolish(dynamic v) {
if (v is bool) return v;
if (v is num) return v != 0;
final str = v?.toString().toLowerCase().trim();
if (str == null || str.isEmpty) return false;
if (['1', 'true', 'yes', 'y', 'admin', 'superuser', 'staff', 'elevated'].contains(str)) {
return true;
}
return str.contains('admin') || str.contains('superuser') || str.contains('staff');
}
if (value is List) {
for (final entry in value) {
if (parseBoolish(entry)) return true;
final s = entry?.toString().toLowerCase();
if (s != null &&
(s.contains('admin') ||
s.contains('superuser') ||
s.contains('staff') ||
s.contains('elevated') ||
s == 'root')) {
return true;
}
}
return false;
}
return parseBoolish(value);
}
bool _parseIsDisabled(Map<String, dynamic> json) {
dynamic value = json['disabled'] ?? json['is_disabled'];
if (value is bool) return value;
if (value is num) return value != 0;
final str = value?.toString().toLowerCase().trim();
if (str == null || str.isEmpty) return false;
return ['1', 'true', 'yes', 'y', 'disabled'].contains(str);
}
String _parseVisibility(dynamic value, String fallback) {
const allowed = ['private', 'friends', 'public'];
final str = value?.toString().toLowerCase().trim();
if (str != null && allowed.contains(str)) return str;
return fallback;
}
} }

View File

@@ -1,514 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.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
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 {
final ApiService api;
DataService({required this.api});
_LegFetchOptions _lastLegsFetch = const _LegFetchOptions();
// Homepage Data
HomepageStats? _homepageStats;
HomepageStats? get homepageStats => _homepageStats;
// Legs Data
List<Leg> _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
List<LocoSummary> _traction = [];
List<LocoSummary> get traction => _traction;
bool _isTractionLoading = false;
bool get isTractionLoading => _isTractionLoading;
bool _tractionHasMore = false;
bool get tractionHasMore => _tractionHasMore;
final Map<int, List<LocoAttrVersion>> _locoTimelines = {};
final Map<int, bool> _isLocoTimelineLoading = {};
List<LocoAttrVersion> timelineForLoco(int locoId) =>
_locoTimelines[locoId] ?? [];
bool isLocoTimelineLoading(int locoId) =>
_isLocoTimelineLoading[locoId] ?? false;
// 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;
List<EventField> _eventFields = [];
List<EventField> get eventFields => _eventFields;
bool _isEventFieldsLoading = false;
bool get isEventFieldsLoading => _isEventFieldsLoading;
// Station Data
List<Station>? _cachedStations;
DateTime? _stationsFetchedAt;
List<String> stations = [""];
bool _isHomepageLoading = false;
bool get isHomepageLoading => _isHomepageLoading;
bool _isOnThisDayLoading = false;
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() {
// Always defer to the next frame to avoid setState during build.
SchedulerBinding.instance.addPostFrameCallback((_) {
notifyListeners();
});
}
Future<void> fetchHomepageStats() async {
_isHomepageLoading = true;
try {
final json = await api.get('/stats/homepage');
_homepageStats = HomepageStats.fromJson(json);
_trips = _homepageStats?.trips ?? [];
} catch (e) {
debugPrint('Failed to fetch homepage stats: $e');
_homepageStats = null;
_trips = [];
} finally {
_isHomepageLoading = false;
_notifyAsync();
}
}
Future<void> fetchLegs({
int offset = 0,
int limit = 100,
String sortBy = 'date',
int sortDirection = 0,
String? dateRangeStart,
String? dateRangeEnd,
bool append = false,
}) async {
_isLegsLoading = true;
if (!append) {
_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) {
final newLegs = json.map((e) => Leg.fromJson(e)).toList();
_legs = append ? [..._legs, ...newLegs] : newLegs;
_legsHasMore = newLegs.length >= limit;
} else {
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 {
await fetchTraction(
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) {
final newItems = json.map((e) => LocoSummary.fromJson(e)).toList();
_traction = append ? [..._traction, ...newItems] : newItems;
_tractionHasMore = newItems.length >= limit - 1;
} else {
throw Exception('Unexpected traction response: $json');
}
} catch (e) {
debugPrint('Failed to fetch traction: $e');
if (!append) {
_traction = [];
}
_tractionHasMore = false;
} finally {
_isTractionLoading = false;
_notifyAsync();
}
}
Future<List<LocoAttrVersion>> fetchLocoTimeline(int locoId) async {
_isLocoTimelineLoading[locoId] = true;
_notifyAsync();
try {
final json = await api.get('/loco/get-timeline/$locoId');
final timeline = LocoAttrVersion.fromGroupedJson(json);
_locoTimelines[locoId] = timeline;
return timeline;
} catch (e) {
debugPrint('Failed to fetch loco timeline for $locoId: $e');
_locoTimelines[locoId] = [];
return [];
} finally {
_isLocoTimelineLoading[locoId] = false;
_notifyAsync();
}
}
Future<dynamic> createLoco(Map<String, dynamic> payload) async {
try {
final response = await api.put('/loco/new', payload);
final locoClass = payload['class']?.toString();
if (locoClass != null &&
locoClass.isNotEmpty &&
!_locoClasses.contains(locoClass)) {
_locoClasses = [..._locoClasses, locoClass];
}
_notifyAsync();
return response;
} catch (e) {
debugPrint('Failed to create loco: $e');
rethrow;
}
}
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 tripMap = json.map((e) => TripDetail.fromJson(e)).toList();
_tripDetails = [...tripMap]..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<List<TripLocoStat>> fetchTripLocoStats(int tripId) async {
try {
final json = await api.get('/trips/stats/$tripId');
return _parseTripLocoStats(json);
} catch (e) {
debugPrint('Failed to fetch trip loco stats: $e');
return [];
}
}
List<TripLocoStat> _parseTripLocoStats(dynamic json) {
List<dynamic>? list;
if (json is List) {
list = json.expand((e) => e is List ? e : [e]).toList();
} else if (json is Map) {
for (final key in ['locos', 'stats', 'data', 'trip_locos']) {
final candidate = json[key];
if (candidate is List) {
list = candidate.expand((e) => e is List ? e : [e]).toList();
break;
}
}
}
if (list == null) return [];
return list
.whereType<Map<String, dynamic>>()
.map((e) => TripLocoStat.fromJson(e))
.toList();
}
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 {
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 tripMap = raw
.whereType<Map<String, dynamic>>()
.map((e) => TripSummary.fromJson(e))
.toList();
_tripList = [...tripMap]..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;
}
Future<void> createLocoEvent({
required int locoId,
required String eventDate,
required Map<String, dynamic> values,
required String details,
String eventType = 'other',
}) async {
try {
await api.put(
'/event/new',
{
'loco_id': locoId,
'loco_event_type': eventType,
'loco_event_date': eventDate,
'loco_event_value': jsonEncode(values),
'loco_event_details': details,
},
);
} catch (e) {
debugPrint('Failed to create loco event: $e');
rethrow;
}
}
void clear() {
_homepageStats = null;
_legs = [];
_onThisDay = [];
_trips = [];
_tripDetails = [];
_eventFields = [];
_locoTimelines.clear();
_isLocoTimelineLoading.clear();
_notifyAsync();
}
double getMileageForCurrentYear() {
final currentYear = DateTime.now().year;
return getMileageForYear(currentYear) ?? 0;
}
double? getMileageForYear(int year) {
return _homepageStats?.yearlyMileage
.firstWhere(
(entry) => entry.year == year,
orElse: () => YearlyMileage(year: null, mileage: 0),
)
.mileage ??
0;
}
Future<List<Station>> fetchStations() async {
final now = DateTime.now();
// If cache exists and is less than 30 minutes old, return it
if (_cachedStations != null &&
_stationsFetchedAt != null &&
now.difference(_stationsFetchedAt!) < Duration(minutes: 30)) {
return _cachedStations!;
}
final response = await api.get('/location');
final parsed = (response as List).map((e) => Station.fromJson(e)).toList();
_cachedStations = parsed;
_stationsFetchedAt = now;
return parsed;
}
}

Some files were not shown because too many files have changed in this diff Show More