Compare commits

..
88 Commits
Author SHA1 Message Date
jpmschweitzer 52048c03ce fix(permissions): narrow rm -rf deny globs to their exact forms
The trailing wildcard on the three rm -rf deny entries spanned path
separators, so Bash(rm -rf /*) matched every absolute path on the
machine rather than the filesystem root, and the ~ and $HOME entries
had the same shape. Narrowed to the exact literal forms.

These rules match literal command text, so they still stop a typo on
rm -rf /, rm -rf ~ or rm -rf $HOME exactly, but they no longer stop a
recursive delete aimed at any other path. That reduced cover is
deliberate, not an oversight.
2026-08-25 20:31:28 +02:00
jpmschweitzer f04672f350 build(make): prove setup converged instead of trusting build_runner's exit code
`make setup` ran build_runner and reported success whether it produced the
39 files a fresh clone needs or almost nothing. `flutter test`'s only guard
checked a single sentinel file, which is why the 2026-08-09 4-of-46 gap
still read as 26 passed / 17 failed instead of a missing build step.

ci/check_codegen.sh walks every `part` directive under lib/ and confirms
the sibling file it names exists, then wires into both `setup` (fail loud
right after codegen if it under-produced) and `test` (fail loud, exit 69,
if nobody ran setup at all). Replaces the one-file guard, which would have
missed 44 of the 45 directives that exist today.
2026-08-17 12:05:06 +02:00
jpmschweitzerandClaude 809f900bd5 chore(pql): close T-1
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 21:27:15 +02:00
jpmschweitzerandClaude 69de933ab0 fix(claude): share the browser-automation rules, untrack the local settings file
.claude/settings.local.json was tracked — the one file in the workspace whose
whole purpose is to stay out of version control. .gitignore has listed it since
it was added and line 130 even carries the git rm --cached command, but gitignore
cannot act on a path git already tracks, so the rule had never once fired.

Nothing leaked. Both committed versions held four permission rules and no env
keys, checked per commit rather than only at HEAD. The risk was prospective: the
next person to put a credential in the local overrides file would have committed
it, and the ignore rule would have stayed silent about it.

The four rules are worth sharing, so they move rather than disappear. They allow
chrome-devtools screenshot/snapshot/navigate and puppeteer evaluate — visual
verification, which is routine work in a Flutter UI and not one person's
preference. settings.json is committed by design and already carries 16 allow
and 43 deny rules, so they now sit with their peers. Anyone cloning this repo
keeps the tooling; before this commit they only got it by accident.

The file itself stays on disk, so no one loses local settings. It is simply no
longer shared, and the existing ignore rule now has something it can act on.

Closes T-1.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 21:27:15 +02:00
jpmschweitzerandClaude 3776a4012b chore(pql): file T-1 — settings.local.json is tracked and should not be
First ticket in this repo's vault, so the changelog files are new. Committed
because the database is gitignored and the changelog is what makes a ticket
travel with a clone (workspace D-15); uncommitted, this ticket would exist only
on one machine.

The bug itself: .gitignore has listed .claude/settings.local.json since it was
added, and line 130 even carries the git rm --cached command, but gitignore does
not apply to paths git already tracks — so the rule has been inert the whole
time. This is the only repo in the workspace where that file is tracked.

No credentials were ever committed; both existing versions hold four permission
rules and no env keys, checked per commit rather than only at HEAD. The ticket
records that explicitly, because a previous survey misread this same file as
credentials across nine repos and the correction is worth keeping attached to it.

Not fixing it here. The four rules allow browser-automation MCP tools, and
untracking silently removes them from every clone — whether they are personal or
belong in the committed settings.json is a judgement about how people work in
this repo, not something to decide while filing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 21:22:20 +02:00
jpmschweitzerandClaude 05948b41a6 fix(analysis): clear the five findings blocking the pre-push gate
flutter analyze exits non-zero on info-level findings too, so all five had to
go for `make pre-push` to pass. Four were mechanical. The fifth was not.

envApiUser was reported as an unused declaration. Removing it revealed that the
field behind it, _envApiUser, was then unused as well -- and the pair turns out
to be a closed loop nothing could enter: the getter is public but sits on
_DashboardContentState, a private class, so no caller outside this file could
ever have reached it. The field was written once per session and never read.
The debugPrint next to it logs envData.user directly, so the logging the
comment describes never depended on the stored copy. Field, getter and
assignment removed; _hasLoggedEnvUser stays, because it genuinely guards the
log-once.

Deleting the first warning exposing the second is the useful part: unused_field
could not fire while a dead getter was "using" it. Dead code hides dead code.

The two `if (x != null) x` collection entries become null-aware elements, which
is the same intent spelled the way the SDK now expects. The two casts in
data_grid_test were the second cast of a pair -- `mode as InfiniteDataMode` on
the preceding line already promotes the local.

flutter analyze: No issues found. The edited test file still passes all 37.

Note the gate still prints "not gated here yet: test (T-56)" -- analysis is
green, tests remain unwired, and that is deliberately left visible.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:49:05 +02:00
jpmschweitzerandClaude 9385dd253a build(ci): move the pre-push gate into the Makefile
The hook carried ~50 lines of gitleaks logic and a comment explaining it was
self-contained because "this repo has no Makefile". It has one now, so the
reason is gone and the arrangement is backwards: a hook is a trigger, and
logic belongs where it can be read, run by hand, and changed under review.

.githooks/pre-push is now a byte-identical shim onto `make pre-push` in every
repo in the workspace. The scan itself moves to ci/secrets.sh unchanged, and
`make secrets` runs it on its own.

The call surface is identical everywhere; what it runs is not, and should not
be — each repo gates what it actually has. That is the point of standardising
the name rather than the contents: nobody has to read a repo to find out how
to check it.

secrets runs first, deliberately. It is the only failure here that cannot be
undone by fixing it afterwards — a failed lint costs another commit, a pushed
credential is cached and indexed whether or not it is later deleted.

Some of these gates fail today, on lint debt that predates them, and they are
left wired anyway. The board was measured once and written down in T-56
instead of being worked around here. Narrowing each gate to whatever already
passes would produce a gate that reports success for doing nothing, which is
the failure this workspace keeps rediscovering.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 18:57:22 +02:00
jpmschweitzerandClaude e816b0cb35 ci(make): reserve exit 69 for "could not run" (D-26)
Environment guards now exit 69 rather than 1, so a caller can tell a suite
that could not start from one that ran and failed. The first toj test sweep
reported "3 repositories failed" and none of the three had executed a test —
two could not find go, one had no venv. That points the reader at the tests
when the fault is in the environment.

Only the environment guards change. A gitleaks finding, a failed test run and
a vulncheck hit still exit 1, because those did run and did fail.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 15:56:23 +02:00
jpmschweitzerandClaude 36fdffe644 build: make the Makefile aware that this repo needs codegen
The suite reported 26 passed and 17 failed, which reads as broken tests and
was actually a missing build step. *.freezed.dart and lib/**/*.g.dart are
gitignored, so a fresh tree has none of them and most of the suite fails to
compile rather than to assert. After running build_runner the same suite is
452 passed, unchanged.

setup now runs pub get then generate, and generate exists on its own for after
a model change. test guards on a known generated file and says which command
fixes it, because "cannot compile" and "assertion failed" are different
problems and the runner presents them identically.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 15:25:57 +02:00
jpmschweitzerandClaude 110116f586 build: add the Makefile command surface (D-27)
Every repo gets one at the root: help, plus test and lint where those exist.
The point is that a target name means the same thing in every repo, so an
agent or a person can act without reading the repo first.

Paths resolve here rather than in callers (D-10). python3 on this host is 3.8
and cannot parse these sources, and a bare pytest or ruff resolves only in a
login shell — so both are named explicitly through the venv, and a missing
venv fails with the command to fix it rather than a bare no-such-file.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 15:10:35 +02:00
jpmschweitzerandClaude abc8520a0e chore(claude): pin PQL_VAULT per project so cwd stops choosing the vault
pql is now a bare word on PATH, which removed the long incantation that had
been forcing --vault into every call by habit. Convenience lowered the cost
of the wrong thing without lowering the cost of the right one: a three-word
pql ticket new targets whichever vault the cwd happens to sit in, and there
are nine of them with colliding id sequences.

PQL_VAULT in each project settings file makes the vault a property of the
session rather than of the working directory — the same lesson Rule 3 records
for git -C, applied to pql. Verified the env var overrides cwd discovery,
that an explicit --vault still beats the env var, and that the harness
hot-reloads it without a restart.

This does not make provenance visible: no output says which vault answered,
so a forgotten --vault still returns a well-formed answer about the wrong
dataset. That remains T-37.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 13:49:01 +02:00
jpmschweitzerandClaude 024a0ce074 chore(claude): deny toj in the sub-repos
toj is now on the global PATH as /usr/local/bin/toj, so its scope boundary
had to stop being "the absolute path is inconvenient to type" and start
being a rule. Its repo and settings verbs operate on the workspace root; run
from inside this repo they answer about the wrong tree.

Both spellings are denied, bare and absolute, because a deny with one
spelling left open is decorative.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 13:42:07 +02:00
jpmschweitzerandClaude 5181b12d7a ci: gate pushes on a gitleaks scan of the outgoing commits
No repo here scanned for committed credentials. The hook is self-contained
rather than delegating to a Makefile, because this repo has none and a hook
reaching into a sibling repo breaks the moment this one is cloned elsewhere.

Scans the outgoing range rather than full history: history carries settled
findings — test fixtures, vendored third-party code — and a gate that fails
on something unfixable gets bypassed within a week.

Setting core.hooksPath means pql init must replant its replication shims into
.githooks, which is why they are gitignored here alongside the tracked
pre-push. Same layout pql itself uses.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 12:48:56 +02:00
jpmschweitzerandClaude 2baf8b4ba3 docs: qualify workspace decision ids cited from this repo
Decision ids are per-vault sequences, so they collide by construction
once there is more than one vault -- and every repo now has one. A bare
D-15 here will mean this repo's D-15 the moment this repo records one.
Cross-vault references are therefore qualified: workspace D-15.

Not hypothetical: pql holds D-1 through D-31 while the workspace holds
D-1 through D-21, so every workspace id currently collides with an
unrelated pql one. A bare id is not wrong the day it is written -- it
decays into wrong as the other vault grows, and nothing flags it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 04:17:07 +02:00
jpmschweitzerandClaude c26b54398c docs: replace AGENTS.md with a repo-specific CLAUDE.md
One agent doc per repo, and it is CLAUDE.md. Written fresh rather than
reformatted. PHILOSOPHY.md linked to the old file, so that pointer moves
with it, and its standing requirement to be read before working here is
carried forward rather than lost in the rewrite.

Two claims did not survive verification. The app is published on 9999,
not the tower:8092 the old file gave, and it pointed at portainer-core
for full-stack documentation -- that repo is deprecated and must not be
used as a source of infra facts.

Establishing what is live needs a different method here: there is no
sys.modules to read, since the container holds a compiled web build
rather than source. A transitive walk of import/export/part directives
from lib/main.dart found 8 of 132 files unreachable, and five of those
are exactly what runs in production. They are conditional-import targets
-- `import 'a.dart' if (dart.library.html) 'b.dart'` -- and a walk that
takes the first string misses the branch. Since this ships as Flutter
web, the _web half is live and the _stub/_native half is dormant. The
naive reading was not merely wrong but inverted.

Of the three genuinely unreferenced files, stack_model.dart is imported
only by its own test, so the suite is green and vouches for a model the
app never uses. permission_gate.dart sits next to an unimplemented auth
redesign and is recorded as undetermined rather than dead.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 03:15:54 +02:00
jpmschweitzerandClaude f9f10b322f chore: adopt the workspace agent-config baseline
Commits a .claude/settings.json rather than leaving permissions to
per-developer local state, and initialises a pql vault for this repo's
tickets and internal decisions.

Every git deny rule appears in both the `git <verb>` and `git * <verb>`
forms. Only the second catches `git -C <path>`, and without it the whole
deny list is decorative -- it looks like a policy and stops nothing.

The allow list carries pql's absolute path alongside the bare name.
pql is installed to ~/.local/bin, which is on the login PATH but not the
one a non-interactive shell gets, so the bare-name rules match nothing on
their own and every call would prompt anyway.

.gitignore now covers .claude/settings.local.json, which is machine-local
and must never be shared. `pql init` contributed the .pql/* rules with an
exception for the changelog, which is the replication log of record and
has to be committed for tickets to travel with a clone.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 03:15:20 +02:00
jpmschweitzerandClaude Fable 5 13991c7afe docs(agents): registry is git.schweitz.net not git.schweitz.internal
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:11:07 +02:00
jpmschweitzerandClaude Fable 5 1e789c1d6b chore: release v1.7.1
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m41s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:41:05 +02:00
jpmschweitzerandClaude Fable 5 f38a4e7c7d fix(api): keep DioExceptionType switch exhaustive across dio versions
CI resolves dependencies fresh (pubspec.lock is gitignored), so the
v1.7.0 build failed when dio 5.10 introduced transformTimeout. A
default clause absorbs future enum additions on either dio version;
unknown keeps identical behavior through it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:41:05 +02:00
jpmschweitzerandClaude Fable 5 d6f2223de5 chore: release v1.7.0
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Failing after 5m19s
Network-migration release: default Core API and Tatlock API URLs now
point at the public https schweitz.net domains, and the decommissioned
Netdata / code-server quick links are removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:31:43 +02:00
jpmschweitzerandClaude Fable 5 8839426bc8 chore(config): drop dead Netdata/code-server links and unused URL constants
Netdata was never deployed and code-server was decommissioned on
2026-07-19; their quick links pointed at dead domains. The
portainerUrl/netdataUrl constants had no consumers (quick links
hardcode their own URLs). The Portainer quick link stays — its
portainer.schweitz.net host arrives with the port-lockdown phase.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:30:27 +02:00
jpmschweitzerandClaude Fable 5 8886326eb2 fix(config): default API URLs to https schweitz.net domains
Browser clients run on machines other than the host, and the homelab is
retiring direct LAN IP:port access (ports move to loopback behind NPM),
so the 192.168.86.149 defaults would stop working. The public domains
work from anywhere; LAN clients bypass Authentik via source-IP rules.
LAN development can still override via --dart-define.

Portainer (9000) and Netdata (19999) defaults are left unchanged: no
*.schweitz.net proxy hosts exist for those services yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:22:46 +02:00
jpmschweitzerandClaude Fable 5 e52bdeb664 chore(ci): push images via git.schweitz.net registry
The .internal registry domain is being retired; git.schweitz.net now
serves the registry without SSO on /v2/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:15:02 +02:00
Jeroen Schweitzer 980af45aac chore: update branch references from master to main 2026-01-12 17:09:26 +01:00
Jeroen SchweitzerandClaude Opus 4.5 4331555f84 feat: add news ticker widget for scrolling headlines
Build and Push / build (push) Successful in 3m25s
Build and Push / release (push) Successful in 3s
- Add NewsTickerWidget with horizontal auto-scrolling at 40px/sec
- Add NewsData and NewsHeadline Freezed models
- Add news datasource fetching from /tools/news endpoint
- Add news provider with 30-minute auto-refresh
- Place ticker between Welcome card and System Stats on dashboard
- Show placeholder headlines when no data (italic, muted style)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:51:32 +01:00
Jeroen SchweitzerandClaude Opus 4.5 a376482cd1 feat: align horizon line at 50px across all environment widgets
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m24s
- Weather/Air Quality dividers align with Sun Position horizon
- Forecast card bottoms align with same horizon line
- Unified visual rhythm across all cards

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 19:50:13 +01:00
Jeroen SchweitzerandClaude Opus 4.5 f31967aa3e feat: bottom-aligned widgets and wind direction
Build and Push / release (push) Successful in 2s
Build and Push / build (push) Successful in 3m27s
- All environment widgets align content from bottom for visual harmony
- Wind chip now shows direction (e.g., "SE 14 km/h")

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 19:10:24 +01:00
Jeroen SchweitzerandClaude Opus 4.5 780edd2d3b fix: environment widget alignment and sun position night labels
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m27s
- Consistent 170px minHeight across Weather, Air Quality, Forecast widgets
- Swap sunrise/sunset labels at night to match arc direction
- Weather header shows "Weather" instead of location

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 18:52:27 +01:00
Jeroen SchweitzerandClaude Opus 4.5 eefb491e87 fix: log environment API user only once per session
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m11s
- Store user in static variable for reuse
- Only debugPrint on first successful load

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 15:40:06 +01:00
Jeroen SchweitzerandClaude Opus 4.5 9b2000efc2 fix: sun position arc overflow and revert user display
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m14s
- Constrain arc height to fit within card boundaries
- Scale radius down when arc would overflow on wider displays
- Revert user display in section header, use debugPrint instead

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 15:26:51 +01:00
Jeroen SchweitzerandClaude Opus 4.5 cc6068b759 chore: release v1.5.4
Build and Push / release (push) Successful in 2s
Build and Push / build (push) Successful in 3m9s
Add user display in environment section header for debugging OIDC
user resolution issues.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 14:05:16 +01:00
Jeroen SchweitzerandClaude Opus 4.5 9a75fb25db feat(sun-position): redesign arc to touch horizon at endpoints
Build and Push / release (push) Successful in 23s
Build and Push / build (push) Successful in 3m13s
- Arc geometry now uses chord-radius calculation for proper horizon intersection
- Sunrise/sunset icons integrated at horizon endpoints (removed duplicate labels)
- Daylight duration centered below arc
- Increased all environment card heights 20% (140px → 170px) for better arc visibility
- Visual balance: sunrise/sunset raised 10px, daylight at bottom

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 13:29:56 +01:00
Jeroen SchweitzerandClaude Opus 4.5 9b2878f6e3 feat(sun-position): add proportional arc visualization with 10-min updates
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m12s
- Arc angle now proportional to day/night duration (day = daylight/24 × 360°)
- Horizon points represent sunrise/sunset times
- Day arc with sun icon and yellow/orange gradient
- Night arc with moon icon and blue/indigo gradient
- Position updates every 10 minutes aligned to clock (0/10/20/30/40/50)
- Default to 07:00-17:00 when API data unavailable (asymmetric for visual effect)
- All environment cards maintain consistent height in "no data" state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 12:06:17 +01:00
Jeroen SchweitzerandClaude Opus 4.5 d200cad8be feat: environment widgets layout redesign with always-visible widgets
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m11s
- Desktop: 4-in-a-row layout (30/20/20/30 distribution)
- Tablet: 2x2 grid layout
- Mobile: Stacked vertically
- All widgets show "No data available" state instead of being hidden
- Sun position calculates from clock, defaults to 6am/6pm
- Environment refresh changed from 5min to 1 hour

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 11:21:48 +01:00
Jeroen SchweitzerandClaude Opus 4.5 fffc3d5baf chore: release v1.5.0
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m12s
feat: add dynamic environment widgets

Add live weather, sun position, and forecast widgets to Front Hall dashboard,
powered by data from the Qdrant volatile collection via core-api.

New widgets:
- SunPositionWidget: Animated arc showing sun/moon position with gradient colors
- ForecastWidget: Multi-day weather outlook
- Updated WeatherWidget and AirQualityWidget to accept API data

Infrastructure:
- Environment datasource calling GET /tools/environment
- Environment provider with 5-minute auto-refresh
- Freezed models for environment data

Tests:
- 12 widget tests for environment section
- Updated existing tests with givenEnvironment() harness method

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:05:24 +01:00
Jeroen SchweitzerandClaude Opus 4.5 ea6914b5f4 test: add responsive tests to all page test files
Add shared screen size constants and configureScreenSize() helper in
test/harness/screen_sizes.dart. Add responsive test groups to all page
tests covering desktop (1920x1080), tablet landscape (1024x768), tablet
portrait (768x1024), mobile (375x812), and small mobile (320x568).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 19:45:32 +01:00
Jeroen SchweitzerandClaude Opus 4.5 71eaccce67 test: add widget tests for ControlRoomPage
- Add 13 widget tests covering:
  - Sections nav panel with Stack/Data Management groups
  - Containers section with stacks filter panel
  - Proxy Hosts section navigation
  - Placeholder for unimplemented sections
  - Loading and empty states

- Add 5 unit tests for ControlRoomNav enum:
  - Unique IDs, labels, sections
  - Route path generation
  - NavItem conversion

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 12:12:17 +01:00
Jeroen SchweitzerandClaude Opus 4.5 422fac392c test: add widget tests for SettingsPage
- Add 14 widget tests covering:
  - Settings header and section displays
  - Appearance section with theme dropdown options
  - Navigation section with default room dropdown
  - Account section with user info (name, email, roles)
  - Theme icons for system/light/dark modes
  - Three section cards layout

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:31:13 +01:00
Jeroen SchweitzerandClaude Opus 4.5 837ccb6709 test: add widget tests for FrontHallPage
- Add 14 widget tests covering:
  - Welcome message and Quick Links panel display
  - System Stats and Environment sections
  - Loading and error states
  - API endpoint verification
  - Default links fallback behavior
  - Category headers display

- Fix fixtures:
  - Add SystemStats fixture for dashboard content
  - Remove duplicate quickLinks definition
  - Update quickLinks endpoint to /dashboard/quick-links

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:14:27 +01:00
Jeroen SchweitzerandClaude Opus 4.5 d6fd9aea60 test: add test harness and widget tests for DataGrid pages
Add comprehensive testing infrastructure:
- Test harness with mock auth, API client, and fixtures
- Mock Dio interceptor for canned API responses
- Fixtures for containers, domains, users, groups

Add widget tests for all DataGrid pages:
- ContainersListPage (21 tests)
- ProxyHostsPage (16 tests)
- UsersListPage (21 tests)
- GroupsListPage (18 tests)

Add unit tests:
- RoomRegistry (28 tests)
- DataGrid components (35 tests)

Test count: 282 -> 357 (+75 tests)
Coverage: 5.7% -> 19.1% (+504 lines)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:18:34 +01:00
Jeroen Schweitzer 09e120221d auth refactor plan for later consideration 2026-01-05 17:09:14 +01:00
Jeroen Schweitzer 48d34dd3b5 theme colors for PWA 2026-01-05 17:08:59 +01:00
Jeroen SchweitzerandClaude Opus 4.5 c3f6d27a52 chore: release v1.4.0
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m7s
- Decentralized Room Registry pattern
- Each room registers itself with central registry
- Dynamic navigation tabs and settings dropdown
- New Media Room and Parlor feature folders
- Permission-based room filtering support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 15:46:44 +01:00
Jeroen SchweitzerandClaude Opus 4.5 40ba6a869d fix: apply default room preference on app load
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m7s
- Root `/` now redirects to user's preferred default room
- Front Hall moved to `/front-hall` route (was `/`)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 15:00:47 +01:00
Jeroen SchweitzerandClaude Opus 4.5 c494ace5d8 feat: add URL deep-linking for DataGrids
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m5s
- Add PageUrlState utility for URL ↔ state serialization
- Add column `id` field for unique column identification in URLs
- Update idSelector to return String for URL compatibility
- All DataGrid pages now support URL params: search, sort, order, id
- Browser URL updates via replaceState (no GoRouter rebuilds)
- Add FilterPanelSemantics for filter panel semantic IDs
- Add TESTING.md documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:59:08 +01:00
Jeroen SchweitzerandClaude Opus 4.5 a6c51f757a feat(semantics): add semantic labels for UI automation
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
- Created lib/core/semantics/ with semantic ID constants and helper widget
- Enabled SemanticsBinding on web builds for accessibility tree exposure
- Added semantic IDs to:
  - ProfileDropdown (button, settings, theme options, logout)
  - TopHeaderBar room tabs (frontHall, controlRoom, security, parlor)
  - NavPanel items (nav_item_{id})

This enables browser automation tools like Puppeteer and WebDriver to
discover and interact with Flutter widgets via the accessibility tree.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 12:21:43 +01:00
Jeroen SchweitzerandClaude Opus 4.5 04032a6dbc fix(auth): remove auto-signout on 401 in AuthInterceptor
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m2s
The AuthInterceptor was calling signOut() on any 401 error, which caused
the theme toggle to trigger logout when the preferences API returned 401.
Now 401 errors propagate to calling code for graceful handling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 10:16:43 +01:00
Jeroen SchweitzerandClaude Opus 4.5 6b6614f482 fix(auth): prevent AuthNotifier auto-dispose causing theme toggle logout
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m1s
Applied @persistentRiverpod annotation to AuthNotifier so it persists
for app lifetime. Previously, theme changes could trigger AuthProvider
rebuild via auto-dispose, causing AsyncLoading state and auth issues.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 09:50:48 +01:00
Jeroen SchweitzerandClaude Opus 4.5 265ca5959d fix: theme toggle causing auth issues due to auto-dispose
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
- Add @persistentRiverpod annotation for providers that need keepAlive
- ThemeProvider now persists for app lifetime
- Refactored API clients to use @persistentRiverpod
- Documented in ARCHITECTURE.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 21:44:03 +01:00
Jeroen SchweitzerandClaude Opus 4.5 c617d7dfbb feat: add settings page and theme toggle in user dropdown
Build and Push / build (push) Successful in 3m3s
Build and Push / release (push) Successful in 3s
- Settings page with Appearance, Navigation, and Account sections
- Theme toggle (System/Light/Dark) in profile dropdown
- Theme syncs with API preferences on login
- Default room preference syncs with backend

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 21:24:21 +01:00
Jeroen SchweitzerandClaude Opus 4.5 f0f5e08c46 chore: match HTML background to Flutter dark theme
Changed from #1a1a2e to #111111 to match FlexScheme.aquaBlue scaffold background.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:40:39 +01:00
Jeroen SchweitzerandClaude Opus 4.5 9db677bee2 chore: rename Stack section to Stack Management
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:39:34 +01:00
Jeroen SchweitzerandClaude Opus 4.5 2e1d1dd457 refactor: reorganize Control Room navigation
Build and Push / release (push) Successful in 2s
Build and Push / build (push) Successful in 3m0s
- Consolidate Containers and Proxy Hosts under "Stack" section
- Add "Data Management" section with database browser placeholders:
  PostgreSQL, Redis, Qdrant, Neo4j
- Remove unused nav items: Networks, Volumes, Images (Portainer),
  Redirections, Streams, Certificates (NPM)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:34:25 +01:00
Jeroen SchweitzerandClaude Opus 4.5 34fdc77818 fix: prevent API client provider auto-dispose causing Ref invalidation
Build and Push / release (push) Successful in 4s
Build and Push / build (push) Successful in 2m58s
API client providers (coreApiClientProvider, tatlockApiClientProvider) now
use keepAlive: true. This fixes "DioException [unknown]: null" errors on
pages like /security/users where ref.read() was used without subscription.

The AuthInterceptor stores a Ref that became invalid when the provider
auto-disposed after a one-time read.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:08:46 +01:00
Jeroen SchweitzerandClaude Opus 4.5 c374ecbffb feat: add visible version debugPrint on app startup
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:36:52 +01:00
Jeroen SchweitzerandClaude Opus 4.5 55a9cbdc6e chore: release v1.1.10
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
Remove page swipe transitions - instant navigation via NoTransitionPage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:30:20 +01:00
Jeroen SchweitzerandClaude Opus 4.5 a6fc9daab9 docs: add NPM forward auth config for reference
Config for home.schweitz.net with Authentik forward auth:
- Static assets excluded via auth_request off
- Proper proxy pass to upstream

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:19:31 +01:00
Jeroen SchweitzerandClaude Opus 4.5 8b3bff7df0 chore: release v1.1.9
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
Move health check to /health directory for NPM forward auth exclusion

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 18:54:17 +01:00
Jeroen SchweitzerandClaude Opus 4.5 2b2ddc1b1b docs: simplify release steps - CI auto-triggers on v* tag push
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 18:26:54 +01:00
Jeroen SchweitzerandClaude Opus 4.5 0d5986b81a chore: release v1.1.8
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m10s
Dark background on web/index.html to prevent white flash during auth redirects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 18:19:10 +01:00
Jeroen SchweitzerandClaude Opus 4.5 31e3306997 chore: release v1.1.7
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m58s
Remove callback route - AuthController handles it before app starts:
- Removed /callback route from Flutter router
- Removed _OidcCallbackPage widget
- Auth is now invisible - no Flutter UI during auth flow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:41:43 +01:00
Jeroen SchweitzerandClaude Opus 4.5 5f3ff7f31a chore: release v1.1.6
Build and Push / build (push) Successful in 3m3s
Build and Push / release (push) Successful in 3s
Auth moved to standalone controller outside Riverpod:
- New AuthController runs in main() before runApp()
- Handles callback, token exchange, and /auth/sync before app starts
- If auth not ready (redirecting), app doesn't start at all
- AuthProvider now just loads stored tokens (no async OIDC logic)
- Fixes "Cannot use Ref after disposed" errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:14:20 +01:00
Jeroen SchweitzerandClaude Opus 4.5 f90b4a0963 fix: skip silent OIDC on callback page to prevent race condition
Build and Push / release (push) Successful in 2s
Build and Push / build (push) Successful in 3m3s
AuthProvider.build() was initiating silent OIDC while the callback
page was processing the auth code, causing PKCE state to be cleared.
Now checks if on /callback route and skips silent OIDC initiation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:51:39 +01:00
Jeroen SchweitzerandClaude Opus 4.5 a95296e1fc fix: fall back to regular OIDC when silent auth fails
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m16s
When prompt=none fails with login_required (no Authentik session),
automatically redirect to regular OIDC flow to show login UI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:20:57 +01:00
Jeroen SchweitzerandClaude Opus 4.5 3fa97bb0b0 feat: silent OIDC auth with JWT Bearer tokens for web
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
- Add prompt=none to silently obtain JWT when Authentik session exists
- Flutter sends Bearer token to core-api instead of forward auth cookies
- Fixes cross-subdomain cookie issues between home/api.schweitz.net
- Callback syncs with /auth/sync for user profile and roles
- API interceptor now adds Bearer token on web

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:11:07 +01:00
Jeroen SchweitzerandClaude Opus 4.5 f0b32ff68b fix(auth): skip Flutter OIDC on web, rely on NPM forward auth
Build and Push / release (push) Successful in 4s
Build and Push / build (push) Successful in 3m9s
On web, NPM forward auth handles authentication at the proxy level.
By the time the Flutter app loads, the user is already authenticated.
Skip the redundant Flutter OIDC flow that was causing Riverpod
"Ref disposed" errors from conflicting auth state updates.

Mobile still uses Flutter's OIDC flow as before.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 15:01:33 +01:00
Jeroen SchweitzerandClaude Opus 4.5 790ae41171 fix(auth): Riverpod lifecycle error + Authentik logout
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m2s
- Fix "Cannot use Ref after disposed" error in OIDC callback page
  - Store notifier reference before async gap
  - Add mounted check at start of processing
- Add proper SSO logout via Authentik end_session_endpoint
  - Clears local tokens AND redirects to Authentik logout
  - Returns to app after Authentik session ends

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 14:50:32 +01:00
Jeroen SchweitzerandClaude Opus 4.5 8625ac6574 fix(build): ensure fresh Flutter build on each deploy
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m1s
- Add flutter clean before build to prevent stale cached artifacts
- Add VERSION build arg for explicit cache busting
- Reorder build steps: clean → pub get → build_runner → health.json → build
- Replace deprecated dart:html with package:web in iframe_view_web.dart
- Add lint ignore to generate_health_json.dart

Fixes issue where Docker layer caching kept old main.dart.js
while regenerating health.json with new version number.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 14:18:01 +01:00
Jeroen SchweitzerandClaude Opus 4.5 16bad327f1 feat(auth): auto-initiate OIDC, remove login page
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m59s
- AppScaffold now handles auth: auto-starts OIDC if not authenticated
- Removed /login route and _LoginPage (no longer needed)
- Shows loading screen during auth, error screen on failure
- Seamless auth when Authentik session already exists

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:52:13 +01:00
Jeroen SchweitzerandClaude Opus 4.5 b35f495537 feat(auth): extract user info from JWT, skip core-api sync
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m59s
- Decode JWT claims (name, email, groups) directly in Flutter
- No longer calls /auth/sync endpoint (avoids CORS preflight issues)
- Bearer token used for subsequent API authentication

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:38:19 +01:00
Jeroen SchweitzerandClaude Opus 4.5 346ca75d68 fix(auth): callback route redirect order + favicon.ico
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m59s
- Move callback route exception check BEFORE auth redirect check
- This was preventing OIDC token exchange from ever happening
- Add favicon.ico to web root for browser tab icon

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:17:03 +01:00
Jeroen SchweitzerandClaude Opus 4.5 0c27c10a2c fix(auth): defer OIDC callback processing to post-frame
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m1s
Wraps _processCallback() in addPostFrameCallback to avoid Riverpod
"Tried to modify a provider while the widget tree was building" error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:03:35 +01:00
Jeroen SchweitzerandClaude Opus 4.5 ff5df30c53 feat(web): switch to path-based URL strategy
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m58s
Remove hash from URLs (/#/login -> /login) using usePathUrlStrategy().
Uses conditional imports to only apply on web, keeping mobile/desktop
builds unaffected.

Required for OIDC callback to work - Authentik redirects to /callback
which Flutter now recognizes as a route.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:55:36 +01:00
Jeroen SchweitzerandClaude Opus 4.5 0a6e9de4a8 fix(auth): persist PKCE state in sessionStorage
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m58s
Store OIDC code_verifier and state in sessionStorage instead of
static memory variables. This fixes the "No code verifier" error
that occurred after Authentik redirect because the Flutter app
restarts and loses in-memory state.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:40:38 +01:00
Jeroen SchweitzerandClaude Opus 4.5 4c377b19c4 chore: release v1.0.6
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m58s
Fix version generation in CI/CD builds

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:15:14 +01:00
Jeroen Schweitzer 8494b4ad7f add version debug print to console on start 2026-01-04 12:15:05 +01:00
Jeroen SchweitzerandClaude Opus 4.5 e596b99e39 chore: stop tracking generated files
- Remove version.g.dart and health.json from git
- These are now regenerated during CI/CD build from pubspec.yaml
- Fixes version mismatch issue in deployments

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:12:10 +01:00
Jeroen SchweitzerandClaude Opus 4.5 9c41a8805d chore: release v1.0.5
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
Web authentication now uses OIDC Authorization Code flow with PKCE
instead of NPM forward auth. Added callback route and web utilities.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:01:42 +01:00
Jeroen SchweitzerandClaude Opus 4.5 45de2591a7 feat(health): add health.json with version info
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m59s
- Generate health.json from pubspec.yaml at build time
- health.html fetches and displays JSON with version, title, status
- Added tool/generate_health_json.dart script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:51:47 +01:00
Jeroen SchweitzerandClaude Opus 4.5 2fe6067085 fix(auth): correct endpoint path /auth/me → /auth/users/me
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:42:07 +01:00
Jeroen SchweitzerandClaude Opus 4.5 ce2dfcd13c fix(build): add production API URLs to Docker build
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m57s
Dockerfile now passes --dart-define flags for CORE_API_URL and
TATLOCK_API_URL pointing to schweitz.net domains. This enables
requiresAuth=true, fixing auth being completely skipped in production.

Also added service port reference table to AGENTS.md.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:19:32 +01:00
Jeroen SchweitzerandClaude Opus 4.5 672f497733 fix(auth): enable cookie credentials for web API requests
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m56s
Configure Dio with BrowserHttpClientAdapter and withCredentials: true
for web platform, allowing session cookies to be sent with XHR requests.
This fixes NPM forward auth not working for API calls.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:56:56 +01:00
Jeroen SchweitzerandClaude Opus 4.5 360c7a8bb3 chore: release v1.0.0
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m57s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:19:34 +01:00
Jeroen SchweitzerandClaude Opus 4.5 a991f5ebed test(auth): add unit tests for auth system
- Add permissions_test.dart (51 tests for Domain, Action, Role)
- Add auth_state_test.dart (16 tests for AuthState)
- Add user_preferences_test.dart (12 tests for UserPreferences)
- Add permission_gate_test.dart (10 tests for permission logic)

All 60 tests passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:15:50 +01:00
Jeroen SchweitzerandClaude Opus 4.5 f1b2b0430f feat(auth): implement dual-flow authentication (web + mobile)
Add complete authentication system supporting both web (NPM forward auth)
and mobile (OIDC) authentication flows.

Web flow:
- Check /auth/me on startup to detect NPM forward auth session
- Cookies handled by proxy, no Bearer tokens needed

Mobile flow:
- flutter_appauth for OIDC Authorization Code + PKCE
- POST /auth/sync to get user profile and roles
- Token storage in SharedPreferences

Shared:
- Permission system with Domain/Action enums and Role class
- PermissionGate and AdminGate widgets for UI permission checks
- Route guards redirecting unauthenticated users to login
- Login page with platform-specific messaging

Platform config:
- iOS: CFBundleURLTypes for net.schweitz.tatlock://
- Android: appAuthRedirectScheme, minSdk 23

Docs:
- Added Freezed 3.x sealed class documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:56:11 +01:00
Jeroen SchweitzerandClaude Opus 4.5 806b0a98c8 ci: trigger build on version tag push with auto-release
Changed workflow to:
- Trigger on push of v* tags instead of release publish
- Auto-create Gitea release via API
- Then build and push Docker image

This simplifies deployment: just push a version tag.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:40:56 +01:00
Jeroen SchweitzerandClaude Opus 4.5 6c271489a9 refactor: add shared StoplightColors for consistent status colors
- Add StoplightColors class with green/orange/red pastel colors
- Update dashboard gauges to use StoplightColors.forPercent()
- Update air quality levels to use shared stoplight colors
- Provides consistent color scheme for all threshold-based indicators

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:33:42 +01:00
Jeroen SchweitzerandClaude Opus 4.5 67fed18cb0 feat(dashboard): wire system stats to Core API with live gauges
Build and Push / build (release) Successful in 2m59s
- Add SystemStats freezed models matching Core API response
- Add systemStatsProvider to fetch stats from /tools/system/stats
- Update dashboard to display CPU, RAM, VRAM, and disk usage gauges
- Implement color-coded gauges: green ≤50%, orange 51-75%, red >75%
- Add auto-refresh every 30 seconds
- Improve gauge widget: speedometer style, icons below labels
- Update weather widget to match air quality vertical layout

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:24:04 +01:00
134 changed files with 13872 additions and 818 deletions
+74
View File
@@ -0,0 +1,74 @@
{
"env": {
"PQL_VAULT": "/mnt/media/Projects/tatlock-ui"
},
"permissions": {
"allow": [
"Bash(pql)",
"Bash(pql *)",
"Bash(/home/jpmschweitzer/.local/bin/pql:*)",
"Bash(git status:*)",
"Bash(git log:*)",
"Bash(git diff:*)",
"Bash(git branch:*)",
"Bash(flutter test:*)",
"Bash(flutter analyze:*)",
"Bash(flutter pub get:*)",
"Bash(flutter pub outdated:*)",
"Bash(flutter build web:*)",
"Bash(dart analyze:*)",
"Bash(dart format:*)",
"Bash(docker logs tatlock-ui:*)",
"Bash(curl -sI http://localhost:9999/*)",
"mcp__chrome-devtools__take_screenshot",
"mcp__puppeteer__puppeteer_evaluate",
"mcp__chrome-devtools__navigate_page",
"mcp__chrome-devtools__take_snapshot"
],
"deny": [
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj)",
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj:*)",
"Bash(chmod -R 777 *)",
"Bash(chmod 777 *)",
"Bash(dart pub publish*)",
"Bash(dd if=*)",
"Bash(find * -delete*)",
"Bash(find * -exec*)",
"Bash(flutter pub publish*)",
"Bash(git * add --all*)",
"Bash(git * add -A*)",
"Bash(git * add .)",
"Bash(git * branch -D *)",
"Bash(git * checkout -- *)",
"Bash(git * clean -fd*)",
"Bash(git * clean -fdx*)",
"Bash(git * commit --no-verify*)",
"Bash(git * merge --no-ff*)",
"Bash(git * push --force*)",
"Bash(git * push -f*)",
"Bash(git * reset --hard*)",
"Bash(git * restore .*)",
"Bash(git add --all*)",
"Bash(git add -A*)",
"Bash(git add .)",
"Bash(git branch -D *)",
"Bash(git checkout -- *)",
"Bash(git clean -fd*)",
"Bash(git clean -fdx*)",
"Bash(git commit --no-verify*)",
"Bash(git merge --no-ff*)",
"Bash(git push --force*)",
"Bash(git push -f*)",
"Bash(git reset --hard*)",
"Bash(git restore .*)",
"Bash(mkfs*)",
"Bash(rm -rf $HOME)",
"Bash(rm -rf /)",
"Bash(rm -rf ~)",
"Bash(su *)",
"Bash(sudo *)",
"Bash(toj)",
"Bash(toj:*)"
]
}
}
-10
View File
@@ -1,10 +0,0 @@
{
"permissions": {
"allow": [
"mcp__chrome-devtools__take_screenshot",
"mcp__puppeteer__puppeteer_evaluate",
"mcp__chrome-devtools__navigate_page",
"mcp__chrome-devtools__take_snapshot"
]
}
}
+1
View File
@@ -0,0 +1 @@
.pql/changelog/*.sql merge=union
+20 -8
View File
@@ -1,20 +1,32 @@
name: Build and Push
on:
release:
types: [published]
push:
tags:
- 'v*'
jobs:
build:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create Gitea Release
run: |
curl -sf -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"tag_name": "${{ github.ref_name }}", "name": "Release ${{ github.ref_name }}", "body": "Automated release for ${{ github.ref_name }}"}' \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
build:
runs-on: ubuntu-latest
needs: release
steps:
- uses: actions/checkout@v4
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.schweitz.internal
registry: git.schweitz.net
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -24,8 +36,8 @@ jobs:
context: .
push: true
tags: |
git.schweitz.internal/jpmschweitzer/tatlock-ui:latest
git.schweitz.internal/jpmschweitzer/tatlock-ui:${{ github.ref_name }}
git.schweitz.net/jpmschweitzer/tatlock-ui:latest
git.schweitz.net/jpmschweitzer/tatlock-ui:${{ github.ref_name }}
- name: Trigger Watchtower update
if: success()
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Trigger only. The checks live in the Makefile, where they can be read, run by
# hand (`make pre-push`), and changed under review.
#
# This file is identical in every repo in this workspace, deliberately: the call
# surface is the same everywhere even though what each gate runs is not, so
# nobody has to read a repo to find out how to check it (D-27).
#
# Enable per clone with: git config core.hooksPath .githooks
# Never bypass with --no-verify. Suppress a specific finding deliberately
# instead, with a reason — see `make pre-push`.
set -euo pipefail
exec make -C "$(git rev-parse --show-toplevel)" pre-push
+29 -4
View File
@@ -13,11 +13,12 @@ pubspec.lock
*.gr.dart
*.mocks.dart
# Keep version.g.dart - it's generated but should be committed
# so CI/CD builds have version info without running the generator
# Other *.g.dart files (from json_serializable, etc.) are ignored
# All generated *.g.dart files (from json_serializable, riverpod, version_builder)
# These are regenerated by build_runner during CI/CD builds
lib/**/*.g.dart
!lib/version.g.dart
# Generated health.json (regenerated by tool/generate_health_json.dart during build)
web/health.json
# IDE
.idea/
@@ -120,3 +121,27 @@ secrets/
uploads/
logs/
.vscode/launch.json
# Claude Code user-specific settings.
#
# WARNING: this file is currently TRACKED, so this rule does NOTHING yet. Git
# applies ignore rules only to untracked paths; edits to a tracked file still
# show in status and still get committed. It takes effect only after
# git rm --cached .claude/settings.local.json
# which is a history decision, deliberately left out of the 2026-08-09
# normalization pass. Contents are benign - a 4-entry permission allow list, no
# env block, no secrets - so this is hygiene, not an incident.
#
# Also: plain `git check-ignore` prints nothing for this path because it consults
# the index. Use `git check-ignore --no-index` to confirm the rule itself matches.
.claude/settings.local.json
.pql/*
!.pql/changelog/
# pql shims planted by `pql init` into the dir core.hooksPath points at.
# Per-clone: each embeds the absolute path of the pql binary that planted it.
# Only .githooks/pre-push is shared.
.githooks/pre-commit
.githooks/post-merge
.githooks/post-checkout
.githooks/post-rewrite
+11
View File
@@ -0,0 +1,11 @@
-- Changelog format marker, written by pql. Comments only: this file
-- is never executed — Import descends into the per-table directories
-- and does not read the changelog root.
--
-- A changelog carrying no marker is format 1, the shape that existed
-- before formats were versioned. An older format is migrated forward
-- by `pql plan upgrade` (and automatically from the post-merge hook);
-- a newer one is refused rather than replayed under rules this binary
-- does not know. See D-28 and docs/versions.md.
-- pql:changelog_format: 2.0.0
-- pql:written_by: 2.2.0
+139
View File
@@ -0,0 +1,139 @@
-- Auto-generated by pql init. CREATE TABLE statements
-- for the planning schema; per-table dir keeps the changelog
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
-- idempotent so running schema files from each directory in
-- replay order is harmless.
--
-- Importer parses the markers below to detect schema drift
-- between the producing pql version and the local one — a
-- bumped canonical_version means projection rules changed
-- and replay must refuse rather than silently corrupt state.
-- pql:created_by: 2.2.0
-- pql:canonical_version: 2
CREATE TABLE IF NOT EXISTS decisions (
id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
domain TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active','superseded','resolved','open')),
date TEXT,
file_path TEXT NOT NULL,
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS decision_refs (
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
ref_type TEXT NOT NULL
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (source_id, target_id, ref_type)
);
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
-- reference (parent, deps, history, labels) targets record_id, so a label
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
CREATE TABLE IF NOT EXISTS tickets (
record_id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
parent_record_id TEXT REFERENCES tickets(record_id),
title TEXT NOT NULL,
description TEXT,
-- No CHECK enumeration: the ticket status vocabulary is per-vault
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
-- always inserts the configured default explicitly.
status TEXT NOT NULL DEFAULT 'backlog',
priority TEXT DEFAULT 'medium'
CHECK(priority IN ('critical','high','medium','low')),
assigned_to TEXT,
team TEXT,
decision_ref TEXT REFERENCES decisions(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
-- can mint the same label, which surfaces as a duplicate-label collision
-- (detected at replay) and is fixed with "pql ticket relabel".
CREATE TABLE IF NOT EXISTS ticket_idmap (
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
ticket_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_deps (
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (blocker_record_id, blocked_record_id)
);
CREATE TABLE IF NOT EXISTS ticket_history (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by TEXT,
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT UNIQUE,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_labels (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
label TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (ticket_record_id, label)
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
@@ -0,0 +1,139 @@
-- Auto-generated by pql init. CREATE TABLE statements
-- for the planning schema; per-table dir keeps the changelog
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
-- idempotent so running schema files from each directory in
-- replay order is harmless.
--
-- Importer parses the markers below to detect schema drift
-- between the producing pql version and the local one — a
-- bumped canonical_version means projection rules changed
-- and replay must refuse rather than silently corrupt state.
-- pql:created_by: 2.2.0
-- pql:canonical_version: 2
CREATE TABLE IF NOT EXISTS decisions (
id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
domain TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active','superseded','resolved','open')),
date TEXT,
file_path TEXT NOT NULL,
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS decision_refs (
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
ref_type TEXT NOT NULL
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (source_id, target_id, ref_type)
);
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
-- reference (parent, deps, history, labels) targets record_id, so a label
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
CREATE TABLE IF NOT EXISTS tickets (
record_id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
parent_record_id TEXT REFERENCES tickets(record_id),
title TEXT NOT NULL,
description TEXT,
-- No CHECK enumeration: the ticket status vocabulary is per-vault
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
-- always inserts the configured default explicitly.
status TEXT NOT NULL DEFAULT 'backlog',
priority TEXT DEFAULT 'medium'
CHECK(priority IN ('critical','high','medium','low')),
assigned_to TEXT,
team TEXT,
decision_ref TEXT REFERENCES decisions(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
-- can mint the same label, which surfaces as a duplicate-label collision
-- (detected at replay) and is fixed with "pql ticket relabel".
CREATE TABLE IF NOT EXISTS ticket_idmap (
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
ticket_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_deps (
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (blocker_record_id, blocked_record_id)
);
CREATE TABLE IF NOT EXISTS ticket_history (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by TEXT,
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT UNIQUE,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_labels (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
label TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (ticket_record_id, label)
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
+24
View File
@@ -0,0 +1,24 @@
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ4HDS0AGMK8R3WS0RY7BMJW', 'description', NULL, '`.claude/settings.local.json` is committed to this repo. It is the per-clone local overrides file — the documented home for personal settings and for credentials — and it does not belong in version control at all.
STATE, verified 2026-08-11 rather than assumed:
- the file is tracked, with 2 commits touching it (both 2025-12-31)
- `.gitignore` already lists it at line 137, and line 130 carries the exact `git rm --cached` command in a comment
- gitignore does not apply to files git already tracks, which is why the rule has been inert since it was added
- this is the ONLY repo in the workspace where the file is tracked; desklock, library-desk and tatlock all ignore it correctly
NO CREDENTIALS WERE EVER COMMITTED. Both committed versions contain a `permissions` section and nothing else — zero env keys, checked at each commit rather than only at HEAD. This is a loaded trap, not a leak: the next person who adds an env key to this file commits a secret, and nothing will stop them because the ignore rule cannot fire on a tracked path.
Worth recording because it has already cost time once: a survey agent previously reported credentials committed across nine repos. Traced to source, it was this one file holding a four-entry permission list and no secrets. The finding was wrong and the file is still tracked, so the same false alarm is available to the next person who greps for it.
THE FOUR RULES IN IT ARE THE ONLY REASON TO PAUSE. They allow browser-automation MCP tools:
mcp__chrome-devtools__take_screenshot, take_snapshot, navigate_page
mcp__puppeteer__puppeteer_evaluate
Untracking removes them from anyone who clones. Two ways to go, and this wants deciding rather than defaulting:
- they are genuinely personal (one person''s browser tooling) — untrack and let each clone re-grant
- they are useful to anyone working on this UI — move them into `.claude/settings.json`, which is committed by design and already carries 16 allow and 43 deny rules
The second reading looks likelier for a front-end repo where visual verification is routine, but it is a judgement about how people work here, not something the file can answer.
FIX: `git rm --cached .claude/settings.local.json` and commit. The file stays on disk, so nobody loses their local settings; it simply stops being shared. Decide the four rules first, or they vanish quietly.', NULL, '2026-08-11 19:21:23', '2026-08-11 19:21:23.594', '2026-08-11 19:21:23.594', NULL, '6ae54cdfcaa87232e3074a23b88d1b82', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ4HDS0AGMK8R3WS0RY7BMJW', 'status', 'backlog', 'done', NULL, '2026-08-11 19:27:15', '2026-08-11 19:27:15.269', '2026-08-11 19:27:15.269', NULL, 'e7784de0ed56f0679d0823a163b008bc', 2) ON CONFLICT(hash) DO NOTHING;
+139
View File
@@ -0,0 +1,139 @@
-- Auto-generated by pql init. CREATE TABLE statements
-- for the planning schema; per-table dir keeps the changelog
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
-- idempotent so running schema files from each directory in
-- replay order is harmless.
--
-- Importer parses the markers below to detect schema drift
-- between the producing pql version and the local one — a
-- bumped canonical_version means projection rules changed
-- and replay must refuse rather than silently corrupt state.
-- pql:created_by: 2.2.0
-- pql:canonical_version: 2
CREATE TABLE IF NOT EXISTS decisions (
id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
domain TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active','superseded','resolved','open')),
date TEXT,
file_path TEXT NOT NULL,
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS decision_refs (
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
ref_type TEXT NOT NULL
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (source_id, target_id, ref_type)
);
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
-- reference (parent, deps, history, labels) targets record_id, so a label
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
CREATE TABLE IF NOT EXISTS tickets (
record_id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
parent_record_id TEXT REFERENCES tickets(record_id),
title TEXT NOT NULL,
description TEXT,
-- No CHECK enumeration: the ticket status vocabulary is per-vault
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
-- always inserts the configured default explicitly.
status TEXT NOT NULL DEFAULT 'backlog',
priority TEXT DEFAULT 'medium'
CHECK(priority IN ('critical','high','medium','low')),
assigned_to TEXT,
team TEXT,
decision_ref TEXT REFERENCES decisions(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
-- can mint the same label, which surfaces as a duplicate-label collision
-- (detected at replay) and is fixed with "pql ticket relabel".
CREATE TABLE IF NOT EXISTS ticket_idmap (
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
ticket_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_deps (
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (blocker_record_id, blocked_record_id)
);
CREATE TABLE IF NOT EXISTS ticket_history (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by TEXT,
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT UNIQUE,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_labels (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
label TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (ticket_record_id, label)
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
+1
View File
@@ -0,0 +1 @@
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ4HDS0AGMK8R3WS0RY7BMJW', 'T-1', '2026-08-11 19:21:23.466', '2026-08-11 19:21:23.466', NULL, '953ce74627a5dd3cc3f2c4e56e6fc6aa', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
@@ -0,0 +1,139 @@
-- Auto-generated by pql init. CREATE TABLE statements
-- for the planning schema; per-table dir keeps the changelog
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
-- idempotent so running schema files from each directory in
-- replay order is harmless.
--
-- Importer parses the markers below to detect schema drift
-- between the producing pql version and the local one — a
-- bumped canonical_version means projection rules changed
-- and replay must refuse rather than silently corrupt state.
-- pql:created_by: 2.2.0
-- pql:canonical_version: 2
CREATE TABLE IF NOT EXISTS decisions (
id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
domain TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active','superseded','resolved','open')),
date TEXT,
file_path TEXT NOT NULL,
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS decision_refs (
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
ref_type TEXT NOT NULL
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (source_id, target_id, ref_type)
);
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
-- reference (parent, deps, history, labels) targets record_id, so a label
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
CREATE TABLE IF NOT EXISTS tickets (
record_id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
parent_record_id TEXT REFERENCES tickets(record_id),
title TEXT NOT NULL,
description TEXT,
-- No CHECK enumeration: the ticket status vocabulary is per-vault
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
-- always inserts the configured default explicitly.
status TEXT NOT NULL DEFAULT 'backlog',
priority TEXT DEFAULT 'medium'
CHECK(priority IN ('critical','high','medium','low')),
assigned_to TEXT,
team TEXT,
decision_ref TEXT REFERENCES decisions(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
-- can mint the same label, which surfaces as a duplicate-label collision
-- (detected at replay) and is fixed with "pql ticket relabel".
CREATE TABLE IF NOT EXISTS ticket_idmap (
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
ticket_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_deps (
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (blocker_record_id, blocked_record_id)
);
CREATE TABLE IF NOT EXISTS ticket_history (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by TEXT,
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT UNIQUE,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_labels (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
label TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (ticket_record_id, label)
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
+139
View File
@@ -0,0 +1,139 @@
-- Auto-generated by pql init. CREATE TABLE statements
-- for the planning schema; per-table dir keeps the changelog
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
-- idempotent so running schema files from each directory in
-- replay order is harmless.
--
-- Importer parses the markers below to detect schema drift
-- between the producing pql version and the local one — a
-- bumped canonical_version means projection rules changed
-- and replay must refuse rather than silently corrupt state.
-- pql:created_by: 2.2.0
-- pql:canonical_version: 2
CREATE TABLE IF NOT EXISTS decisions (
id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
domain TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active','superseded','resolved','open')),
date TEXT,
file_path TEXT NOT NULL,
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS decision_refs (
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
ref_type TEXT NOT NULL
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (source_id, target_id, ref_type)
);
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
-- reference (parent, deps, history, labels) targets record_id, so a label
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
CREATE TABLE IF NOT EXISTS tickets (
record_id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
parent_record_id TEXT REFERENCES tickets(record_id),
title TEXT NOT NULL,
description TEXT,
-- No CHECK enumeration: the ticket status vocabulary is per-vault
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
-- always inserts the configured default explicitly.
status TEXT NOT NULL DEFAULT 'backlog',
priority TEXT DEFAULT 'medium'
CHECK(priority IN ('critical','high','medium','low')),
assigned_to TEXT,
team TEXT,
decision_ref TEXT REFERENCES decisions(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
-- can mint the same label, which surfaces as a duplicate-label collision
-- (detected at replay) and is fixed with "pql ticket relabel".
CREATE TABLE IF NOT EXISTS ticket_idmap (
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
ticket_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_deps (
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (blocker_record_id, blocked_record_id)
);
CREATE TABLE IF NOT EXISTS ticket_history (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by TEXT,
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT UNIQUE,
canonical_version INTEGER
);
CREATE TABLE IF NOT EXISTS ticket_labels (
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
label TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
hash TEXT,
canonical_version INTEGER,
PRIMARY KEY (ticket_record_id, label)
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
+47
View File
@@ -0,0 +1,47 @@
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ4HDS0AGMK8R3WS0RY7BMJW', 'bug', NULL, '.claude/settings.local.json is tracked in git and should not be', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-08-11 19:21:23.458', '2026-08-11 19:21:23.458', NULL, 'b68f6240b59c932e1b81744ce2e9f054', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ4HDS0AGMK8R3WS0RY7BMJW', 'bug', NULL, '.claude/settings.local.json is tracked in git and should not be', '`.claude/settings.local.json` is committed to this repo. It is the per-clone local overrides file — the documented home for personal settings and for credentials — and it does not belong in version control at all.
STATE, verified 2026-08-11 rather than assumed:
- the file is tracked, with 2 commits touching it (both 2025-12-31)
- `.gitignore` already lists it at line 137, and line 130 carries the exact `git rm --cached` command in a comment
- gitignore does not apply to files git already tracks, which is why the rule has been inert since it was added
- this is the ONLY repo in the workspace where the file is tracked; desklock, library-desk and tatlock all ignore it correctly
NO CREDENTIALS WERE EVER COMMITTED. Both committed versions contain a `permissions` section and nothing else — zero env keys, checked at each commit rather than only at HEAD. This is a loaded trap, not a leak: the next person who adds an env key to this file commits a secret, and nothing will stop them because the ignore rule cannot fire on a tracked path.
Worth recording because it has already cost time once: a survey agent previously reported credentials committed across nine repos. Traced to source, it was this one file holding a four-entry permission list and no secrets. The finding was wrong and the file is still tracked, so the same false alarm is available to the next person who greps for it.
THE FOUR RULES IN IT ARE THE ONLY REASON TO PAUSE. They allow browser-automation MCP tools:
mcp__chrome-devtools__take_screenshot, take_snapshot, navigate_page
mcp__puppeteer__puppeteer_evaluate
Untracking removes them from anyone who clones. Two ways to go, and this wants deciding rather than defaulting:
- they are genuinely personal (one person''s browser tooling) — untrack and let each clone re-grant
- they are useful to anyone working on this UI — move them into `.claude/settings.json`, which is committed by design and already carries 16 allow and 43 deny rules
The second reading looks likelier for a front-end repo where visual verification is routine, but it is a judgement about how people work here, not something the file can answer.
FIX: `git rm --cached .claude/settings.local.json` and commit. The file stays on disk, so nobody loses their local settings; it simply stops being shared. Decide the four rules first, or they vanish quietly.', 'backlog', 'high', NULL, NULL, NULL, '2026-08-11 19:21:23.458', '2026-08-11 19:21:23.593', NULL, 'dad1d9ab6415c597def74a213e0d914e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ4HDS0AGMK8R3WS0RY7BMJW', 'bug', NULL, '.claude/settings.local.json is tracked in git and should not be', '`.claude/settings.local.json` is committed to this repo. It is the per-clone local overrides file — the documented home for personal settings and for credentials — and it does not belong in version control at all.
STATE, verified 2026-08-11 rather than assumed:
- the file is tracked, with 2 commits touching it (both 2025-12-31)
- `.gitignore` already lists it at line 137, and line 130 carries the exact `git rm --cached` command in a comment
- gitignore does not apply to files git already tracks, which is why the rule has been inert since it was added
- this is the ONLY repo in the workspace where the file is tracked; desklock, library-desk and tatlock all ignore it correctly
NO CREDENTIALS WERE EVER COMMITTED. Both committed versions contain a `permissions` section and nothing else — zero env keys, checked at each commit rather than only at HEAD. This is a loaded trap, not a leak: the next person who adds an env key to this file commits a secret, and nothing will stop them because the ignore rule cannot fire on a tracked path.
Worth recording because it has already cost time once: a survey agent previously reported credentials committed across nine repos. Traced to source, it was this one file holding a four-entry permission list and no secrets. The finding was wrong and the file is still tracked, so the same false alarm is available to the next person who greps for it.
THE FOUR RULES IN IT ARE THE ONLY REASON TO PAUSE. They allow browser-automation MCP tools:
mcp__chrome-devtools__take_screenshot, take_snapshot, navigate_page
mcp__puppeteer__puppeteer_evaluate
Untracking removes them from anyone who clones. Two ways to go, and this wants deciding rather than defaulting:
- they are genuinely personal (one person''s browser tooling) — untrack and let each clone re-grant
- they are useful to anyone working on this UI — move them into `.claude/settings.json`, which is committed by design and already carries 16 allow and 43 deny rules
The second reading looks likelier for a front-end repo where visual verification is routine, but it is a judgement about how people work here, not something the file can answer.
FIX: `git rm --cached .claude/settings.local.json` and commit. The file stays on disk, so nobody loses their local settings; it simply stops being shared. Decide the four rules first, or they vanish quietly.', 'done', 'high', NULL, NULL, NULL, '2026-08-11 19:21:23.458', '2026-08-11 19:27:15.269', NULL, 'be5da7217e2daee70cd57471ffd934fc', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
-92
View File
@@ -1,92 +0,0 @@
# LLM Agent Instructions
This document contains instructions and documentation references for AI assistants working with this codebase.
> **📖 Important**: Before working on this project, read [PHILOSOPHY.md](PHILOSOPHY.md) to understand the system vision, architectural patterns, and design goals. All development should work towards realizing those patterns.
# AGENTS.md
> **Start every session by reading this file.**
> This file outlines the operational protocols, coding standards, and architectural decisions for this Flutter project.
## 1. Agent Operational Protocols
### 🧠 Work Patterns (Plan-Act-Reflect)
* **Plan:** Before writing code, briefly outline your plan. Identify which files you will touch and what the side effects might be.
* **Act:** Execute the changes in small, atomic steps.
* **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests?
### 🌐 Internal Service Access
* **git.schweitz.net**: Access via `http://localhost:3002` (direct Gitea) to bypass Authentik SSO
* Example: `curl http://localhost:3002/jpmschweitzer/library-desk/raw/branch/main/README.md`
* Public repos are readable without authentication
* Related repos: , `core-api`, `tatlock`, `library-desk`, `scheduler`, `portainer-core`
### 🐳 Deployment & Infrastructure
* **Full stack documentation**: Available in the `portainer-core` repo
* Access: `curl http://localhost:3002/jpmschweitzer/portainer-core/raw/branch/main/CONTAINERS.md`
* Contains: All service ports, URLs, Redis DB allocations, external domains
* **Tatlock deployment**:
* LAN: `http://192.168.86.149:8000`
* External: `tatlock.schweitz.net` (behind Authentik SSO)
* Redis DBs: 1 (memory), 6 (benchmarks)
* **Health check**: `curl http://192.168.86.149:8000/health`
### 🛡️ Git Discipline
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
* `feat: add user login endpoint`
* `fix: resolve database connection timeout`
* `refactor: split monolith dependency file`
* **Atomic Commits:** Keep commits small. One logical change = one commit.
* **Version Tagging:** Every version increment (major.minor.patch, not build count) must have a corresponding git tag.
* Format: `v{major}.{minor}.{patch}` (e.g., `v0.3.0`)
* Tag after updating `pubspec.yaml` version and CHANGELOG
* Push tags with `git push --tags`
### 🚀 Release Procedure
This project uses version-tag-based CI/CD. Releases trigger automated Docker builds and deployments.
**Release Steps:**
1. Update version in `pubspec.yaml` (bump major.minor.patch, not build number)
2. Update `CHANGELOG.md` with changes under `## [x.x.x] - YYYY-MM-DD`
3. Commit changes: `git commit -m "chore: release vX.X.X"`
4. Create git tag: `git tag vX.X.X`
5. Push with tags: `git push origin master --tags`
6. Create release in Gitea UI (git.schweitz.net → Releases → New Release)
* Select the tag
* Add release notes (can copy from CHANGELOG)
* **Publish** the release (this triggers CI/CD)
**What happens on release:**
* Gitea CI builds Flutter web app in Docker
* Image pushed to `git.schweitz.internal/jpmschweitzer/tatlock-ui:latest` and `:vX.X.X`
* Watchtower detects new image and auto-updates running container
* App available at `http://tower:8092` (and eventually `home.schweitz.net`)
**Rollback:**
* In Portainer, update image tag to previous version (e.g., `:v0.2.0`)
* Or: `docker pull git.schweitz.internal/jpmschweitzer/tatlock-ui:v0.2.0`
### 🧪 Testing Requirements
* **Always add tests for new code before committing.** No exceptions.
* Tests should cover the happy path and key edge cases.
* Run `flutter test` before committing to ensure all tests pass.
* For widgets: use widget tests. For business logic: use unit tests.
* Code coverage should not decrease with new commits.
### 📝 Changelog Maintenance
* **Update `CHANGELOG.md`** with every user-facing change.
* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`.
### 🎨 UI Patterns (MUST READ BEFORE CHANGES)
* **Before modifying the widget tree**, read `docs/UI_LAYOUT.md` to understand established patterns.
* Investigate existing implementations in the codebase before creating new components.
* **DO NOT** reinvent wheels - check if shared components already exist in `lib/shared/components/`.
* Look at similar features for reference patterns (e.g., how other list views, forms, or CRUD screens are built).
* Deviating from established patterns creates inconsistency and technical debt.
+475
View File
@@ -7,6 +7,481 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.7.1] - 2026-07-19
### Fixed
- Web build failure with dio >= 5.8: `DioExceptionType` switch is now
exhaustive across dio versions (CI resolves dependencies fresh)
## [1.7.0] - 2026-07-19
### Changed
- Default Core API and Tatlock API URLs now use the public domains
`https://api.schweitz.net` and `https://tatlock.schweitz.net` (previously LAN
`http://192.168.86.149:8083`/`:8000`), so default builds require OIDC auth;
override via `--dart-define` for direct LAN development
### Removed
- Netdata and Cloud IDE (code-server) quick links — both services are
decommissioned; also dropped the unused `portainerUrl`/`netdataUrl`
config constants
## [1.6.0] - 2026-01-08
### Added
- **News Ticker Widget** - Scrolling news headlines on dashboard
- Full-width ticker between Welcome card and System Stats
- Horizontal auto-scrolling at 40px/second with seamless looping
- Fetches headlines from `/tools/news` endpoint
- Placeholder headlines shown when no data (italic, muted style)
- Auto-refresh every 30 minutes
- News data model (`NewsData`, `NewsHeadline`) with Freezed
- News datasource calling `GET /tools/news`
- News provider with `hasNews` helper
## [1.5.9] - 2026-01-08
### Changed
- **Aligned horizon line across all widgets** - Consistent visual baseline at 50px from bottom
- Weather and Air Quality dividers now align with Sun Position horizon line
- Forecast card bottoms align with the same horizon
- Creates unified visual rhythm across all environment cards
## [1.5.8] - 2026-01-08
### Changed
- **Bottom-aligned widget content** - All environment widgets now align content from the bottom
- Creates consistent visual baseline across Sun Position, Weather, Air Quality, and Forecast cards
- Footers (weather details, pollutants) sit at the same level across cards
### Added
- **Wind direction in Weather** - Wind chip now shows direction (e.g., "SE 14 km/h")
## [1.5.7] - 2026-01-08
### Fixed
- **Environment widget alignment** - Consistent card heights across all environment widgets
- Added `ConstrainedBox(minHeight: 170)` to Weather, Air Quality, and Forecast widgets
- All cards now match Sun Position widget height when displaying data
- **Sun position night labels** - Swap sunrise/sunset labels at night
- During day: Sunrise on left, Sunset on right (day arc)
- At night: Sunset on left, Sunrise on right (night arc)
### Changed
- **Weather widget header** - Changed from location name to "Weather" for consistency
- Location now displayed in content area below temperature
## [1.5.6] - 2026-01-07
### Changed
- **Environment user logging** - Now logs API user only once per session instead of on every refresh
## [1.5.5] - 2026-01-07
### Fixed
- **Sun position arc overflow** - Arc now constrained to fit within card boundaries
- Prevents arc and sun/moon from overflowing on wider displays
- Scales radius down when arc height exceeds available space
### Added
- **Debug logging for environment user** - Logs authenticated user on environment data load
## [1.5.4] - 2026-01-07
### Added
- User display in environment section header (reverted in 1.5.5)
## [1.5.3] - 2026-01-07
### Changed
- **Sun position arc redesign** - Arc now touches horizon at sunrise/sunset points
- Arc geometry uses chord-radius calculation for proper horizon intersection
- Sunrise/sunset icons integrated at horizon endpoints (no duplicate labels)
- Daylight duration centered below arc
- Increased card height 20% for better arc visibility (140px → 170px)
- **Visual balance improvements** - Sunrise/sunset labels raised 10px, daylight label at bottom
## [1.5.2] - 2026-01-07
### Changed
- **Proportional sun/moon arc visualization** - Arc angle now proportional to day/night duration
- Day arc: spans (daylight hours / 24) × 360° above horizon
- Night arc: spans (night hours / 24) × 360° with moon traversal
- Horizon points represent sunrise/sunset times
- Gradient colors: yellow/orange for day, blue/indigo for night
- **Sun position widget now updates every 10 minutes** - Position aligned to clock intervals (0/10/20/30/40/50)
- Converted to StatefulWidget with timer-based updates
- Shows default 7am/5pm sunrise/sunset when API data unavailable (asymmetric for visual effect)
- Always displays times and daylight duration (no more "--:--")
- **Environment widgets consistent height** - All cards maintain same height in "no data" state
## [1.5.1] - 2026-01-07
### Changed
- **Environment widgets layout redesign** - All 4 widgets always visible with responsive layout
- Desktop (>900px): 4-in-a-row with 30/20/20/30 width distribution (Sun | Weather | Air Quality | Forecast)
- Tablet (600-900px): 2x2 grid layout
- Mobile (<600px): Stacked vertically
- Widgets now show "No data available" state instead of being hidden or using mock data
- Sun position widget now calculates position from system clock
- Defaults to 6am/6pm (12-hour day/night cycles) when API sun times unavailable
- Environment data refresh interval changed from 5 minutes to 1 hour
## [1.5.0] - 2026-01-06
### Added
- **Dynamic Environment Widgets** - Live weather, sun position, and forecast data from Qdrant
- `SunPositionWidget` - Animated semicircle arc showing sun/moon position based on current time
- Gradient colors: yellow for daytime, orange for sunrise/sunset, blue for night
- Displays sunrise, sunset times and daylight duration
- `WeatherWidget` - Current temperature, conditions, humidity from API
- `ForecastWidget` - Multi-day weather forecast with conditions icons
- `AirQualityWidget` - AQI display (only shown when data available)
- Environment data provider with auto-refresh every 5 minutes
- Environment datasource calling `GET /tools/environment`
- Freezed models for environment data (weather, forecast, sun times, air quality)
- Comprehensive widget tests for environment section
### Changed
- Dashboard layout now displays dynamic environment data instead of static widgets
- Weather and air quality widgets accept optional API data parameters
## [1.4.0] - 2026-01-05
### Added
- **Decentralized Room Registry** - Each room registers itself with central registry
- `RoomDefinition` class with id, label, icons, routes, and permissions
- `RoomRegistry` singleton for managing all rooms
- Dynamic navigation tabs built from registry
- Settings dropdown builds from available rooms
- Permission-based room filtering support
- **Media Room** - New placeholder room for future media management features
- **Parlor** - Now a proper feature folder with router registration
### Changed
- Room navigation is now fully dynamic via registry
- `top_header_bar.dart` uses `roomRegistry.all` instead of hardcoded list
- `app_scaffold.dart` uses registry for route matching and navigation
- Settings page dropdown populated from `roomRegistry.all`
- Routers moved to per-room pattern:
- `lib/features/front_hall/router.dart` (new)
- `lib/features/parlor/router.dart` (new)
- `lib/features/media_room/router.dart` (new)
- Existing `control_room/router.dart` and `security/router.dart` now register with registry
- Documentation updated in ARCHITECTURE.md with Room Registry Pattern section
### Removed
- Hardcoded room lists in `top_header_bar.dart` and `app_scaffold.dart`
- `_PlaceholderPage` widget in `app_router.dart` (each room has its own page)
## [1.3.1] - 2026-01-05
### Fixed
- Default room preference now applies on app load
- Root `/` redirects to user's preferred default room
- Front Hall moved to `/front-hall` route (was `/`)
## [1.3.0] - 2026-01-05
### Added
- **URL deep-linking for DataGrids** - State is now reflected in URL query parameters
- `?search=` - DataGrid search query
- `?sort=` - Column ID for sorting
- `?order=desc` - Sort direction
- `?id=` - Opened document ID (proxy hosts page)
- `PageUrlState` utility class (`lib/routing/url_state.dart`) for URL ↔ state serialization
- Browser URL updates via `replaceState` without triggering GoRouter rebuilds
- `id` field added to `DataGridColumn` for unique column identification in URLs
- `FilterPanelSemantics` class for filter panel semantic IDs
- TESTING.md documentation for semantic widgets and automation testing
### Changed
- `idSelector` in DataGridController now returns `String` (was `Object`) for URL compatibility
- All DataGrid pages now support URL deep-linking:
- Containers list (`/control-room/containers`)
- Proxy hosts (`/control-room/proxy-hosts`)
- Users (`/security/users`)
- Groups (`/security/groups`)
- Router passes `GoRouterState` to pages for query parameter access
## [1.2.0] - 2026-01-05
### Added
- **Semantic labels for UI automation** (`lib/core/semantics/`)
- `semantic_ids.dart` - Centralized semantic identifier constants
- `semantic_widget.dart` - Helper widget and extension for adding semantics
- Enables browser automation tools (Puppeteer, WebDriver) via accessibility tree
- Semantic IDs added to:
- Profile dropdown button and menu items (theme options, settings, logout)
- Room navigation tabs (Front Hall, Control Room, Security, Parlor)
- NavPanel items (sidebar navigation)
- `SemanticsBinding.instance.ensureSemantics()` enabled on web builds
## [1.1.16] - 2026-01-05
### Fixed
- Theme toggle causing logout due to AuthInterceptor auto-signout on 401
- Removed aggressive `signOut()` call in `AuthInterceptor.onError`
- 401 errors now propagate to calling code for graceful handling
- Preferences API 401 no longer triggers full logout redirect
## [1.1.15] - 2026-01-05
### Fixed
- Theme toggle causing auth issues due to AuthNotifier auto-dispose
- Applied `@persistentRiverpod` annotation to AuthNotifier
- AuthProvider now persists for app lifetime, preventing rebuild on theme change
## [1.1.14] - 2026-01-04
### Fixed
- Theme toggle causing auth issues due to ThemeProvider auto-dispose
- Added `@persistentRiverpod` annotation for providers that need keepAlive
- ThemeProvider now persists for app lifetime
### Added
- `@persistentRiverpod` annotation in `core/providers/annotations.dart`
- Reusable annotation for providers that should not auto-dispose
- Documented in ARCHITECTURE.md
## [1.1.13] - 2026-01-04
### Added
- Settings page with Appearance, Navigation, and Account sections
- Theme toggle in user profile dropdown (System/Light/Dark)
- Theme syncs with API preferences on login
- Default room preference syncs with backend
### Changed
- Theme changes now persist to both local storage and API
## [1.1.12] - 2026-01-04
### Changed
- Control Room navigation reorganized:
- New "Stack" section with Containers and Proxy Hosts
- New "Data Management" section with PostgreSQL, Redis, Qdrant, Neo4j placeholders
- Removed: Networks, Volumes, Images (Portainer) and Redirections, Streams, Certificates (NPM)
## [1.1.11] - 2026-01-04
### Fixed
- API client providers now use `keepAlive: true` to prevent Ref invalidation
- Fixes "DioException [unknown]: null" error on /security/users and other API pages
- AuthInterceptor's stored Ref was becoming invalid when provider auto-disposed
## [1.1.10] - 2026-01-04
### Changed
- Removed page swipe transitions - all navigation is now instant (NoTransitionPage)
## [1.1.9] - 2026-01-04
### Changed
- Moved health check to `/health` directory - URL is now `/health` instead of `/health.html`
- Enables NPM forward auth path exclusion for health endpoint
## [1.1.8] - 2026-01-04
### Changed
- Dark background (`#1a1a2e`) on web/index.html to prevent white flash during auth redirects
## [1.1.7] - 2026-01-04
### Removed
- Removed `/callback` route from Flutter router - AuthController handles callback in main() before app starts
- Removed `_OidcCallbackPage` widget - no visible auth UI needed
## [1.1.6] - 2026-01-04
### Changed
- **Auth moved to standalone controller** - Handles OIDC completely outside Riverpod
- New `AuthController` runs in `main()` before `runApp()` - avoids provider lifecycle issues
- Handles callback, token exchange, and /auth/sync before app starts
- If auth not ready (redirecting), app doesn't start at all
- `AuthProvider` now just loads stored tokens (no async OIDC logic)
- Fixes "Cannot use Ref after disposed" errors from autoDispose providers
## [1.1.5] - 2026-01-04
### Fixed
- Race condition in OIDC callback: AuthProvider.build() was initiating silent OIDC while the callback page was processing, causing PKCE state to be cleared. Now skips silent OIDC when on `/callback` route.
## [1.1.4] - 2026-01-04
### Fixed
- Silent OIDC fallback: when `prompt=none` fails with `login_required` (no Authentik session), automatically fall back to regular OIDC flow to show login UI
## [1.1.3] - 2026-01-04
### Changed
- **Web auth uses silent OIDC with JWT Bearer tokens**
- Uses `prompt=none` to silently obtain JWT when Authentik session exists (via NPM forward auth)
- Flutter sends Bearer token to core-api instead of relying on forward auth cookies
- Fixes cross-subdomain cookie issues between home.schweitz.net and api.schweitz.net
- Callback now syncs with `/auth/sync` to get user profile and roles from core-api
- API interceptor now adds Bearer token on web (previously skipped)
## [1.1.2] - 2026-01-04
### Changed
- **Web auth simplified**: Skip Flutter OIDC on web - NPM forward auth handles it
- NPM authenticates at proxy level before app loads
- No more redundant OIDC redirect after NPM auth completes
- Fixes "Cannot use Ref after disposed" error from conflicting auth flows
- Mobile still uses Flutter OIDC flow
### Added
- Logout now redirects to Authentik to end SSO session
- Clears local tokens AND invalidates Authentik session
- Uses OIDC end_session_endpoint from discovery document
- Redirects back to app after Authentik logout completes
## [1.1.0] - 2026-01-04
### Changed
- **Dockerfile rebuild fix**: Added `flutter clean` before build to prevent stale cached artifacts
- VERSION build arg added for explicit cache busting
- Reordered build steps: clean → pub get → build_runner → health.json → flutter build
- Ensures deployed app always matches the version in health.json
### Fixed
- Replaced deprecated `dart:html` with `package:web` in iframe_view_web.dart
- Uses `web.HTMLIFrameElement` instead of `html.IFrameElement`
- Fixes deprecation warnings for Flutter 3.x web builds
## [1.0.12] - 2026-01-04
### Changed
- Removed login page - auth now auto-initiates from AppScaffold
- No more redirect to /login, just auto-start OIDC if not authenticated
- Shows loading screen during auth, error screen on failure with retry
- Seamless experience when Authentik session exists
### Removed
- Removed /login route and _LoginPage widget
## [1.0.11] - 2026-01-04
### Changed
- Web auth now extracts user info directly from JWT instead of syncing with core-api
- Eliminates CORS preflight issues with /auth/sync endpoint
- Decodes JWT claims (name, email, groups) client-side
- Bearer token will be used for API authentication
## [1.0.10] - 2026-01-04
### Fixed
- Fixed OIDC callback route being redirected to login before processing
- Moved callback route exception check BEFORE the auth redirect check in router
- This was preventing token exchange from ever happening
- Added favicon.ico to web root for proper browser tab icon display
## [1.0.9] - 2026-01-04
### Fixed
- Fixed OIDC callback Riverpod state modification error
- Deferred callback processing to `addPostFrameCallback` to avoid modifying state during widget build
## [1.0.8] - 2026-01-04
### Changed
- Switched from hash-based URLs (`/#/login`) to path-based URLs (`/login`)
- Required for OIDC callback to work correctly
- Uses conditional import to avoid breaking mobile/desktop builds
## [1.0.7] - 2026-01-04
### Fixed
- Fixed OIDC PKCE state loss across browser redirect
- Code verifier and state now persist in sessionStorage instead of memory
- Prevents "No code verifier" error after Authentik redirect
## [1.0.6] - 2026-01-04
### Fixed
- Fixed version generation in CI/CD builds
- Removed generated files (version.g.dart, health.json) from git tracking
- These files are now regenerated from pubspec.yaml during Docker build
## [1.0.5] - 2026-01-04
### Changed
- **Web authentication now uses OIDC** instead of NPM forward auth
- Added `OidcServiceWeb` for browser redirect-based Authorization Code flow with PKCE
- Added `/callback` route to handle Authentik redirect after login
- Login page now shows "Sign in with Authentik" button for both web and mobile
- Tokens stored in SharedPreferences and synced with core-api via `/auth/sync`
- Added web utility functions (`web_utils.dart`) with conditional imports for non-web platforms
- Added `crypto` and `web` packages for PKCE SHA-256 and browser API access
### Fixed
- Removed cross-origin cookie dependency that caused authentication failures on web
## [1.0.4] - 2026-01-03
### Added
- `health.json` generated at build time with app version info
- `health.html` now displays version, title, and status from health.json
## [1.0.3] - 2026-01-03
### Fixed
- Fixed auth endpoint path: `/auth/me``/auth/users/me`
## [1.0.2] - 2026-01-03
### Fixed
- Production Docker build now uses correct API URLs
- Added `--dart-define` flags for `CORE_API_URL` and `TATLOCK_API_URL`
- This enables `requiresAuth=true` so authentication is actually triggered
- Updated AGENTS.md with clear service port reference table
## [1.0.1] - 2026-01-03
### Fixed
- Web authentication now works correctly with NPM forward auth
- Dio client sends cookies with requests via `withCredentials: true`
- Added platform-specific adapters (native vs web) for proper cookie handling
## [1.0.0] - 2026-01-03
### Added
- **Authentication System** - Dual-flow auth supporting web (NPM forward auth) and mobile (OIDC)
- `AuthState` model with roles, permissions, and user preferences
- `AuthProvider` with automatic web session detection via `/auth/me`
- Permission system with Domain/Action enums and hierarchical access levels
- `PermissionGate` and `AdminGate` widgets for UI permission checks
- `Role` model with `{domain}.{category}:{action}` format parsing
- Route guards redirect unauthenticated users to login page
- Login page with Authentik OAuth redirect
- Mobile auth platform configuration (iOS URL schemes, Android AppAuth)
- Comprehensive auth test suite (60 unit tests)
### Changed
- API interceptor skips Bearer tokens on web (uses cookies via NPM forward auth)
- Router integrates auth state for protected route access
- **First stable release** - Core functionality complete for home lab dashboard
## [0.3.3] - 2026-01-03
### Added
+163
View File
@@ -0,0 +1,163 @@
# CLAUDE.md — tatlock-ui
Flutter/Dart frontend for the homelab — the dashboard at `home.schweitz.net`. Riverpod state,
Material 3, Go-Router. Built as a **Flutter web** app, served as compiled static assets by nginx
in the `tatlock-ui` container. `pubspec.yaml` version **1.7.1+1**, package name `tatlock_ui`,
Dart SDK `^3.10.4`.
It is a **client**, not a service. It talks to core-api (:8083) and tatlock (:8000); it exposes
no API of its own and has no `/openapi.json`.
## Read first
- **[PHILOSOPHY.md](PHILOSOPHY.md)** — the system vision and the architectural patterns all work
should move toward. The previous AGENTS.md made this a mandatory pre-work read and that
requirement is carried forward deliberately.
- **[docs/UI_LAYOUT.md](docs/UI_LAYOUT.md)** — **read before touching the widget tree.** Check
`lib/shared/components/` for an existing component before building a new one, and look at how a
comparable feature already does it. Deviating from the established patterns is the main source
of drift here.
- `docs/` also holds `ARCHITECTURE.md`, `API_INTEGRATION.md`, `TESTING.md`, `THEMING.md`,
`DATAGRID.md`, `DEPLOYMENT.md`.
## Ports and where it runs
| | |
|---|---|
| Container | `tatlock-ui`, `127.0.0.1:9999 -> 80` (nginx serving the web build) |
| External | `home.schweitz.net` |
| Backends | core-api `:8083`, tatlock `:8000` |
The old AGENTS.md said the app is "available at `http://tower:8092`". **That is stale** — the
published port is 9999, verified against `docker ps` on 2026-08-09.
It also pointed at `portainer-core` for full-stack documentation. **`portainer-core` is
deprecated** and must not be used as a source of infra facts; it was merged into
`system-admin-toj/containers/`. The live inventory is `CONTAINERS.md` there.
The Gitea SSO-bypass trick is real and still works: `http://localhost:3002` reaches Gitea
directly, verified returning `{"version":"1.27.1"}`. Useful for reading a sibling repo's raw
files without going through Authentik.
## Layout
`lib/main.dart``lib/app.dart`; `lib/core/` (api, auth, config, error, providers, semantics,
theme), `lib/features/<room>/` (control_room, front_hall, media_room, parlor, security,
settings), `lib/routing/`, `lib/shared/` (components, layouts, theme, widgets). 132 Dart files
under `lib/`, 24 test files.
## Establishing what is live — and the trap in it
There is no `sys.modules` here and nothing to `docker exec` into: the container holds compiled
assets, not source. The Dart analogue is a transitive walk of `import`/`export`/`part`
directives from `lib/main.dart`, resolving `package:tatlock_ui/…` to `lib/…`. Run 2026-08-09:
124 of 132 files reachable, 8 not.
**Do not read that as a delete list. Five of the eight are the code that actually runs in
production.** They are conditional-import targets:
```dart
import 'api_client_native.dart' if (dart.library.html) 'api_client_web.dart';
```
A naive walk captures the *first* string and misses the branch. Since this app ships as Flutter
**web**, the `_web.dart` half is the live one and the `_stub`/`_native` half is the dormant one —
the exact inverse of what the reachability count suggests. The five: `api_client_web.dart`,
`web_utils_web.dart`, `url_strategy_web.dart`, `url_state_web.dart`,
`iframe_view_web.dart`. Find them all with `grep -rn "if (dart.library" lib/`.
That leaves three genuinely unreferenced files, and they are **not** all the same thing:
| File | Status |
|---|---|
| `lib/core/auth/permission_gate.dart` | no reference anywhere in `lib/` or `test/` |
| `lib/core/semantics/semantic_widget.dart` | no reference anywhere in `lib/` or `test/` |
| `lib/features/control_room/stacks/data/models/stack_model.dart` | **referenced only by its own test** |
The third is the interesting one: `stack_model_test.dart` imports and exercises it, so the suite
is green and gives confidence about a model the app never uses. A passing test is not evidence a
thing is wired in.
Before deleting any of the three, check whether it is intended groundwork rather than debris —
`TODO_AUTH_REFACTOR.md` describes an unimplemented auth redesign, and `permission_gate.dart` sits
squarely in that area. Neither that file nor `PLAN.md` mentions it by name, so its status is
**undetermined**, not dead. Ask before removing.
## Tooling
`flutter` and `dart` resolve from `/snap/bin`, which **is** on the non-interactive `PATH` — so
bare commands work here (unlike `pql`, which needs its absolute path).
```bash
flutter pub get
flutter test # 24 test files
flutter analyze # static analysis; analysis_options.yaml at the repo root
flutter build web --release
```
**Always add tests for new code before committing** — happy path plus key edge cases, widget
tests for widgets, unit tests for logic. Coverage should not decrease. Carried over from the
previous AGENTS.md, which stated it as "no exceptions".
Note there is **no CI test gate**: `.gitea/workflows/build.yml` triggers only on `v*` tag push
and goes straight to build and release. `flutter test` runs locally or not at all.
## Work tracking
Work lives in **pql**, not a markdown TODO. **This repo's vault is standalone** — its tickets and
its internal decisions live here in `.pql/` and `governance/`, and travel with a clone, because
`.pql/changelog/` is committed and replayed by the git hooks (workspace D-15). The databases are gitignored
and rebuildable with `pql plan rebuild`.
`pql` is **not** on the non-interactive `PATH` — invoke it as `/home/jpmschweitzer/.local/bin/pql`.
From inside this repo no `--vault` is needed: pql anchors at the nearest `.git/` ancestor, which
is this repo.
```bash
/home/jpmschweitzer/.local/bin/pql ticket list # this repo's open work
/home/jpmschweitzer/.local/bin/pql plan whatsnext # next unblocked item, with context
/home/jpmschweitzer/.local/bin/pql decisions list # this repo's own decisions
```
Stack-level decisions that constrain this app live in the **workspace** vault and need the flag:
```bash
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects decisions list --domain tatlock-ui
```
Note `ticket new --decision D-N` resolves ids within **one** vault, so a ticket here cannot link
to a workspace decision. Cite the id in the ticket body instead.
`PLAN.md` and `TODO_AUTH_REFACTOR.md` predate this convention. Treat them as research notes;
new work goes in pql.
## Git
- **History is linear — no merge commits.** Work on `main`, or a short-lived branch that is
fast-forwarded and deleted.
- **Conventional Commits**: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`.
- **Stage explicitly. Never `git add -A`** — denied by policy, and it sweeps in whatever else is
dirty.
- **Every version increment gets a tag** — `vX.Y.Z`, on the `major.minor.patch` part, not the
build number.
- Update `CHANGELOG.md` for every user-facing change.
**`.claude/settings.local.json` is currently tracked in git here.** Contents are benign — a
four-entry permission allow list, no `env` block, no secrets — but it is machine-local state that
should not be shared. There is now a `.gitignore` rule for it, and **that rule is inert**: git
applies ignore rules only to untracked paths, so edits still show in `git status` and still get
committed. It starts working only after `git rm --cached .claude/settings.local.json`, which is a
history decision and was deliberately left out of normalization.
Consequence for checking: plain `git check-ignore` prints nothing for this path — it consults the
index — which looks identical to "no rule exists". Use `--no-index` to test the rule itself.
## Releasing
1. Bump `version` in `pubspec.yaml` (the `major.minor.patch` part).
2. Move `[Unreleased]` entries into a dated section in `CHANGELOG.md`.
3. Stage by name, commit, tag `vX.Y.Z`, `git push origin main --tags`.
4. Gitea CI builds the web app in Docker, pushes `:latest` and `:vX.Y.Z`; Watchtower deploys.
5. Verify at `home.schweitz.net`, or `curl -I http://localhost:9999`.
**Rollback:** in Portainer, point the image tag at the previous version.
+20 -6
View File
@@ -3,20 +3,34 @@ FROM ghcr.io/cirruslabs/flutter:stable AS builder
WORKDIR /app
# Copy dependency files first for better caching
COPY pubspec.yaml ./
# VERSION arg busts cache when version changes in pubspec.yaml
# Extract version: docker build --build-arg VERSION=$(grep '^version:' pubspec.yaml | cut -d' ' -f2) .
ARG VERSION=0.0.0
RUN echo "Building version: $VERSION"
# Get dependencies (generates pubspec.lock)
# Copy dependency files first for better caching
COPY pubspec.yaml pubspec.lock* ./
# Get dependencies
RUN flutter pub get
# Copy the rest of the application
COPY . .
# Generate code with build_runner
# Clean any cached build artifacts to ensure fresh build
RUN flutter clean && flutter pub get
# Generate code with build_runner (after clean for fresh generation)
RUN dart run build_runner build --delete-conflicting-outputs
# Build for web release
RUN flutter build web --release
# Generate health.json with version info
RUN dart run tool/generate_health_json.dart
# Build for web release with production configuration
# These URLs enable authentication (requiresAuth = true when URL contains schweitz.net)
RUN flutter build web --release \
--dart-define=CORE_API_URL=https://api.schweitz.net \
--dart-define=TATLOCK_API_URL=https://tatlock.schweitz.net
# Stage 2: Serve with nginx
FROM nginx:alpine
+92
View File
@@ -0,0 +1,92 @@
# tatlock-ui — the repo's command surface (D-27).
#
# Flutter rather than Python, so there is no venv and no PYTHON here. The
# reason the targets still exist under these names is the point of D-27: an
# agent or a person can run `make test` in any repo in this workspace without
# first working out which stack it is.
.DEFAULT_GOAL := help
.PHONY: help
help: ## Show this help
@grep -hE '^[a-z][a-z0-9_-]*:.*?## ' $(MAKEFILE_LIST) \
| awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
.PHONY: setup
setup: ## Fetch dependencies and generate code (run this after a fresh clone)
flutter pub get
$(MAKE) generate
@ci/check_codegen.sh
.PHONY: generate
generate: ## Regenerate freezed/json_serializable/riverpod sources
dart run build_runner build --delete-conflicting-outputs
.PHONY: check-codegen
check-codegen: ## Prove every part directive has a generated file on disk
@ci/check_codegen.sh
.PHONY: test
test: ## Run the widget and unit tests
@ci/check_codegen.sh \
|| { echo "FAIL — generated sources missing or stale; run: make setup"; exit 69; }
flutter test
# Why the guard above: *.freezed.dart and lib/**/*.g.dart are gitignored, so a
# fresh clone has none of them and most of the suite fails to compile rather
# than to assert. On 2026-08-09 that read as "26 passed, 17 failed" — which
# looks like broken tests and is actually a missing build step. After
# generating, the same suite is 452 passed. A test run that cannot compile
# should say so in those words.
#
# `setup` and `test` both call ci/check_codegen.sh rather than one calling
# the other's target, because `setup`'s job is "make the tree usable" (fails
# loud if codegen silently produced less than the tree needs) and `test`'s
# job is "is the tree usable right now" (fails loud if nobody ran setup at
# all, or ran it before a source file changed). Same check, two different
# questions, so a shared script rather than a shared Make target — a Make
# target can only be reused by depending on it, which would make `test`
# imply `flutter pub get` and `build_runner`, both slow, every run.
#
# The check walks every `part '<name>.g.dart'`/`part '<name>.freezed.dart'`
# directive under lib/ and confirms the named sibling file exists — not one
# sentinel file (the previous guard checked only
# user_preferences.freezed.dart, which would have missed 44 of the 45
# directives that exist today). See ci/check_codegen.sh for why this is
# preferred over `flutter analyze`: cheaper, and it targets exactly the
# generated/ungenerated distinction rather than static analysis in general.
.PHONY: lint
lint: ## Static analysis (analysis_options.yaml at the repo root)
flutter analyze
.PHONY: build
build: ## Release build for the web target
flutter build web --release
.PHONY: clean
clean: ## Remove build artefacts and the pub cache for this project
flutter clean
# git hands a hook a non-login shell, which never sees ~/.local/bin — where
# gitleaks lands. Without this the scan reports "not installed" on every push,
# which is a check that fails open (D-24).
export PATH := $(HOME)/.local/bin:/usr/local/bin:$(PATH)
.PHONY: secrets
secrets: ## Scan the commits about to be pushed for credentials
@ci/secrets.sh
# The call surface is identical in every repo; what it runs is not.
#
# `secrets` runs first, deliberately: it is the only failure here that cannot be
# undone by fixing it afterwards. A failed lint costs another commit; a pushed
# credential is cached and indexed whether or not it is later deleted.
#
# Some of these fail today, and are left wired anyway. The state was measured
# once and written down in T-56 rather than being worked around here — a gate
# quietly narrowed to what already passes is a gate that reports success for
# doing nothing, which is the failure this workspace keeps rediscovering.
.PHONY: pre-push
pre-push: secrets lint ## Everything the pre-push hook runs
@echo " -- not gated here yet: test (T-56)"
+1 -1
View File
@@ -109,7 +109,7 @@ The butler (backend) runs the household. The UI opens the door.
**Related Documents**:
- **README.md**: Project setup and operational details
- **AGENTS.md**: LLM agent development guidelines
- **CLAUDE.md**: LLM agent development guidelines
- **PLAN.md**: Implementation roadmap and phases
---
+86
View File
@@ -0,0 +1,86 @@
# Auth Flow Refactor: Invisible Token Exchange
> Research note for future implementation
## Problem
The `/callback?code=...` URL is visible in the browser during token refresh. This happens every time tokens need refreshing, not just on initial login. The current implementation appears to re-run the full OIDC redirect flow (`prompt=none`) instead of using the refresh_token.
## Current Behavior
```
Token expires → Redirect to Authentik (prompt=none) →
Redirect to /callback?code=xxx → Exchange code → Continue
```
User sees URL flicker to `/callback` repeatedly.
## Desired Behavior
```
Token expires → Show overlay (lock/lightning icon) →
XHR refresh request → Hide overlay → Continue
```
No URL changes. No redirects. Just a brief visual indicator.
## Key Insight
**Only the initial authorization MUST redirect** (user needs to see Authentik login UI).
Everything else can be XHR:
| Operation | Current | Should Be |
|-----------|---------|-----------|
| Initial login | Redirect | Redirect (unavoidable) |
| Token exchange (code → tokens) | Redirect to /callback | XHR POST |
| Token refresh | Full OIDC with prompt=none | XHR POST with refresh_token |
| Session expired | Redirect | Redirect (unavoidable) |
## Token Refresh via XHR
```dart
final response = await dio.post(
'https://authentik.schweitz.net/application/o/token/',
data: {
'grant_type': 'refresh_token',
'refresh_token': storedRefreshToken,
'client_id': clientId,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
);
// Returns new access_token, refresh_token, expires_in
```
## Potential Blocker: CORS
Authentik's token endpoint may block browser XHR. Solutions:
1. **Configure Authentik CORS** - Allow `home.schweitz.net` origin
2. **Proxy through core-api** (recommended)
- Flutter → `POST /auth/refresh` → core-api → Authentik
- Keeps client_secret server-side
- No CORS issues
## Implementation Steps
1. [ ] Verify Authentik is issuing refresh_tokens (check token response)
2. [ ] Check if refresh_token is being stored (SharedPreferences)
3. [ ] Test XHR to token endpoint (check CORS)
4. [ ] If CORS blocked, add `/auth/refresh` endpoint to core-api
5. [ ] Refactor `AuthProvider` to use XHR refresh instead of full OIDC flow
6. [ ] Add refresh overlay UI (lock icon + brief animation)
7. [ ] Remove `prompt=none` redirect logic for refresh cases
## Files to Investigate
- `lib/core/auth/auth_provider.dart` - Main auth state management
- `lib/core/auth/oidc_service_web.dart` - OIDC implementation
- `lib/core/api/api_interceptors.dart` - Token refresh trigger point
## References
- CHANGELOG entries v1.1.3-v1.1.6 document the current auth architecture
- AuthController runs in `main()` before `runApp()` (v1.1.6 pattern)
+4 -1
View File
@@ -24,10 +24,13 @@ android {
applicationId = "net.schweitz.tatlock_ui"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
minSdk = 23 // Required for AppAuth
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
// flutter_appauth redirect scheme for OIDC callbacks
manifestPlaceholders["appAuthRedirectScheme"] = "net.schweitz.tatlock"
}
buildTypes {
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Prove that generated sources exist for every part directive that requires
# one, after `make generate` has run. Lives here rather than inline in the
# Makefile so it can be read, run by hand (`make check-codegen`), and changed
# under review — same reasoning as ci/secrets.sh (D-27).
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
# *.freezed.dart and lib/**/*.g.dart are gitignored, so a fresh or stale
# checkout can silently have some but not all of the files a `part`
# directive names. `dart run build_runner build` exits 0 whether it produced
# everything the tree needs or almost nothing — exit code is not evidence
# (T-47). What IS evidence: every `part '<name>';` directive in lib/ names a
# sibling file, and that file either exists or it doesn't. This walks every
# directive and checks its target directly, rather than trusting a single
# sentinel file (the old `test` guard checked one file,
# user_preferences.freezed.dart, and would have missed 44 other gaps).
#
# On 2026-08-09 this exact condition was 4 generated files present where 46
# were needed. `flutter analyze` would also catch it, but slower and later —
# this check is the cheapest thing that proves the same fact.
missing=0
checked=0
while IFS=: read -r file part_line; do
# grep -H prefixes exactly one "file:" — no line numbers, so a colon
# inside the match (there is none here, but be safe) can't split wrong.
# part_line looks like: part 'auth_state.freezed.dart';
target=$(printf '%s' "$part_line" | sed -E "s/^part '([^']+)';.*/\1/")
dir=$(dirname "$file")
checked=$((checked + 1))
if [ ! -f "$dir/$target" ]; then
echo "MISSING generated file: $dir/$target (required by 'part' directive in $file)" >&2
missing=$((missing + 1))
fi
done < <(grep -rH "^part '" lib --include='*.dart')
if [ "$checked" -eq 0 ]; then
echo "FAIL check-codegen — found zero 'part' directives under lib/; the check itself is broken, not the tree." >&2
exit 1
fi
if [ "$missing" -gt 0 ]; then
echo "FAIL check-codegen — $missing of $checked generated files are missing. Run: make generate" >&2
exit 1
fi
echo "check-codegen — $checked/$checked generated files present."
Executable
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Secret scan over the commits about to be pushed.
#
# Lives here rather than inside .githooks/pre-push so it can be read, run by
# hand (`make secrets`), and changed under review. A hook is a trigger; it is
# not a home for logic. Identical in every repo in this workspace (D-27).
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
# A non-login shell — which is what git gives a hook — skips /etc/profile.d
# and never sees ~/.local/bin, where the gitleaks release tarball lands.
# Without this the scan reports "not installed" on every push.
[ -d "$HOME/.local/bin" ] && PATH="$HOME/.local/bin:$PATH"
if ! command -v gitleaks >/dev/null 2>&1; then
echo "FAIL secrets — gitleaks not installed, so this check would be a no-op pretending to pass." >&2
echo " https://github.com/gitleaks/gitleaks/releases → ~/.local/bin/gitleaks" >&2
exit 1
fi
# Scan the outgoing range, not full history. History here carries findings
# that are settled — test fixtures and vendored third-party code — and a gate
# that fails on something unfixable gets bypassed within a week. What matters
# is what is about to leave this machine.
if upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null); then
range="$upstream..HEAD"
elif git rev-parse --verify --quiet origin/main >/dev/null; then
range="origin/main..HEAD"
else
range=""
fi
if [ -z "$range" ]; then
gitleaks dir . --redact --no-banner --exit-code 1 || {
echo "FAIL secrets — gitleaks found a credential in the working tree." >&2; exit 1; }
exit 0
fi
[ -n "$(git log --oneline "$range" 2>/dev/null)" ] || exit 0
gitleaks git . --log-opts="$range" --redact --no-banner --exit-code 1 >/dev/null 2>&1 || {
echo "FAIL secrets — gitleaks found a credential in the commits being pushed." >&2
echo " inspect (values redacted): gitleaks git . --log-opts=\"$range\" --redact" >&2
echo " then remove and rotate it, or suppress deliberately:" >&2
echo " inline '# gitleaks:allow <reason>'" >&2
echo " or add the fingerprint to .gitleaksignore WITH a reason" >&2
exit 1
}
echo " ok secrets"
+252 -10
View File
@@ -20,6 +20,7 @@ lib/
│ ├── auth/ # Authentik OIDC integration
│ ├── config/ # Environment configuration
│ ├── error/ # Error types and handling
│ ├── semantics/ # Semantic IDs for automation
│ └── theme/ # Material 3 theming
├── routing/ # go_router configuration
├── shared/ # Reusable components
@@ -27,14 +28,20 @@ lib/
│ └── layouts/ # App scaffold, navigation
├── features/ # Feature modules (rooms)
│ ├── front_hall/ # Dashboard - estate overview
│ │ └── router.dart # Room registration
│ ├── control_room/ # Infrastructure
│ │ ├── router.dart # Room registration
│ │ ├── containers/ # Container management
│ │ ── stacks/ # Stack management
│ ├── networks/ # Network management
│ │ ── volumes/ # Volume management
│ ├── parlor/ # Housekeeping - home automation
├── library/ # Knowledge management (future)
── study/ # Secretarial tasks (future)
│ │ ── npm/ # Proxy hosts management
│ ├── security/ # User & access management
│ │ ── router.dart # Room registration
│ ├── users/ # User management
│ └── groups/ # Group management
── parlor/ # AI chat & automation hub
│ │ └── router.dart # Room registration
│ ├── media_room/ # Media management (future)
│ │ └── router.dart # Room registration
│ └── settings/ # User preferences
└── chat/ # Tatlock chat - omnipresent, NOT a room
```
@@ -44,13 +51,16 @@ lib/
|---------|-----------|---------|
| Front Hall | `features/front_hall/` | Dashboard, overview, quick access |
| Control Room | `features/control_room/` | Infrastructure management |
| Parlor | `features/parlor/` | Home automation |
| Library | `features/library/` | Knowledge, docs, bookmarks |
| Study | `features/study/` | Email, calendar (hidden for now) |
| Security | `features/security/` | User & access management |
| Parlor | `features/parlor/` | AI chat & automation hub |
| Media Room | `features/media_room/` | Media management (future) |
| *(non-room)* | `features/settings/` | User preferences |
| *(omnipresent)* | `chat/` | Tatlock assistant dock |
Note: `chat/` lives at the top level of `lib/` (not under `features/`) because it's not a navigable room - it's an omnipresent dock injected at the layout level.
Each room has a `router.dart` file that registers the room with the central registry. See [Room Registry Pattern](#room-registry-pattern) for details.
## Feature Structure
Each feature follows a three-layer architecture:
@@ -142,7 +152,7 @@ part 'container_model.freezed.dart';
part 'container_model.g.dart';
@freezed
class ContainerModel with _$ContainerModel {
sealed class ContainerModel with _$ContainerModel {
const factory ContainerModel({
required String id,
required String name,
@@ -420,6 +430,41 @@ ContainerRepository containerRepository(Ref ref) {
}
```
### Persistent Providers
By default, `@riverpod` generates providers with `isAutoDispose: true`, meaning they dispose when no longer watched. This causes issues for:
- **API clients** with interceptors that store a `Ref`
- **App-level state** like theme, auth, config
- **Providers with listeners** to other providers
Use `@persistentRiverpod` from `core/providers/annotations.dart` for these cases:
```dart
import 'package:tatlock_ui/core/providers/annotations.dart';
// ✅ Correct - persists for app lifetime
@persistentRiverpod
Dio coreApiClient(Ref ref) { ... }
@persistentRiverpod
class ThemeNotifier extends _$ThemeNotifier { ... }
// ❌ Wrong - auto-dispose can invalidate stored Ref
@riverpod
Dio coreApiClient(Ref ref) { ... }
```
**When to use `@persistentRiverpod`:**
| Use Case | Annotation | Example |
|----------|------------|---------|
| API clients with interceptors | `@persistentRiverpod` | `coreApiClient`, `tatlockApiClient` |
| Auth state provider | `@persistentRiverpod` | `AuthNotifier` |
| Theme/config providers | `@persistentRiverpod` | `ThemeNotifier` |
| Feature data providers | `@riverpod` (default) | `ContainersNotifier` |
| UI state providers | `@riverpod` (default) | `SearchFilterNotifier` |
## File Naming Conventions
| Type | Convention | Example |
@@ -464,6 +509,34 @@ Generated files:
- `*.freezed.dart` - Immutable classes
- `*.g.dart` - JSON serialization, Riverpod providers
### Freezed 3.x: Required `sealed class`
**Freezed 3.x requires the `sealed` keyword** on all classes with generated mixins. Without it, the generated code will fail to compile with errors about missing concrete implementations.
```dart
// ✅ Correct - Freezed 3.x
@freezed
sealed class UserModel with _$UserModel {
const factory UserModel({
required String id,
required String name,
}) = _UserModel;
factory UserModel.fromJson(Map<String, dynamic> json) =>
_$UserModelFromJson(json);
}
// ❌ Wrong - will fail to compile
@freezed
class UserModel with _$UserModel { // Missing `sealed`
const factory UserModel({...}) = _UserModel;
}
```
The `sealed` keyword was introduced in Dart 3.0 and allows the generated mixin `_$UserModel` to have abstract members that are implemented by the private `_UserModel` class.
**Always use `sealed class` with `@freezed`** - this applies to all models, entities, and state classes using Freezed.
## Import Rules
1. Never import from `data/` in `domain/`
@@ -481,3 +554,172 @@ import 'package:tatlock_ui/features/containers/domain/entities/container.dart';
// Bad - importing model in presentation
import '../data/models/container_model.dart'; // Don't do this
```
## Semantic Identifiers for Automation
All interactive widgets should have semantic identifiers for UI automation. This enables reliable testing with Puppeteer, Appium, and other automation tools.
### Quick Reference
```dart
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
// Wrap interactive widgets with Semantics
Semantics(
identifier: DataGridSemantics.row(item.id),
label: 'Select ${item.name}',
child: MyRowWidget(item: item),
)
```
### Key Points
1. **Central ID Registry** - All IDs defined in `lib/core/semantics/semantic_ids.dart`
2. **Naming Convention** - `{area}_{component}_{identifier}` (e.g., `dataGrid_row_abc123`)
3. **Web Enabled** - Semantics tree exposed via `SemanticsBinding.instance.ensureSemantics()` in `main.dart`
For complete documentation on semantic patterns, automation queries, and best practices, see **[TESTING.md](./TESTING.md)**.
## Room Registry Pattern
The application uses a **decentralized room registry** pattern for navigation. Each feature/room registers itself with the central registry, providing:
- **Decoupled navigation** - Rooms define their own routes, icons, and metadata
- **Permission-based filtering** - Rooms can specify required permissions
- **Dynamic UI** - Settings dropdowns and tab bars build from registry
- **Single source of truth** - All room metadata in one place per room
### Architecture
```
lib/routing/room_registry.dart # Central registry class
lib/features/{room}/router.dart # Per-room registration
```
### Room Definition
Each room's `router.dart` exports a `RoomDefinition` and register function:
```dart
// lib/features/control_room/router.dart
import 'package:tatlock_ui/routing/room_registry.dart';
/// Room definition with all metadata.
final controlRoomRoom = RoomDefinition(
id: 'control-room', // Preference value, URL segment
label: 'Control Room', // Display name
icon: Icons.dns_outlined, // Unselected icon
selectedIcon: Icons.dns, // Selected icon
defaultRoute: '/control-room/containers', // Landing route
routes: controlRoomRoutes, // Function returning List<RouteBase>
requiredPermissions: [], // Empty = accessible to all
);
/// Register with the central registry.
void registerControlRoom() {
roomRegistry.register(controlRoomRoom);
}
/// Routes for go_router.
List<RouteBase> controlRoomRoutes() {
return [
GoRoute(path: '/control-room', ...),
// Sub-routes...
];
}
```
### Registration Order
Rooms are registered in `app_router.dart` in display order:
```dart
void _initializeRoomRegistry() {
if (roomRegistry.all.isNotEmpty) return; // Skip if initialized
// Registration order = tab order
registerFrontHall();
registerControlRoom();
registerSecurity();
registerParlor();
registerMediaRoom();
}
```
### Using the Registry
**Navigation tabs** (`top_header_bar.dart`):
```dart
List<RoomDefinition> get _rooms => roomRegistry.all;
// Build tab for each room
for (final room in _rooms) {
IconButton(
icon: Icon(isSelected ? room.selectedIcon : room.icon),
onPressed: () => onRoomSelected(index),
);
}
```
**Settings dropdown**:
```dart
DropdownButton<String>(
items: roomRegistry.all
.map((room) => DropdownMenuItem(
value: room.id,
child: Text(room.label),
))
.toList(),
);
```
**Router** - All routes from registry:
```dart
ShellRoute(
routes: [
...roomRegistry.allRoutes(),
// Plus non-room routes like /settings
],
);
```
**Route matching**:
```dart
int _selectedIndex(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
return roomRegistry.indexOfRoute(location);
}
```
### Permission Filtering
Rooms can specify required permissions:
```dart
final adminRoom = RoomDefinition(
id: 'admin',
requiredPermissions: ['admin:access'],
// ...
);
// Filter by user permissions
final accessibleRooms = roomRegistry.accessibleTo(userPermissions);
```
### Adding a New Room
1. Create feature folder: `lib/features/{room_name}/`
2. Create `router.dart` with `RoomDefinition` and register function
3. Create placeholder page in `presentation/pages/{room_name}_page.dart`
4. Add `register{RoomName}()` call to `_initializeRoomRegistry()` in `app_router.dart`
5. Import the router in `app_router.dart`
### Current Rooms
| Room | ID | Default Route |
|------|-----|---------------|
| Front Hall | `front-hall` | `/front-hall` |
| Control Room | `control-room` | `/control-room/containers` |
| Security | `security` | `/security/users` |
| Parlor | `parlor` | `/parlor` |
| Media Room | `media-room` | `/media-room` |
+368
View File
@@ -0,0 +1,368 @@
# Testing & Automation Guide
This document covers automated testing patterns for Tatlock UI, focusing on semantic identifiers that enable reliable UI automation.
## Overview
Tatlock UI uses Flutter's **Semantics tree** to expose stable identifiers for automated testing. These identifiers are accessible to:
- **Puppeteer** (via Chrome DevTools accessibility API)
- **Appium** (via accessibility labels)
- **WebDriver** (via ARIA attributes)
- **Flutter integration tests**
The semantics system is enabled on web in `main.dart`:
```dart
if (kIsWeb) {
SemanticsBinding.instance.ensureSemantics();
}
```
## Semantic Identifiers
All semantic IDs are centralized in `lib/core/semantics/semantic_ids.dart`. This provides:
1. **Stable selectors** - IDs don't change with UI refactoring
2. **Type safety** - Compile-time verification of ID usage
3. **Discoverability** - Single source of truth for automation targets
### Available ID Classes
| Class | Purpose | Example IDs |
|-------|---------|-------------|
| `ProfileSemantics` | User profile dropdown | `profile_button`, `profile_menu_settings` |
| `RoomTabSemantics` | Main navigation tabs | `roomTab_frontHall`, `roomTab_controlRoom` |
| `NavSemantics` | Side navigation panel | `nav_panel`, `nav_item_{id}` |
| `DataGridSemantics` | Data tables | `dataGrid_row_{id}`, `dataGrid_search` |
| `DialogSemantics` | Modal dialogs | `dialog_confirm`, `dialog_cancel` |
| `SettingsSemantics` | Settings page | `settings_theme`, `settings_defaultRoom` |
| `StateSemantics` | Loading/error states | `state_auth_loading`, `snackbar_{type}` |
### ID Naming Convention
```
{area}_{component}_{identifier}
```
- **area**: Feature or section (e.g., `profile`, `nav`, `dataGrid`)
- **component**: Widget type (e.g., `menu`, `button`, `row`)
- **identifier**: Specific item (e.g., `light`, `settings`, `selectAll`)
Examples:
- `profile_menu_theme_dark` - Dark theme option in profile menu
- `dataGrid_row_abc123` - Row with ID "abc123" in data grid
- `nav_item_containers` - Containers nav item
## Adding Semantics to Widgets
### Method 1: Direct Semantics Widget
Use Flutter's `Semantics` widget with the `identifier` property:
```dart
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
Semantics(
identifier: ProfileSemantics.button,
label: 'Open profile menu',
button: true,
child: IconButton(
icon: Icon(Icons.person),
onPressed: () => ...,
),
)
```
### Method 2: SemanticWidget Wrapper
Use the convenience wrapper from `lib/core/semantics/semantic_widget.dart`:
```dart
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
import 'package:tatlock_ui/core/semantics/semantic_widget.dart';
SemanticWidget(
id: DataGridSemantics.search,
label: 'Search data grid',
textField: true,
child: TextField(
decoration: InputDecoration(hintText: 'Search...'),
),
)
```
### Method 3: Extension Method
Use the `withSemantics` extension for inline wrapping:
```dart
TextField(
decoration: InputDecoration(hintText: 'Search...'),
).withSemantics(
id: DataGridSemantics.search,
label: 'Search data grid',
)
```
### Dynamic IDs
For lists and grids, use the generator methods:
```dart
// Row in a data grid
Semantics(
identifier: DataGridSemantics.row(item.id), // "dataGrid_row_abc123"
child: DataGridRow(item: item),
)
// Navigation item
Semantics(
identifier: NavSemantics.item(route.id), // "nav_item_containers"
child: NavItem(route: route),
)
// Bulk action button
Semantics(
identifier: DataGridSemantics.bulkAction('delete'), // "dataGrid_bulk_delete"
child: IconButton(icon: Icon(Icons.delete), ...),
)
```
## Querying from Puppeteer
Puppeteer can query semantic identifiers via Chrome's accessibility tree:
```javascript
// Connect to Chrome with DevTools protocol
const browser = await puppeteer.connect({
browserURL: 'http://localhost:9222'
});
const page = await browser.newPage();
// Get accessibility snapshot
const snapshot = await page.accessibility.snapshot({ interestingOnly: false });
// Find element by semantic identifier
function findBySemanticId(node, id) {
if (node.name === id || node.description === id) {
return node;
}
for (const child of node.children || []) {
const found = findBySemanticId(child, id);
if (found) return found;
}
return null;
}
// Example: Find profile button
const profileButton = findBySemanticId(snapshot, 'profile_button');
// Example: Find a specific data grid row
const row = findBySemanticId(snapshot, 'dataGrid_row_abc123');
```
### Using Chrome DevTools MCP
With the Chrome DevTools MCP server, you can query semantics directly:
```javascript
// Take a snapshot (returns accessibility tree)
const snapshot = await mcp__chrome_devtools__take_snapshot();
// Click by semantic ID (uid in snapshot)
await mcp__chrome_devtools__click({ uid: 'profile_button' });
// Fill input by semantic ID
await mcp__chrome_devtools__fill({
uid: 'dataGrid_search',
value: 'my search query'
});
```
## Best Practices
### 1. Add Semantics to Interactive Elements
Every clickable, tappable, or input element should have a semantic identifier:
```dart
// Buttons
Semantics(
identifier: 'myFeature_submit',
button: true,
label: 'Submit form',
child: ElevatedButton(...),
)
// Text fields
Semantics(
identifier: 'myFeature_email',
textField: true,
label: 'Email address',
child: TextField(...),
)
// Checkboxes
Semantics(
identifier: 'myFeature_rememberMe',
checked: isChecked,
label: 'Remember me',
child: Checkbox(...),
)
```
### 2. Use Meaningful Labels
Labels help both accessibility tools and test debugging:
```dart
// Good - descriptive label
Semantics(
identifier: DataGridSemantics.rowAction(item.id, 'delete'),
label: 'Delete ${item.name}',
button: true,
child: ...,
)
// Bad - no context
Semantics(
identifier: 'btn1',
child: ...,
)
```
### 3. Register New IDs Centrally
Always add new semantic IDs to `semantic_ids.dart`:
```dart
/// My new feature IDs.
abstract class MyFeatureSemantics {
static const submitButton = 'myFeature_submit';
static const cancelButton = 'myFeature_cancel';
static const nameField = 'myFeature_name';
/// Generate ID for a list item.
static String item(String id) => 'myFeature_item_$id';
}
```
### 4. Test ID Stability
Semantic IDs should remain stable across releases. When refactoring:
- Keep existing IDs unchanged
- Add deprecation comments if IDs must change
- Update automation tests when IDs change
### 5. Exclude Decorative Elements
Don't add semantic IDs to purely decorative elements:
```dart
// Decorative icon - no semantics needed
Icon(Icons.star, color: Colors.yellow)
// Interactive icon - needs semantics
Semantics(
identifier: 'rating_star_3',
button: true,
label: 'Rate 3 stars',
child: IconButton(
icon: Icon(Icons.star),
onPressed: () => rate(3),
),
)
```
## DataGrid Semantic Patterns
The DataGrid component has comprehensive semantic coverage:
```
dataGrid - The grid container
dataGrid_search - Search input field
dataGrid_search_clear - Clear search button
dataGrid_selectAll - Select all checkbox
dataGrid_header_{columnId} - Column header (sortable)
dataGrid_row_{itemId} - Row container
dataGrid_row_{itemId}_checkbox - Row selection checkbox
dataGrid_row_{itemId}_actions - Row actions menu trigger
dataGrid_row_{itemId}_action_{actionId} - Specific row action
dataGrid_bulk_{actionId} - Bulk action button
dataGrid_bulk_clear - Clear selection button
dataGrid_loading - Loading indicator
dataGrid_empty - Empty state message
dataGrid_error - Error state message
dataGrid_refresh - Refresh button
```
### Example: Automating DataGrid Selection
```javascript
// Select all rows
await click('dataGrid_selectAll');
// Select specific row
await click('dataGrid_row_abc123_checkbox');
// Perform bulk delete
await click('dataGrid_bulk_delete');
// Confirm in dialog
await click('dialog_confirm');
```
## Debugging Semantics
### Flutter DevTools
1. Open Flutter DevTools
2. Go to "Inspector" tab
3. Enable "Semantics" overlay
4. Click widgets to see their semantic properties
### Chrome DevTools
1. Open DevTools (F12)
2. Go to "Accessibility" tab
3. Inspect the accessibility tree
4. Search for semantic identifiers
### Programmatic Inspection
```dart
// In a test, dump the semantics tree
debugDumpSemanticsTree();
// Check if semantics are enabled
print('Semantics enabled: ${SemanticsBinding.instance.semanticsEnabled}');
```
## Integration with URL Routing
For deep-linkable test scenarios, semantic IDs work with URL query parameters:
```
/control-room/containers?selected=abc123
```
Automation can:
1. Navigate to URL with query params
2. Verify selection state via `dataGrid_row_abc123_checkbox` (checked: true)
3. Interact with selected rows via semantic IDs
See [URL Routing](#url-routing) section for query parameter patterns.
## Checklist for New Features
When adding a new feature, ensure semantic coverage:
- [ ] Add semantic ID class to `semantic_ids.dart`
- [ ] Wrap all buttons with `Semantics` + `identifier`
- [ ] Wrap all inputs with `Semantics` + `identifier`
- [ ] Wrap list/grid items with dynamic IDs
- [ ] Add labels for accessibility
- [ ] Test that IDs appear in accessibility snapshot
- [ ] Document IDs in this file if they establish new patterns
+54
View File
@@ -0,0 +1,54 @@
# Decisions, Questions, Rejected
This directory holds structured planning records that pql parses
into pql.db. Each record is a `### [DQR]-N: Title` heading inside
a markdown file. Files live in three per-type subdirectories:
- `decisions/<domain>.md` — confirmed design decisions
- `questions/<domain>.md` — open questions that may resolve into
decisions or rejected proposals
- `rejected/<domain>.md` — rejected proposals (kept for the audit
trail)
The parser infers domain from the filename stem and record type
from the parent subdirectory.
D-records that propose implementation work link to `initiative`-type
tickets via `decision_ref`. Run `pql decisions show <id>
--with-tickets` to inspect implementation status.
## Recommended domains
Start with this canonical set; create files as records land in
each domain:
- **architecture** — structural commitments (storage, layering,
languages, libraries)
- **process** — team workflow (commits, branches, releases, reviews)
- **design** — user-facing surface (UX, UI, public APIs)
- **coding-conventions** — team-internal code shape (style, lint,
file layout)
- **testing** — quality strategy (coverage, layers, gates)
You might also want, project-permitting:
- `accessibility` — if you ship user-facing software
- `security` — if you handle user data or network surfaces
- `licensing` — if you release open-source or commercial
- `documentation` — if user-docs are non-trivial
- `deployment` — if shipping is non-trivial
- `performance` — if you have perf budgets / SLOs
<!-- pql:records (auto-generated; do not edit manually) -->
## Decisions
- _(none)_
## Open questions
- _(none)_
## Rejected
- _(none)_
+13
View File
@@ -45,5 +45,18 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>net.schweitz.tatlock</string>
<key>CFBundleURLSchemes</key>
<array>
<string>net.schweitz.tatlock</string>
</array>
</dict>
</array>
</dict>
</plist>
+26 -22
View File
@@ -2,24 +2,28 @@ import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/core/api/api_interceptors.dart';
import 'package:tatlock_ui/core/config/app_config.dart';
import 'package:tatlock_ui/core/providers/annotations.dart';
import 'api_client_native.dart' if (dart.library.html) 'api_client_web.dart'
as platform;
part 'api_client.g.dart';
/// Provides the Dio instance for Core API.
@riverpod
@persistentRiverpod
Dio coreApiClient(Ref ref) {
final dio = Dio(
BaseOptions(
baseUrl: AppConfig.coreApiUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
final options = BaseOptions(
baseUrl: AppConfig.coreApiUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
);
final dio = platform.createDio(options);
dio.interceptors.addAll([
AuthInterceptor(ref),
LoggingInterceptor(),
@@ -30,20 +34,20 @@ Dio coreApiClient(Ref ref) {
}
/// Provides the Dio instance for Tatlock API.
@riverpod
@persistentRiverpod
Dio tatlockApiClient(Ref ref) {
final dio = Dio(
BaseOptions(
baseUrl: AppConfig.tatlockApiUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(minutes: 5), // Longer for LLM responses
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
final options = BaseOptions(
baseUrl: AppConfig.tatlockApiUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(minutes: 5), // Longer for LLM responses
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
);
final dio = platform.createDio(options);
dio.interceptors.addAll([
AuthInterceptor(ref),
LoggingInterceptor(),
+6
View File
@@ -0,0 +1,6 @@
import 'package:dio/dio.dart';
/// Create a Dio instance for native platforms (mobile, desktop).
Dio createDio(BaseOptions options) {
return Dio(options);
}
+12
View File
@@ -0,0 +1,12 @@
import 'package:dio/dio.dart';
import 'package:dio_web_adapter/dio_web_adapter.dart';
/// Create a Dio instance for web platform with credentials support.
///
/// Enables `withCredentials` to send cookies with requests, which is
/// required for NPM forward auth to work correctly.
Dio createDio(BaseOptions options) {
final dio = Dio(options);
dio.httpClientAdapter = BrowserHttpClientAdapter(withCredentials: true);
return dio;
}
+10 -12
View File
@@ -9,7 +9,8 @@ import 'package:tatlock_ui/core/error/app_exception.dart';
/// Adds authentication token to requests.
///
/// Skipped entirely when [AppConfig.requiresAuth] is false (LAN development).
/// - **LAN mode**: Skipped entirely (no auth required)
/// - **Web + Mobile**: Adds Bearer token from OIDC authentication
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._ref);
@@ -23,6 +24,7 @@ class AuthInterceptor extends Interceptor {
return;
}
// Add Bearer token for all platforms (web + mobile)
final authState = _ref.read(authProvider);
authState.whenData((auth) {
@@ -36,16 +38,9 @@ class AuthInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// Skip auth error handling for LAN development
if (!AppConfig.requiresAuth) {
handler.next(err);
return;
}
if (err.response?.statusCode == 401) {
// Token expired - trigger re-authentication
_ref.read(authProvider.notifier).signOut();
}
// Don't auto-signout on 401 - let calling code handle auth errors gracefully.
// Auto-signout was causing issues (e.g., theme toggle triggering logout when
// preferences API returned 401).
handler.next(err);
}
}
@@ -168,7 +163,10 @@ class ErrorInterceptor extends Interceptor {
case DioExceptionType.badCertificate:
return const NetworkException(message: 'Invalid SSL certificate');
case DioExceptionType.unknown:
// default keeps this exhaustive across dio versions (CI resolves deps
// fresh — pubspec.lock is gitignored — so DioExceptionType can gain
// cases, e.g. transformTimeout in dio >= 5.8)
default:
return NetworkException(
message: err.message ?? 'Unknown error',
cause: err,
+288
View File
@@ -0,0 +1,288 @@
import 'dart:convert' show jsonDecode, jsonEncode;
import 'dart:developer' as developer;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
import 'auth_datasource.dart';
import 'auth_state.dart';
import 'oidc_service_web.dart';
import 'permissions.dart';
import 'user_preferences.dart';
import 'web_utils.dart' as web_utils;
/// Standalone auth controller that handles OIDC flow before app starts.
///
/// This runs outside of Riverpod to avoid lifecycle issues. Call [initialize]
/// in main() before runApp(). The controller will:
/// 1. Handle callback if on /callback route (exchange code, sync, store tokens)
/// 2. Check for valid stored tokens
/// 3. Redirect to silent OIDC if no tokens (app won't continue)
///
/// Once auth is complete, [AuthProvider] can simply read the stored tokens.
class AuthController {
// Storage keys (same as AuthProvider)
static const _accessTokenKey = 'auth_access_token';
static const _refreshTokenKey = 'auth_refresh_token';
static const _expiresAtKey = 'auth_expires_at';
static const _userIdKey = 'auth_user_id';
static const _authentikIdKey = 'auth_authentik_id';
static const _userNameKey = 'auth_user_name';
static const _userEmailKey = 'auth_user_email';
static const _avatarUrlKey = 'auth_avatar_url';
static const _rolesKey = 'auth_roles';
static const _preferencesKey = 'auth_preferences';
/// Initialize auth before app starts.
///
/// Returns true if auth is ready (tokens available).
/// Returns false if redirecting (app should not continue).
/// Throws on error.
static Future<bool> initialize() async {
// Skip auth entirely for LAN mode
if (!AppConfig.requiresAuth) {
developer.log('Auth not required (LAN mode)', name: 'auth_controller');
return true;
}
// Only handle web auth here - mobile uses different flow
if (!kIsWeb) {
developer.log('Non-web platform, skipping controller init', name: 'auth_controller');
return true;
}
final currentUrl = web_utils.getCurrentUrl();
developer.log('Auth controller init, URL: $currentUrl', name: 'auth_controller');
// Check if we're on the callback route
if (currentUrl.contains('/callback')) {
return _handleCallback(currentUrl);
}
// Check for valid stored tokens
final prefs = await SharedPreferences.getInstance();
final accessToken = prefs.getString(_accessTokenKey);
if (accessToken != null) {
final expiresAtMs = prefs.getInt(_expiresAtKey);
final expiresAt = expiresAtMs != null
? DateTime.fromMillisecondsSinceEpoch(expiresAtMs)
: null;
if (expiresAt == null || expiresAt.isAfter(DateTime.now())) {
developer.log('Valid tokens found', name: 'auth_controller');
return true; // Auth ready
}
developer.log('Tokens expired', name: 'auth_controller');
}
// No valid tokens - initiate silent OIDC
developer.log('No valid tokens, starting silent OIDC', name: 'auth_controller');
await _initiateSilentOidc();
return false; // Redirecting, app should not continue
}
/// Handle the OIDC callback.
static Future<bool> _handleCallback(String url) async {
final uri = Uri.parse(url);
final code = uri.queryParameters['code'];
final state = uri.queryParameters['state'];
final error = uri.queryParameters['error'];
developer.log('Handling callback: code=${code != null}, error=$error', name: 'auth_controller');
// Handle errors
if (error != null) {
if (error == 'login_required') {
// Silent auth failed - no session, start regular OIDC
developer.log('Silent auth failed (login_required), starting regular OIDC', name: 'auth_controller');
await _initiateRegularOidc();
return false;
}
throw Exception('Auth error: $error - ${uri.queryParameters['error_description']}');
}
if (code == null || state == null) {
throw Exception('Invalid callback - missing code or state');
}
// Exchange code for tokens
developer.log('Exchanging code for tokens', name: 'auth_controller');
final oidcService = OidcServiceWeb();
final tokens = await oidcService.exchangeCode(code, state);
// Sync with core-api
developer.log('Syncing with core-api', name: 'auth_controller');
final dio = Dio(BaseOptions(
baseUrl: AppConfig.coreApiUrl,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
));
final authDatasource = AuthDatasource(dio);
final syncResponse = await authDatasource.syncUser(tokens.accessToken);
developer.log('Synced user: ${syncResponse.name}', name: 'auth_controller');
// Store credentials
await _storeAuth(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
);
// Redirect to home (removes callback params from URL)
developer.log('Auth complete, redirecting to home', name: 'auth_controller');
web_utils.redirectTo('/');
return false; // Redirecting
}
/// Initiate silent OIDC (prompt=none).
static Future<void> _initiateSilentOidc() async {
final oidcService = OidcServiceWeb();
final authUrl = await oidcService.getAuthorizationUrl(silent: true);
developer.log('Redirecting to silent OIDC', name: 'auth_controller');
web_utils.redirectTo(authUrl);
}
/// Initiate regular OIDC (shows login UI).
static Future<void> _initiateRegularOidc() async {
final oidcService = OidcServiceWeb();
final authUrl = await oidcService.getAuthorizationUrl(silent: false);
developer.log('Redirecting to regular OIDC', name: 'auth_controller');
web_utils.redirectTo(authUrl);
}
/// Store auth data.
static Future<void> _storeAuth({
required String accessToken,
String? refreshToken,
DateTime? expiresAt,
String? userId,
String? authentikId,
String? userName,
String? userEmail,
String? avatarUrl,
List<Role>? roles,
UserPreferences? preferences,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_accessTokenKey, accessToken);
if (refreshToken != null) {
await prefs.setString(_refreshTokenKey, refreshToken);
}
if (expiresAt != null) {
await prefs.setInt(_expiresAtKey, expiresAt.millisecondsSinceEpoch);
}
if (userId != null) await prefs.setString(_userIdKey, userId);
if (authentikId != null) await prefs.setString(_authentikIdKey, authentikId);
if (userName != null) await prefs.setString(_userNameKey, userName);
if (userEmail != null) await prefs.setString(_userEmailKey, userEmail);
if (avatarUrl != null) await prefs.setString(_avatarUrlKey, avatarUrl);
if (roles != null) {
final rolesJson = jsonEncode(roles.map((r) => {
'id': r.id,
'name': r.name,
'domain': r.domain.value,
'category': r.category,
'action': r.action.name,
}).toList());
await prefs.setString(_rolesKey, rolesJson);
}
if (preferences != null) {
await prefs.setString(_preferencesKey, jsonEncode(preferences.toJson()));
}
}
/// Load stored auth state (for AuthProvider to use).
static Future<AuthState> loadStoredAuth() async {
try {
final prefs = await SharedPreferences.getInstance();
final accessToken = prefs.getString(_accessTokenKey);
if (accessToken == null) {
return const AuthState();
}
final expiresAtMs = prefs.getInt(_expiresAtKey);
final expiresAt = expiresAtMs != null
? DateTime.fromMillisecondsSinceEpoch(expiresAtMs)
: null;
final rolesJson = prefs.getString(_rolesKey);
final roles = rolesJson != null ? _parseRoles(rolesJson) : <Role>[];
final prefsJson = prefs.getString(_preferencesKey);
final preferences = prefsJson != null
? UserPreferences.fromJson(jsonDecode(prefsJson) as Map<String, dynamic>)
: null;
return AuthState(
isAuthenticated: true,
accessToken: accessToken,
refreshToken: prefs.getString(_refreshTokenKey),
expiresAt: expiresAt,
userId: prefs.getString(_userIdKey),
authentikId: prefs.getString(_authentikIdKey),
userName: prefs.getString(_userNameKey),
userEmail: prefs.getString(_userEmailKey),
avatarUrl: prefs.getString(_avatarUrlKey),
roles: roles,
preferences: preferences,
);
} catch (e) {
developer.log('Failed to load stored auth: $e', name: 'auth_controller');
return const AuthState();
}
}
static List<Role> _parseRoles(String json) {
try {
final list = jsonDecode(json) as List<dynamic>;
return list.map((item) {
final map = item as Map<String, dynamic>;
final domain = Domain.fromString(map['domain'] as String);
final action = Action.fromString(map['action'] as String);
if (domain == null || action == null) return null;
return Role(
id: map['id'] as String,
name: map['name'] as String,
domain: domain,
category: map['category'] as String? ?? 'general',
action: action,
);
}).whereType<Role>().toList();
} catch (e) {
return [];
}
}
/// Clear stored auth (for logout).
static Future<void> clearAuth() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_accessTokenKey);
await prefs.remove(_refreshTokenKey);
await prefs.remove(_expiresAtKey);
await prefs.remove(_userIdKey);
await prefs.remove(_authentikIdKey);
await prefs.remove(_userNameKey);
await prefs.remove(_userEmailKey);
await prefs.remove(_avatarUrlKey);
await prefs.remove(_rolesKey);
await prefs.remove(_preferencesKey);
}
}
+133
View File
@@ -0,0 +1,133 @@
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client.dart';
import 'permissions.dart';
import 'user_preferences.dart';
part 'auth_datasource.g.dart';
/// Response from POST /auth/sync endpoint.
class AuthSyncResponse {
const AuthSyncResponse({
required this.userId,
required this.authentikId,
required this.email,
required this.name,
this.avatarUrl,
required this.roles,
required this.preferences,
required this.isNewUser,
});
final String userId;
final String authentikId;
final String email;
final String name;
final String? avatarUrl;
final List<Role> roles;
final UserPreferences preferences;
final bool isNewUser;
factory AuthSyncResponse.fromJson(Map<String, dynamic> json) {
final user = json['user'] as Map<String, dynamic>;
final rolesJson = json['roles'] as List<dynamic>;
final prefsJson = json['preferences'] as Map<String, dynamic>;
return AuthSyncResponse(
userId: user['id'] as String,
authentikId: user['authentik_id'] as String,
email: user['email'] as String,
name: user['name'] as String,
avatarUrl: user['avatar_url'] as String?,
roles: rolesJson.map((r) => _parseRole(r as Map<String, dynamic>)).toList(),
preferences: UserPreferences.fromJson(prefsJson),
isNewUser: json['is_new_user'] as bool,
);
}
}
/// Parse a role from API JSON.
Role _parseRole(Map<String, dynamic> json) {
final name = json['name'] as String;
final domainStr = json['domain'] as String;
final category = json['category'] as String? ?? 'general';
final actionStr = json['action'] as String;
final domain = Domain.fromString(domainStr);
final action = Action.fromString(actionStr);
if (domain == null || action == null) {
// Return a placeholder role for unknown domains/actions
return Role(
id: json['id'] as String,
name: name,
domain: Domain.admin, // Fallback
category: category,
action: Action.viewer, // Fallback - least privilege
);
}
return Role(
id: json['id'] as String,
name: name,
domain: domain,
category: category,
action: action,
);
}
/// Datasource for auth API endpoints.
class AuthDatasource {
AuthDatasource(this._dio);
final Dio _dio;
/// Sync user with core-api after OIDC authentication.
///
/// Sends the OIDC access token to core-api, which validates it with Authentik
/// and returns the user profile, roles, and preferences.
Future<AuthSyncResponse> syncUser(String accessToken) async {
final response = await _dio.post<Map<String, dynamic>>(
'/auth/sync',
data: {'access_token': accessToken},
);
return AuthSyncResponse.fromJson(response.data!);
}
/// Get current user profile via NPM forward auth.
///
/// This endpoint reads X-authentik-* headers set by NPM forward auth.
/// Returns user profile if authenticated via the proxy.
/// Throws 401 if not authenticated or accessing directly.
Future<AuthSyncResponse> getCurrentUser() async {
final response = await _dio.get<Map<String, dynamic>>('/auth/users/me');
return AuthSyncResponse.fromJson(response.data!);
}
/// Update user preferences.
Future<UserPreferences> updatePreferences({
String? theme,
String? defaultRoom,
Map<String, dynamic>? preferencesJson,
}) async {
final data = <String, dynamic>{};
if (theme != null) data['theme'] = theme;
if (defaultRoom != null) data['default_room'] = defaultRoom;
if (preferencesJson != null) data['preferences_json'] = preferencesJson;
final response = await _dio.patch<Map<String, dynamic>>(
'/auth/users/me/preferences',
data: data,
);
return UserPreferences.fromJson(response.data!);
}
}
/// Provider for the auth datasource.
@riverpod
AuthDatasource authDatasource(Ref ref) {
return AuthDatasource(ref.watch(coreApiClientProvider));
}
+330 -23
View File
@@ -1,28 +1,49 @@
import 'dart:convert' show jsonDecode, jsonEncode;
import 'dart:developer' as developer;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
import '../providers/annotations.dart';
import 'auth_datasource.dart';
import 'auth_state.dart';
import 'oidc_service.dart';
import 'oidc_service_web.dart';
import 'permissions.dart';
import 'user_preferences.dart';
import 'web_utils.dart' as web_utils;
part 'auth_provider.g.dart';
/// Provides authentication state and operations.
///
/// Note: Full OIDC implementation with flutter_appauth requires
/// native platform configuration. For now, this provides the
/// state management infrastructure.
@riverpod
/// Supports OIDC Authorization Code flow with PKCE on all platforms:
/// - **Web**: Browser redirect to Authentik, callback via /callback route
/// - **Mobile**: flutter_appauth with custom URL scheme
///
/// After OIDC authentication, syncs with core-api via POST /auth/sync
/// to get user profile, roles, and preferences.
@persistentRiverpod
class AuthNotifier extends _$AuthNotifier {
// Storage keys
static const _accessTokenKey = 'auth_access_token';
static const _refreshTokenKey = 'auth_refresh_token';
static const _expiresAtKey = 'auth_expires_at';
static const _userIdKey = 'auth_user_id';
static const _authentikIdKey = 'auth_authentik_id';
static const _userNameKey = 'auth_user_name';
static const _userEmailKey = 'auth_user_email';
static const _avatarUrlKey = 'auth_avatar_url';
static const _rolesKey = 'auth_roles';
static const _preferencesKey = 'auth_preferences';
@override
Future<AuthState> build() async {
// AuthController.initialize() in main() handles OIDC flow before app starts.
// By the time we get here, tokens are already stored (or we're in LAN mode).
// Just load the stored auth state.
return _loadStoredAuth();
}
@@ -40,17 +61,36 @@ class AuthNotifier extends _$AuthNotifier {
? DateTime.fromMillisecondsSinceEpoch(expiresAtMs)
: null;
// Load roles from JSON
final rolesJson = prefs.getString(_rolesKey);
final roles = rolesJson != null ? _parseRoles(rolesJson) : <Role>[];
// Load preferences from JSON
final prefsJson = prefs.getString(_preferencesKey);
final preferences = prefsJson != null
? UserPreferences.fromJson(jsonDecode(prefsJson) as Map<String, dynamic>)
: null;
final authState = AuthState(
isAuthenticated: true,
accessToken: accessToken,
refreshToken: prefs.getString(_refreshTokenKey),
expiresAt: expiresAt,
userId: prefs.getString(_userIdKey),
authentikId: prefs.getString(_authentikIdKey),
userName: prefs.getString(_userNameKey),
userEmail: prefs.getString(_userEmailKey),
avatarUrl: prefs.getString(_avatarUrlKey),
roles: roles,
preferences: preferences,
);
// Check if token is expired
// Check if token is expired - try to refresh
if (authState.isTokenExpired && authState.refreshToken != null) {
developer.log('Token expired, attempting refresh', name: 'auth');
return _tryRefreshToken(authState);
}
if (authState.isTokenExpired) {
developer.log('Stored token expired, clearing auth', name: 'auth');
await _clearStoredAuth();
@@ -65,29 +105,285 @@ class AuthNotifier extends _$AuthNotifier {
}
}
/// Sign in with OIDC (placeholder for flutter_appauth integration).
/// Parse roles from stored JSON.
List<Role> _parseRoles(String json) {
try {
final list = jsonDecode(json) as List<dynamic>;
return list.map((item) {
final map = item as Map<String, dynamic>;
final domain = Domain.fromString(map['domain'] as String);
final action = Action.fromString(map['action'] as String);
if (domain == null || action == null) {
return null;
}
return Role(
id: map['id'] as String,
name: map['name'] as String,
domain: domain,
category: map['category'] as String? ?? 'general',
action: action,
);
}).whereType<Role>().toList();
} catch (e) {
developer.log('Failed to parse roles: $e', name: 'auth');
return [];
}
}
/// Try to refresh the access token.
Future<AuthState> _tryRefreshToken(AuthState currentState) async {
if (currentState.refreshToken == null) {
await _clearStoredAuth();
return const AuthState();
}
try {
final oidcService = ref.read(oidcServiceProvider);
final tokens = await oidcService.refreshToken(currentState.refreshToken!);
// Update stored tokens
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_accessTokenKey, tokens.accessToken);
if (tokens.refreshToken != null) {
await prefs.setString(_refreshTokenKey, tokens.refreshToken!);
}
await prefs.setInt(_expiresAtKey, tokens.expiresAt.millisecondsSinceEpoch);
developer.log('Token refreshed successfully', name: 'auth');
return currentState.copyWith(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken ?? currentState.refreshToken,
expiresAt: tokens.expiresAt,
);
} catch (e) {
developer.log('Token refresh failed: $e', name: 'auth');
await _clearStoredAuth();
return const AuthState();
}
}
/// Sign in with the appropriate method for the platform.
///
/// - **Web**: Redirects to Authentik for OIDC authentication
/// - **Mobile**: Opens Authentik login via OIDC, then syncs with core-api
Future<void> signIn() async {
// TODO: Implement OIDC flow with flutter_appauth
// For now, this is a placeholder that will be implemented
// when native platform configuration is complete.
developer.log('Sign in requested - OIDC not yet configured', name: 'auth');
if (!AppConfig.requiresAuth) {
developer.log('Auth not required in LAN mode', name: 'auth');
// In LAN mode, set a minimal authenticated state
state = const AsyncData(AuthState(isAuthenticated: true));
return;
}
// Web: Use OIDC flow with browser redirect
if (kIsWeb) {
developer.log('Web sign-in: starting OIDC flow', name: 'auth');
state = const AsyncLoading();
try {
final oidcService = OidcServiceWeb();
final authUrl = await oidcService.getAuthorizationUrl();
developer.log('Redirecting to: $authUrl', name: 'auth');
web_utils.redirectTo(authUrl);
// Browser will redirect, so we don't update state here
} catch (e, stack) {
developer.log('Failed to start OIDC flow: $e', name: 'auth');
state = AsyncError(e, stack);
}
return;
}
// Mobile: Use OIDC flow with flutter_appauth
state = const AsyncLoading();
try {
// Step 1: OIDC authentication with Authentik
developer.log('Starting OIDC authentication', name: 'auth');
final oidcService = ref.read(oidcServiceProvider);
final tokens = await oidcService.signIn();
// Step 2: Sync with core-api to get user profile and roles
developer.log('Syncing with core-api', name: 'auth');
final authDatasource = ref.read(authDatasourceProvider);
final syncResponse = await authDatasource.syncUser(tokens.accessToken);
// Step 3: Store credentials and user data
await _storeAuth(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
);
state = AsyncData(AuthState(
isAuthenticated: true,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
));
developer.log(
'Authenticated as ${syncResponse.name} with ${syncResponse.roles.length} roles',
name: 'auth',
);
} on OidcException catch (e) {
developer.log('OIDC authentication failed: $e', name: 'auth');
state = AsyncError(e, StackTrace.current);
} catch (e, stack) {
developer.log('Authentication failed: $e', name: 'auth');
state = AsyncError(e, stack);
}
}
/// Handle OIDC callback after Authentik redirects back (web only).
///
/// [code] is the authorization code from the callback URL.
/// [state] is the state parameter for CSRF verification.
Future<void> handleOidcCallback(String code, String callbackState) async {
if (!kIsWeb) {
developer.log('handleOidcCallback called on non-web platform', name: 'auth');
return;
}
developer.log('Handling OIDC callback', name: 'auth');
state = const AsyncLoading();
try {
// Step 1: Exchange code for tokens
final oidcService = OidcServiceWeb();
final tokens = await oidcService.exchangeCode(code, callbackState);
// Step 2: Sync with core-api to get user profile and roles
developer.log('Syncing with core-api', name: 'auth');
final authDatasource = ref.read(authDatasourceProvider);
final syncResponse = await authDatasource.syncUser(tokens.accessToken);
developer.log(
'Synced user: ${syncResponse.name} with ${syncResponse.roles.length} roles',
name: 'auth',
);
// Step 3: Store credentials and user data from sync response
await _storeAuth(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
);
state = AsyncData(AuthState(
isAuthenticated: true,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
));
developer.log('Authenticated as ${syncResponse.name}', name: 'auth');
// Clean up the URL by removing the query parameters
web_utils.replaceUrl('/');
} on OidcException catch (e) {
developer.log('OIDC callback failed: $e', name: 'auth');
state = AsyncError(e, StackTrace.current);
} catch (e, stack) {
developer.log('Callback handling failed: $e', name: 'auth');
state = AsyncError(e, stack);
}
}
/// Sign out and clear stored credentials.
///
/// On web, also redirects to Authentik's logout endpoint to end the SSO session.
Future<void> signOut() async {
// Clear local storage first
await _clearStoredAuth();
state = const AsyncData(AuthState());
developer.log('Signed out', name: 'auth');
developer.log('Signed out locally', name: 'auth');
// On web, redirect to Authentik logout to end SSO session
if (kIsWeb && AppConfig.requiresAuth) {
try {
final oidcService = OidcServiceWeb();
final logoutUrl = await oidcService.getLogoutUrl();
developer.log('Redirecting to Authentik logout', name: 'auth');
web_utils.redirectTo(logoutUrl);
} catch (e) {
developer.log('Failed to get logout URL: $e', name: 'auth');
// Local logout already done, just reload to trigger re-auth
web_utils.redirectTo('/');
}
}
}
/// Update auth state (called after successful OIDC flow).
Future<void> setAuthenticated({
/// Update user preferences.
Future<void> updatePreferences({
String? theme,
String? defaultRoom,
Map<String, dynamic>? preferencesJson,
}) async {
final currentState = state.value;
if (currentState == null || !currentState.isAuthenticated) return;
try {
final authDatasource = ref.read(authDatasourceProvider);
final newPrefs = await authDatasource.updatePreferences(
theme: theme,
defaultRoom: defaultRoom,
preferencesJson: preferencesJson,
);
// Update stored preferences
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_preferencesKey, jsonEncode(newPrefs.toJson()));
state = AsyncData(currentState.copyWith(preferences: newPrefs));
developer.log('Preferences updated', name: 'auth');
} catch (e) {
developer.log('Failed to update preferences: $e', name: 'auth');
rethrow;
}
}
/// Store authentication data to SharedPreferences.
Future<void> _storeAuth({
required String accessToken,
String? refreshToken,
DateTime? expiresAt,
String? userId,
String? authentikId,
String? userName,
String? userEmail,
String? avatarUrl,
List<Role>? roles,
UserPreferences? preferences,
}) async {
final prefs = await SharedPreferences.getInstance();
@@ -99,20 +395,27 @@ class AuthNotifier extends _$AuthNotifier {
await prefs.setInt(_expiresAtKey, expiresAt.millisecondsSinceEpoch);
}
if (userId != null) await prefs.setString(_userIdKey, userId);
if (authentikId != null) await prefs.setString(_authentikIdKey, authentikId);
if (userName != null) await prefs.setString(_userNameKey, userName);
if (userEmail != null) await prefs.setString(_userEmailKey, userEmail);
if (avatarUrl != null) await prefs.setString(_avatarUrlKey, avatarUrl);
state = AsyncData(AuthState(
isAuthenticated: true,
accessToken: accessToken,
refreshToken: refreshToken,
expiresAt: expiresAt,
userId: userId,
userName: userName,
userEmail: userEmail,
));
// Store roles as JSON
if (roles != null) {
final rolesJson = jsonEncode(roles.map((r) => {
'id': r.id,
'name': r.name,
'domain': r.domain.value,
'category': r.category,
'action': r.action.name,
}).toList());
await prefs.setString(_rolesKey, rolesJson);
}
developer.log('Authenticated as $userName', name: 'auth');
// Store preferences as JSON
if (preferences != null) {
await prefs.setString(_preferencesKey, jsonEncode(preferences.toJson()));
}
}
Future<void> _clearStoredAuth() async {
@@ -121,7 +424,11 @@ class AuthNotifier extends _$AuthNotifier {
await prefs.remove(_refreshTokenKey);
await prefs.remove(_expiresAtKey);
await prefs.remove(_userIdKey);
await prefs.remove(_authentikIdKey);
await prefs.remove(_userNameKey);
await prefs.remove(_userEmailKey);
await prefs.remove(_avatarUrlKey);
await prefs.remove(_rolesKey);
await prefs.remove(_preferencesKey);
}
}
+37 -1
View File
@@ -1,18 +1,46 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'permissions.dart';
import 'user_preferences.dart';
part 'auth_state.freezed.dart';
/// Authentication state.
/// Authentication state including user profile, roles, and preferences.
@freezed
sealed class AuthState with _$AuthState {
const factory AuthState({
/// Whether user is authenticated.
@Default(false) bool isAuthenticated,
/// OIDC access token.
String? accessToken,
/// OIDC refresh token.
String? refreshToken,
/// Token expiration time.
DateTime? expiresAt,
/// Internal user ID (from core-api).
String? userId,
/// Authentik user ID.
String? authentikId,
/// User display name.
String? userName,
/// User email address.
String? userEmail,
/// User avatar URL.
String? avatarUrl,
/// User's permission roles.
@Default([]) List<Role> roles,
/// User preferences.
UserPreferences? preferences,
}) = _AuthState;
const AuthState._();
@@ -23,4 +51,12 @@ sealed class AuthState with _$AuthState {
// Consider expired if less than 1 minute remaining
return DateTime.now().isAfter(expiresAt!.subtract(const Duration(minutes: 1)));
}
/// Check if user has the specified permission.
bool hasPermission(Domain domain, Action action, {String category = 'general'}) {
return roles.hasPermission(domain, action, category: category);
}
/// Check if user is a global admin.
bool get isGlobalAdmin => roles.isGlobalAdmin;
}
+115
View File
@@ -0,0 +1,115 @@
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../config/app_config.dart';
part 'oidc_service.g.dart';
/// OIDC token response containing access and refresh tokens.
class OidcTokens {
const OidcTokens({
required this.accessToken,
required this.refreshToken,
required this.expiresAt,
this.idToken,
});
final String accessToken;
final String? refreshToken;
final DateTime expiresAt;
final String? idToken;
}
/// Service for OIDC authentication using flutter_appauth.
///
/// Handles the Authorization Code flow with PKCE for secure authentication
/// against Authentik.
class OidcService {
OidcService({FlutterAppAuth? appAuth}) : _appAuth = appAuth ?? const FlutterAppAuth();
final FlutterAppAuth _appAuth;
/// OIDC scopes to request.
static const _scopes = ['openid', 'profile', 'email', 'offline_access'];
/// Redirect URI for the app.
static String get _redirectUri => '${AppConfig.authRedirectScheme}://callback';
/// Start the authorization code flow.
///
/// Opens a browser/webview for user to authenticate with Authentik,
/// then exchanges the authorization code for tokens.
///
/// Throws [OidcException] if authentication fails.
Future<OidcTokens> signIn() async {
try {
final result = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
AppConfig.authClientId,
_redirectUri,
discoveryUrl: AppConfig.authDiscoveryUrl,
scopes: _scopes,
),
);
if (result.accessToken == null) {
throw OidcException('Authorization failed: no access token');
}
return OidcTokens(
accessToken: result.accessToken!,
refreshToken: result.refreshToken,
expiresAt: result.accessTokenExpirationDateTime ?? DateTime.now().add(const Duration(hours: 1)),
idToken: result.idToken,
);
} on Exception catch (e) {
throw OidcException('Authorization failed: $e');
}
}
/// Refresh the access token using a refresh token.
///
/// Throws [OidcException] if refresh fails.
Future<OidcTokens> refreshToken(String refreshToken) async {
try {
final result = await _appAuth.token(
TokenRequest(
AppConfig.authClientId,
_redirectUri,
discoveryUrl: AppConfig.authDiscoveryUrl,
refreshToken: refreshToken,
scopes: _scopes,
),
);
if (result.accessToken == null) {
throw OidcException('Token refresh failed: no access token');
}
return OidcTokens(
accessToken: result.accessToken!,
refreshToken: result.refreshToken ?? refreshToken,
expiresAt: result.accessTokenExpirationDateTime ?? DateTime.now().add(const Duration(hours: 1)),
idToken: result.idToken,
);
} on Exception catch (e) {
throw OidcException('Token refresh failed: $e');
}
}
}
/// Exception thrown when OIDC operations fail.
class OidcException implements Exception {
const OidcException(this.message);
final String message;
@override
String toString() => 'OidcException: $message';
}
/// Provider for the OIDC service.
@riverpod
OidcService oidcService(Ref ref) {
return OidcService();
}
+245
View File
@@ -0,0 +1,245 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import '../config/app_config.dart';
import 'oidc_service.dart';
import 'web_utils.dart' as web_utils;
/// Web implementation of OIDC service using browser redirect flow.
///
/// Uses Authorization Code flow with PKCE for secure authentication.
/// On web, we can't use flutter_appauth, so we implement the flow manually
/// using browser redirects and URL parsing.
class OidcServiceWeb implements OidcService {
OidcServiceWeb({Dio? dio}) : _dio = dio ?? Dio();
final Dio _dio;
/// OIDC scopes to request.
static const _scopes = ['openid', 'profile', 'email', 'offline_access'];
/// Redirect URI for web.
static String get _redirectUri => '${AppConfig.webBaseUrl}/callback';
// SessionStorage keys for PKCE state (persists across redirect)
static const _codeVerifierKey = 'oidc_code_verifier';
static const _stateKey = 'oidc_state';
/// Get the authorization URL to redirect the browser to.
///
/// Returns a URL that the browser should navigate to for authentication.
/// The [codeVerifier] and [state] are stored for later verification.
///
/// If [silent] is true, adds `prompt=none` to skip login UI.
/// This is used when the user already has an Authentik session (via NPM).
/// Authentik will instantly redirect back with a code, or return an error
/// if there's no valid session.
Future<String> getAuthorizationUrl({bool silent = false}) async {
// Fetch OIDC discovery document
final discovery = await _fetchDiscovery();
final authEndpoint = discovery['authorization_endpoint'] as String;
// Generate PKCE code verifier and challenge
final codeVerifier = _generateCodeVerifier();
final codeChallenge = _generateCodeChallenge(codeVerifier);
// Generate state for CSRF protection
final state = _generateRandomString(32);
// Store PKCE state in sessionStorage (persists across redirect)
web_utils.setSessionStorage(_codeVerifierKey, codeVerifier);
web_utils.setSessionStorage(_stateKey, state);
// Build authorization URL
final params = {
'client_id': AppConfig.authClientId,
'redirect_uri': _redirectUri,
'response_type': 'code',
'scope': _scopes.join(' '),
'code_challenge': codeChallenge,
'code_challenge_method': 'S256',
'state': state,
if (silent) 'prompt': 'none', // Silent auth - no UI, instant redirect
};
final uri = Uri.parse(authEndpoint).replace(queryParameters: params);
developer.log('Authorization URL (silent=$silent): $uri', name: 'oidc_web');
return uri.toString();
}
/// Exchange authorization code for tokens.
///
/// Call this after the browser redirects back with the authorization code.
/// [code] is the authorization code from the callback URL.
/// [state] is the state parameter from the callback URL (verified for CSRF).
Future<OidcTokens> exchangeCode(String code, String state) async {
// Retrieve PKCE state from sessionStorage
final storedState = web_utils.getSessionStorage(_stateKey);
final codeVerifier = web_utils.getSessionStorage(_codeVerifierKey);
developer.log('Stored state: $storedState, received state: $state', name: 'oidc_web');
developer.log('Code verifier present: ${codeVerifier != null}', name: 'oidc_web');
// Verify state matches
if (storedState == null || state != storedState) {
_clearPkceState();
throw OidcException('State mismatch - possible CSRF attack');
}
if (codeVerifier == null) {
_clearPkceState();
throw OidcException('No code verifier - flow not started properly');
}
try {
// Fetch token endpoint from discovery
final discovery = await _fetchDiscovery();
final tokenEndpoint = discovery['token_endpoint'] as String;
developer.log('Exchanging code at: $tokenEndpoint', name: 'oidc_web');
// Exchange code for tokens
final response = await _dio.post<Map<String, dynamic>>(
tokenEndpoint,
data: {
'grant_type': 'authorization_code',
'client_id': AppConfig.authClientId,
'redirect_uri': _redirectUri,
'code': code,
'code_verifier': codeVerifier,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
);
final data = response.data!;
developer.log('Token exchange successful', name: 'oidc_web');
// Clear stored PKCE state
_clearPkceState();
return OidcTokens(
accessToken: data['access_token'] as String,
refreshToken: data['refresh_token'] as String?,
expiresAt: DateTime.now().add(
Duration(seconds: data['expires_in'] as int? ?? 3600),
),
idToken: data['id_token'] as String?,
);
} on DioException catch (e) {
developer.log('Token exchange failed: $e', name: 'oidc_web');
_clearPkceState();
throw OidcException('Token exchange failed: ${e.message}');
}
}
/// Clear PKCE state from sessionStorage.
void _clearPkceState() {
web_utils.removeSessionStorage(_codeVerifierKey);
web_utils.removeSessionStorage(_stateKey);
}
/// Not used on web - use [getAuthorizationUrl] and [exchangeCode] instead.
@override
Future<OidcTokens> signIn() async {
throw OidcException(
'signIn() not supported on web. Use getAuthorizationUrl() and exchangeCode() instead.',
);
}
/// Refresh the access token using a refresh token.
@override
Future<OidcTokens> refreshToken(String refreshToken) async {
try {
final discovery = await _fetchDiscovery();
final tokenEndpoint = discovery['token_endpoint'] as String;
final response = await _dio.post<Map<String, dynamic>>(
tokenEndpoint,
data: {
'grant_type': 'refresh_token',
'client_id': AppConfig.authClientId,
'refresh_token': refreshToken,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
);
final data = response.data!;
return OidcTokens(
accessToken: data['access_token'] as String,
refreshToken: data['refresh_token'] as String? ?? refreshToken,
expiresAt: DateTime.now().add(
Duration(seconds: data['expires_in'] as int? ?? 3600),
),
idToken: data['id_token'] as String?,
);
} on DioException catch (e) {
throw OidcException('Token refresh failed: ${e.message}');
}
}
/// Fetch OIDC discovery document.
Future<Map<String, dynamic>> _fetchDiscovery() async {
final response = await _dio.get<Map<String, dynamic>>(
AppConfig.authDiscoveryUrl,
);
return response.data!;
}
/// Generate a random code verifier for PKCE.
String _generateCodeVerifier() {
return _generateRandomString(64);
}
/// Generate code challenge from verifier using S256.
String _generateCodeChallenge(String verifier) {
final bytes = utf8.encode(verifier);
final digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
/// Generate a random string of given length.
String _generateRandomString(int length) {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
final random = Random.secure();
return List.generate(length, (_) => chars[random.nextInt(chars.length)])
.join();
}
/// Get the logout URL to redirect the browser to for SSO logout.
///
/// [idToken] is optional but recommended for logout verification.
/// After logout, Authentik redirects back to [postLogoutRedirectUri].
Future<String> getLogoutUrl({String? idToken}) async {
final discovery = await _fetchDiscovery();
final endSessionEndpoint = discovery['end_session_endpoint'] as String?;
if (endSessionEndpoint == null) {
// Fallback: just redirect to home, local state already cleared
developer.log('No end_session_endpoint in discovery', name: 'oidc_web');
return AppConfig.webBaseUrl;
}
final params = <String, String>{
'post_logout_redirect_uri': AppConfig.webBaseUrl,
};
if (idToken != null) {
params['id_token_hint'] = idToken;
}
final uri = Uri.parse(endSessionEndpoint).replace(queryParameters: params);
developer.log('Logout URL: $uri', name: 'oidc_web');
return uri.toString();
}
}
+110
View File
@@ -0,0 +1,110 @@
import 'package:flutter/widgets.dart' hide Action;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart';
import 'permissions.dart';
/// A widget that conditionally renders its child based on user permissions.
///
/// Example:
/// ```dart
/// PermissionGate(
/// domain: Domain.controlRoom,
/// action: Action.admin,
/// child: DeleteButton(),
/// fallback: Text('No permission'),
/// )
/// ```
class PermissionGate extends ConsumerWidget {
const PermissionGate({
super.key,
required this.domain,
required this.action,
this.category = 'general',
required this.child,
this.fallback,
});
/// The domain required for this permission.
final Domain domain;
/// The action level required (viewer, user, editor, admin).
final Action action;
/// Optional category within the domain (defaults to 'general').
final String category;
/// Widget to show when user has permission.
final Widget child;
/// Widget to show when user lacks permission (defaults to empty).
final Widget? fallback;
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final hasPermission = authState.maybeWhen(
data: (state) => state.hasPermission(domain, action, category: category),
orElse: () => false,
);
if (hasPermission) {
return child;
}
return fallback ?? const SizedBox.shrink();
}
}
/// A widget that shows its child only if the user is a global admin.
class AdminGate extends ConsumerWidget {
const AdminGate({
super.key,
required this.child,
this.fallback,
});
/// Widget to show when user is admin.
final Widget child;
/// Widget to show when user is not admin (defaults to empty).
final Widget? fallback;
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final isAdmin = authState.maybeWhen(
data: (state) => state.isGlobalAdmin,
orElse: () => false,
);
if (isAdmin) {
return child;
}
return fallback ?? const SizedBox.shrink();
}
}
/// Extension for checking permissions in code.
extension PermissionCheck on WidgetRef {
/// Check if the current user has a specific permission.
bool hasPermission(Domain domain, Action action, {String category = 'general'}) {
final authState = read(authProvider);
return authState.maybeWhen(
data: (state) => state.hasPermission(domain, action, category: category),
orElse: () => false,
);
}
/// Check if the current user is a global admin.
bool get isGlobalAdmin {
final authState = read(authProvider);
return authState.maybeWhen(
data: (state) => state.isGlobalAdmin,
orElse: () => false,
);
}
}
+137
View File
@@ -0,0 +1,137 @@
// Permission system for role-based access control.
//
// Roles follow the format: `domain.category:action`
// - Domain: Feature area (control-room, media, etc.)
// - Category: Sub-area within domain (default: general)
// - Action: Permission level (viewer < user < editor < admin)
/// Permission domains matching feature areas.
enum Domain {
controlRoom('control-room'),
library('library'),
media('media'),
ai('ai'),
housekeeper('housekeeper'),
developer('developer'),
documents('documents'),
gaming('gaming'),
admin('admin');
const Domain(this.value);
/// The API string value for this domain.
final String value;
/// Parse a domain string from API response.
static Domain? fromString(String value) {
for (final domain in Domain.values) {
if (domain.value == value) return domain;
}
return null;
}
}
/// Permission actions in hierarchical order.
///
/// Higher actions imply lower ones:
/// - admin implies editor, user, viewer
/// - editor implies user, viewer
/// - user implies viewer
enum Action {
viewer(1),
user(2),
editor(3),
admin(4);
const Action(this.level);
/// Numeric level for comparison (higher = more permissions).
final int level;
/// Check if this action grants at least the required action.
bool grants(Action required) => level >= required.level;
/// Parse an action string from API response.
static Action? fromString(String value) {
for (final action in Action.values) {
if (action.name == value) return action;
}
return null;
}
}
/// A permission role assigned to a user.
///
/// Roles are parsed from the API format: `domain.category:action`
class Role {
const Role({
required this.id,
required this.name,
required this.domain,
required this.category,
required this.action,
});
/// Unique role ID.
final String id;
/// Full role name (e.g., "control-room.general:admin").
final String name;
/// Permission domain.
final Domain domain;
/// Permission category (usually "general").
final String category;
/// Permission action level.
final Action action;
/// Check if this role grants access for the given domain and action.
///
/// Global admin (`admin.general:admin`) grants access to everything.
/// Otherwise, domain and category must match, and action level must be sufficient.
bool grants(Domain domain, Action action, {String category = 'general'}) {
// Global admin override
if (this.domain == Domain.admin &&
this.category == 'general' &&
this.action == Action.admin) {
return true;
}
// Check domain and category match
if (this.domain != domain || this.category != category) {
return false;
}
// Check action hierarchy
return this.action.grants(action);
}
@override
String toString() => 'Role($name)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Role && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}
/// Extension for checking permissions on a list of roles.
extension RoleListPermissions on List<Role> {
/// Check if any role grants the required permission.
bool hasPermission(Domain domain, Action action, {String category = 'general'}) {
return any((role) => role.grants(domain, action, category: category));
}
/// Check if any role grants any of the required permissions.
bool hasAnyPermission(List<(Domain, Action)> permissions) {
return permissions.any((p) => hasPermission(p.$1, p.$2));
}
/// Check if user is a global admin.
bool get isGlobalAdmin => hasPermission(Domain.admin, Action.admin);
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_preferences.freezed.dart';
part 'user_preferences.g.dart';
/// User preferences synced from core-api.
@freezed
sealed class UserPreferences with _$UserPreferences {
const factory UserPreferences({
/// Theme preference: system, light, dark
@Default('system') String theme,
/// Default room for housekeeping
@Default('front-hall') String defaultRoom,
/// Extended preferences as JSON
@Default({}) Map<String, dynamic> preferencesJson,
}) = _UserPreferences;
factory UserPreferences.fromJson(Map<String, dynamic> json) =>
_$UserPreferencesFromJson(json);
}
+7
View File
@@ -0,0 +1,7 @@
/// Web utilities with conditional imports.
///
/// Uses stub implementation on non-web platforms.
library;
export 'web_utils_stub.dart'
if (dart.library.js_interop) 'web_utils_web.dart';
+32
View File
@@ -0,0 +1,32 @@
/// Stub for non-web platforms.
library;
/// Redirect to a URL (no-op on non-web).
void redirectTo(String url) {
throw UnsupportedError('redirectTo is only supported on web');
}
/// Get current URL (no-op on non-web).
String getCurrentUrl() {
throw UnsupportedError('getCurrentUrl is only supported on web');
}
/// Replace current URL without navigation (no-op on non-web).
void replaceUrl(String url) {
throw UnsupportedError('replaceUrl is only supported on web');
}
/// Store a value in sessionStorage (no-op on non-web).
void setSessionStorage(String key, String value) {
throw UnsupportedError('setSessionStorage is only supported on web');
}
/// Get a value from sessionStorage (no-op on non-web).
String? getSessionStorage(String key) {
throw UnsupportedError('getSessionStorage is only supported on web');
}
/// Remove a value from sessionStorage (no-op on non-web).
void removeSessionStorage(String key) {
throw UnsupportedError('removeSessionStorage is only supported on web');
}
+34
View File
@@ -0,0 +1,34 @@
/// Web-specific utilities for browser operations.
library;
import 'package:web/web.dart' as web;
/// Redirect the browser to a URL.
void redirectTo(String url) {
web.window.location.href = url;
}
/// Get the current browser URL.
String getCurrentUrl() {
return web.window.location.href;
}
/// Replace the current URL in history without navigation.
void replaceUrl(String url) {
web.window.history.replaceState(null, '', url);
}
/// Store a value in sessionStorage.
void setSessionStorage(String key, String value) {
web.window.sessionStorage.setItem(key, value);
}
/// Get a value from sessionStorage.
String? getSessionStorage(String key) {
return web.window.sessionStorage.getItem(key);
}
/// Remove a value from sessionStorage.
void removeSessionStorage(String key) {
web.window.sessionStorage.removeItem(key);
}
+22 -25
View File
@@ -1,35 +1,37 @@
/// Application configuration from compile-time environment variables.
///
/// ## Development (LAN - no auth required)
/// Default values use LAN IPs for local development:
/// ## Defaults (public URLs - auth required)
/// Default values use the public https://*.schweitz.net domains so browser
/// clients work from any machine (NPM fronts them; LAN clients bypass
/// Authentik via source-IP rules):
/// ```bash
/// flutter run -d chrome
/// ```
///
/// ## Production (public URLs - auth required)
/// Override with public URLs for production builds:
/// ## Development (LAN - no auth required)
/// Override with LAN URLs to hit services directly without OIDC:
/// ```bash
/// flutter build web \
/// --dart-define=CORE_API_URL=https://api.schweitz.net \
/// --dart-define=TATLOCK_API_URL=https://tatlock.schweitz.net
/// flutter run -d chrome \
/// --dart-define=CORE_API_URL=http://192.168.86.149:8083 \
/// --dart-define=TATLOCK_API_URL=http://192.168.86.149:8000
/// ```
class AppConfig {
AppConfig._();
/// Core API base URL
/// - LAN default: No auth required
/// - Production: https://api.schweitz.net (requires OIDC)
/// - Default: https://api.schweitz.net (requires OIDC)
/// - LAN override: http://192.168.86.149:8083 (no auth)
static const coreApiUrl = String.fromEnvironment(
'CORE_API_URL',
defaultValue: 'http://192.168.86.149:8083',
defaultValue: 'https://api.schweitz.net',
);
/// Tatlock API base URL
/// - LAN default: No auth required
/// - Production: https://tatlock.schweitz.net (requires OIDC)
/// - Default: https://tatlock.schweitz.net (requires OIDC)
/// - LAN override: http://192.168.86.149:8000 (no auth)
static const tatlockApiUrl = String.fromEnvironment(
'TATLOCK_API_URL',
defaultValue: 'http://192.168.86.149:8000',
defaultValue: 'https://tatlock.schweitz.net',
);
/// Authentik OIDC discovery URL
@@ -45,12 +47,18 @@ class AppConfig {
defaultValue: 'tatlock-ui',
);
/// Authentik redirect URI scheme
/// Authentik redirect URI scheme (for mobile/native)
static const authRedirectScheme = String.fromEnvironment(
'AUTH_REDIRECT_SCHEME',
defaultValue: 'net.schweitz.tatlock',
);
/// Web app base URL (for OIDC redirect URI on web)
static const webBaseUrl = String.fromEnvironment(
'WEB_BASE_URL',
defaultValue: 'https://home.schweitz.net',
);
/// Whether running in debug mode
static const isDebug = bool.fromEnvironment('DEBUG', defaultValue: false);
@@ -59,15 +67,4 @@ class AppConfig {
coreApiUrl.contains('schweitz.net') ||
tatlockApiUrl.contains('schweitz.net');
/// Portainer URL for container management
static const portainerUrl = String.fromEnvironment(
'PORTAINER_URL',
defaultValue: 'http://192.168.86.149:9000',
);
/// Netdata URL for system monitoring
static const netdataUrl = String.fromEnvironment(
'NETDATA_URL',
defaultValue: 'http://192.168.86.149:19999',
);
}
+5
View File
@@ -0,0 +1,5 @@
/// URL strategy with conditional imports for web/non-web platforms.
library;
export 'url_strategy_stub.dart'
if (dart.library.js_interop) 'url_strategy_web.dart';
+4
View File
@@ -0,0 +1,4 @@
/// Stub for non-web platforms - does nothing.
void configureUrlStrategy() {
// No-op on mobile/desktop
}
+10
View File
@@ -0,0 +1,10 @@
/// Web-specific URL strategy configuration.
library;
import 'package:flutter_web_plugins/url_strategy.dart';
void configureUrlStrategy() {
// Use path-based URLs instead of hash-based (e.g., /login instead of /#/login)
// Required for OIDC callback to work properly
usePathUrlStrategy();
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
/// Riverpod annotation for providers that should persist for the app lifetime.
///
/// Use this instead of `@riverpod` when:
/// - The provider holds app-level state (theme, auth, config)
/// - The provider stores a Ref that must remain valid (API clients with interceptors)
/// - Disposing would cause flickering or re-initialization issues
///
/// Example:
/// ```dart
/// @persistentRiverpod
/// class ThemeNotifier extends _$ThemeNotifier { ... }
/// ```
const persistentRiverpod = Riverpod(keepAlive: true);
+125
View File
@@ -0,0 +1,125 @@
/// Semantic identifiers for UI automation and accessibility.
///
/// These IDs are exposed via Flutter's Semantics tree, making widgets
/// discoverable by automation tools (Appium, WebDriver, Puppeteer with
/// accessibility enabled).
///
/// Naming convention: `{area}_{component}_{identifier}`
/// - area: room or feature area (e.g., profile, nav, dataGrid)
/// - component: widget type (e.g., menu, button, row)
/// - identifier: specific item (e.g., light, containers, selectAll)
library;
/// Profile dropdown menu IDs.
abstract class ProfileSemantics {
static const button = 'profile_button';
static const menu = 'profile_menu';
static const settings = 'profile_menu_settings';
static const themeSystem = 'profile_menu_theme_system';
static const themeLight = 'profile_menu_theme_light';
static const themeDark = 'profile_menu_theme_dark';
static const logout = 'profile_menu_logout';
}
/// Room tab navigation IDs.
abstract class RoomTabSemantics {
static const frontHall = 'roomTab_frontHall';
static const controlRoom = 'roomTab_controlRoom';
static const security = 'roomTab_security';
static const parlor = 'roomTab_parlor';
/// Get semantic ID for room index.
static String forIndex(int index) => switch (index) {
0 => frontHall,
1 => controlRoom,
2 => security,
3 => parlor,
_ => 'roomTab_$index',
};
}
/// Navigation panel IDs.
abstract class NavSemantics {
static const panel = 'nav_panel';
static const refresh = 'nav_refresh';
/// Generate ID for a nav item.
static String item(String id) => 'nav_item_$id';
/// Generate ID for a nav section.
static String section(String id) => 'nav_section_$id';
}
/// DataGrid component IDs.
abstract class DataGridSemantics {
static const grid = 'dataGrid';
static const search = 'dataGrid_search';
static const searchClear = 'dataGrid_search_clear';
static const selectAll = 'dataGrid_selectAll';
static const loading = 'dataGrid_loading';
static const empty = 'dataGrid_empty';
static const error = 'dataGrid_error';
static const refresh = 'dataGrid_refresh';
/// Generate ID for a column header.
static String header(String columnId) => 'dataGrid_header_$columnId';
/// Generate ID for a row.
static String row(String itemId) => 'dataGrid_row_$itemId';
/// Generate ID for a row checkbox.
static String rowCheckbox(String itemId) => 'dataGrid_row_${itemId}_checkbox';
/// Generate ID for a row actions menu.
static String rowActions(String itemId) => 'dataGrid_row_${itemId}_actions';
/// Generate ID for a row action.
static String rowAction(String itemId, String actionId) =>
'dataGrid_row_${itemId}_action_$actionId';
/// Generate ID for a bulk action button.
static String bulkAction(String actionId) => 'dataGrid_bulk_$actionId';
static const bulkClear = 'dataGrid_bulk_clear';
}
/// Dialog/modal IDs.
abstract class DialogSemantics {
static const confirm = 'dialog_confirm';
static const cancel = 'dialog_cancel';
static const close = 'dialog_close';
/// Generate ID for a named dialog.
static String named(String name) => 'dialog_$name';
/// Generate ID for a dialog action button.
static String action(String dialogName, String actionId) =>
'dialog_${dialogName}_$actionId';
}
/// Settings page IDs.
abstract class SettingsSemantics {
static const themeDropdown = 'settings_theme';
static const defaultRoomDropdown = 'settings_defaultRoom';
}
/// Filter panel IDs.
abstract class FilterPanelSemantics {
static const panel = 'filterPanel';
static const search = 'filterPanel_search';
static const searchClear = 'filterPanel_search_clear';
static const refresh = 'filterPanel_refresh';
/// Generate ID for a filter item.
static String item(String id) => 'filterPanel_item_$id';
}
/// Loading/state indicator IDs.
abstract class StateSemantics {
static const authLoading = 'state_auth_loading';
static const authError = 'state_auth_error';
static const pageLoading = 'state_page_loading';
/// Generate ID for a snackbar.
static String snackbar(String type) => 'snackbar_$type';
}
+118
View File
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
/// Wraps a widget with semantic information for accessibility and automation.
///
/// Usage:
/// ```dart
/// SemanticWidget(
/// id: ProfileSemantics.button,
/// label: 'Open profile menu',
/// child: IconButton(...),
/// )
/// ```
///
/// For buttons, use `button: true`. For other interactive elements,
/// set the appropriate semantic properties.
class SemanticWidget extends StatelessWidget {
const SemanticWidget({
super.key,
required this.id,
required this.child,
this.label,
this.hint,
this.button = false,
this.link = false,
this.header = false,
this.textField = false,
this.enabled = true,
this.selected,
this.checked,
this.value,
this.excludeSemantics = false,
});
/// Unique identifier for this widget, exposed via [SemanticsProperties.identifier].
final String id;
/// The widget to wrap.
final Widget child;
/// Accessibility label describing the widget.
final String? label;
/// Hint text for screen readers.
final String? hint;
/// Whether this widget represents a button.
final bool button;
/// Whether this widget represents a link.
final bool link;
/// Whether this widget represents a header.
final bool header;
/// Whether this widget represents a text field.
final bool textField;
/// Whether the widget is enabled.
final bool enabled;
/// Whether the widget is selected (for toggle buttons, tabs).
final bool? selected;
/// Whether the widget is checked (for checkboxes).
final bool? checked;
/// Current value (for sliders, progress indicators).
final String? value;
/// Whether to exclude child semantics.
final bool excludeSemantics;
@override
Widget build(BuildContext context) {
return Semantics(
identifier: id,
label: label,
hint: hint,
button: button,
link: link,
header: header,
textField: textField,
enabled: enabled,
selected: selected,
checked: checked,
value: value,
excludeSemantics: excludeSemantics,
child: child,
);
}
}
/// Extension to easily wrap any widget with semantic info.
extension SemanticExtension on Widget {
/// Wraps this widget with a semantic identifier.
Widget withSemantics({
required String id,
String? label,
String? hint,
bool button = false,
bool link = false,
bool enabled = true,
bool? selected,
bool? checked,
}) {
return SemanticWidget(
id: id,
label: label,
hint: hint,
button: button,
link: link,
enabled: enabled,
selected: selected,
checked: checked,
child: this,
);
}
}
+43 -1
View File
@@ -1,6 +1,10 @@
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/providers/annotations.dart';
part 'theme_provider.g.dart';
@@ -17,13 +21,21 @@ enum ThemeSetting {
}
/// Provider for theme setting state.
@riverpod
///
/// Syncs with API preferences when user is authenticated. On login, the theme
/// from API preferences takes precedence over local storage.
@persistentRiverpod
class ThemeNotifier extends _$ThemeNotifier {
static const _prefsKey = 'theme_setting';
@override
ThemeSetting build() {
// Load local setting first for immediate UI
_loadSavedSetting();
// Listen for auth state changes to sync from API preferences
_syncFromAuthPreferences();
return ThemeSetting.system;
}
@@ -39,6 +51,36 @@ class ThemeNotifier extends _$ThemeNotifier {
}
}
/// Listen to auth state and sync theme from API preferences.
void _syncFromAuthPreferences() {
ref.listen(authProvider, (_, next) {
next.whenData((auth) {
final apiTheme = auth.preferences?.theme;
if (apiTheme != null && apiTheme.isNotEmpty) {
try {
final themeSetting = ThemeSetting.values.byName(apiTheme);
if (themeSetting != state) {
developer.log(
'Syncing theme from API: $apiTheme',
name: 'theme',
);
state = themeSetting;
// Also persist to local storage for offline use
_saveToLocalStorage(themeSetting);
}
} catch (_) {
// Invalid theme value from API, keep current
}
}
});
});
}
Future<void> _saveToLocalStorage(ThemeSetting setting) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, setting.name);
}
/// Update theme setting and persist to storage.
Future<void> setSetting(ThemeSetting setting) async {
state = setting;
@@ -46,7 +46,7 @@ class ContainersDatasource {
final response = await _dio.get<String>(
'/infrastructure/containers/$id/logs',
queryParameters: {
if (tail != null) 'tail': tail,
'tail': ?tail,
'timestamps': timestamps,
},
);
@@ -1,9 +1,13 @@
import 'dart:async';
import 'package:flutter/material.dart' hide Container;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/core/api/api_client.dart';
import 'package:tatlock_ui/features/control_room/containers/presentation/providers/containers_provider.dart';
import 'package:tatlock_ui/features/control_room/containers/presentation/widgets/container_logs_viewer.dart';
import 'package:tatlock_ui/features/control_room/containers/presentation/widgets/container_status_badge.dart';
import 'package:tatlock_ui/routing/url_state.dart';
import 'package:tatlock_ui/shared/components/data_grid/adapters/core_api_source.dart';
import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart';
@@ -101,7 +105,10 @@ class ContainerPort {
/// Page displaying the list of containers using DataGrid.
class ContainersListPage extends ConsumerStatefulWidget {
const ContainersListPage({super.key});
const ContainersListPage({super.key, this.routerState});
/// Router state for URL deep-linking.
final GoRouterState? routerState;
@override
ConsumerState<ContainersListPage> createState() => _ContainersListPageState();
@@ -110,10 +117,18 @@ class ContainersListPage extends ConsumerStatefulWidget {
class _ContainersListPageState extends ConsumerState<ContainersListPage> {
late final StateNotifierProvider<DataGridController<ContainerData>,
DataGridState<ContainerData>> _gridProvider;
late PageUrlState _urlState;
Timer? _urlSyncTimer;
@override
void initState() {
super.initState();
// Parse URL state
_urlState = PageUrlState.fromQueryParams(
widget.routerState?.uri.queryParameters ?? {},
);
final dio = ref.read(coreApiClientProvider);
final source = CoreApiDataSource<ContainerData>(
dio: dio,
@@ -121,17 +136,68 @@ class _ContainersListPageState extends ConsumerState<ContainersListPage> {
fromJson: ContainerData.fromJson,
);
// Initialize grid with URL state
_gridProvider = dataGridProvider<ContainerData>(
source: source,
config: _buildConfig(),
idSelector: (c) => c.id,
initialSearch: _urlState.search,
initialSortColumnIndex: _columnIndexForId(_urlState.sortColumn),
initialSortDescending: _urlState.sortDescending,
);
}
@override
void dispose() {
_urlSyncTimer?.cancel();
super.dispose();
}
/// Find column index by column ID.
int? _columnIndexForId(String? columnId) {
if (columnId == null) return null;
final columns = _buildConfig().columns;
for (var i = 0; i < columns.length; i++) {
if (columns[i].id == columnId) return i;
}
return null;
}
/// Get column ID by index.
String? _columnIdForIndex(int index) {
final columns = _buildConfig().columns;
if (index >= 0 && index < columns.length) {
return columns[index].id;
}
return null;
}
/// Schedule URL sync with debounce.
void _scheduleUrlSync() {
_urlSyncTimer?.cancel();
_urlSyncTimer = Timer(const Duration(milliseconds: 500), _syncUrlParams);
}
/// Sync current state to URL.
void _syncUrlParams() {
final state = ref.read(_gridProvider);
final params = PageUrlState(
search: state.searchQuery.isEmpty ? null : state.searchQuery,
sortColumn: state.sortColumnIndex != null
? _columnIdForIndex(state.sortColumnIndex!)
: null,
sortDescending: state.sortDescending,
).toQueryParams();
updateBrowserUrlParams(params);
}
DataGridConfig<ContainerData> _buildConfig() {
return DataGridConfig<ContainerData>(
columns: [
DataGridColumn<ContainerData>(
id: 'container',
header: 'Container',
valueBuilder: (c) => '${c.name} ${c.image}',
sortable: true,
@@ -140,12 +206,14 @@ class _ContainersListPageState extends ConsumerState<ContainersListPage> {
cellBuilder: (context, c) => _ContainerCell(container: c),
),
DataGridColumn<ContainerData>(
id: 'ports',
header: 'Ports',
valueBuilder: (c) => c.ports.map((p) => p.formatted).join(', '),
width: const DataGridColumnWidth.flex(1),
cellBuilder: (context, c) => _PortsCell(ports: c.ports),
),
DataGridColumn<ContainerData>(
id: 'status',
header: 'Status',
valueBuilder: (c) => c.displayStatus,
width: const DataGridColumnWidth.fixed(160),
@@ -211,6 +279,15 @@ class _ContainersListPageState extends ConsumerState<ContainersListPage> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Listen for grid state changes to sync URL
ref.listen(_gridProvider, (previous, next) {
if (previous?.searchQuery != next.searchQuery ||
previous?.sortColumnIndex != next.sortColumnIndex ||
previous?.sortDescending != next.sortDescending) {
_scheduleUrlSync();
}
});
// Listen for container action results to show snackbars
ref.listen<AsyncValue<void>>(containerActionsProvider, (previous, next) {
if (previous?.isLoading == true && !next.isLoading) {
@@ -1,8 +1,12 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/core/api/api_client.dart';
import 'package:tatlock_ui/features/control_room/npm/presentation/pages/proxy_host_page.dart';
import 'package:tatlock_ui/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart';
import 'package:tatlock_ui/routing/url_state.dart';
import 'package:tatlock_ui/shared/components/data_grid/adapters/core_api_source.dart';
import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart';
@@ -33,7 +37,10 @@ class DomainData {
/// Page displaying the list of proxy hosts (domains) from NPM.
class ProxyHostsPage extends ConsumerStatefulWidget {
const ProxyHostsPage({super.key});
const ProxyHostsPage({super.key, this.routerState});
/// Router state for URL deep-linking.
final GoRouterState? routerState;
@override
ConsumerState<ProxyHostsPage> createState() => _ProxyHostsPageState();
@@ -42,10 +49,18 @@ class ProxyHostsPage extends ConsumerStatefulWidget {
class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
late final StateNotifierProvider<DataGridController<DomainData>,
DataGridState<DomainData>> _gridProvider;
late PageUrlState _urlState;
Timer? _urlSyncTimer;
@override
void initState() {
super.initState();
// Parse URL state
_urlState = PageUrlState.fromQueryParams(
widget.routerState?.uri.queryParameters ?? {},
);
final dio = ref.read(coreApiClientProvider);
final source = CoreApiDataSource<DomainData>(
dio: dio,
@@ -53,17 +68,80 @@ class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
fromJson: DomainData.fromJson,
);
// Initialize grid with URL state
_gridProvider = dataGridProvider<DomainData>(
source: source,
config: _buildConfig(),
idSelector: (d) => d.proxyHostId.toString(),
initialSearch: _urlState.search,
initialSortColumnIndex: _columnIndexForId(_urlState.sortColumn),
initialSortDescending: _urlState.sortDescending,
);
// Open document from URL if id present
if (_urlState.id != null) {
final id = int.tryParse(_urlState.id!);
if (id != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(selectedProxyHostProvider.notifier).select(id);
});
}
}
}
@override
void dispose() {
_urlSyncTimer?.cancel();
super.dispose();
}
/// Find column index by column ID.
int? _columnIndexForId(String? columnId) {
if (columnId == null) return null;
final columns = _buildConfig().columns;
for (var i = 0; i < columns.length; i++) {
if (columns[i].id == columnId) return i;
}
return null;
}
/// Get column ID by index.
String? _columnIdForIndex(int index) {
final columns = _buildConfig().columns;
if (index >= 0 && index < columns.length) {
return columns[index].id;
}
return null;
}
/// Schedule URL sync with debounce.
void _scheduleUrlSync() {
_urlSyncTimer?.cancel();
_urlSyncTimer = Timer(const Duration(milliseconds: 500), _syncUrlParams);
}
/// Sync current state to URL.
void _syncUrlParams() {
final state = ref.read(_gridProvider);
final selectedId = ref.read(selectedProxyHostProvider);
final params = PageUrlState(
id: selectedId?.toString(),
search: state.searchQuery.isEmpty ? null : state.searchQuery,
sortColumn: state.sortColumnIndex != null
? _columnIdForIndex(state.sortColumnIndex!)
: null,
sortDescending: state.sortDescending,
).toQueryParams();
updateBrowserUrlParams(params);
}
DataGridConfig<DomainData> _buildConfig() {
return DataGridConfig<DomainData>(
columns: [
DataGridColumn<DomainData>(
id: 'domain',
header: 'Domain',
valueBuilder: (d) => d.domain,
sortable: true,
@@ -72,12 +150,14 @@ class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
cellBuilder: (context, d) => _DomainCell(domain: d),
),
DataGridColumn<DomainData>(
id: 'service',
header: 'Service',
valueBuilder: (d) => d.service,
width: const DataGridColumnWidth.flex(1),
cellBuilder: (context, d) => _ServiceCell(service: d.service),
),
DataGridColumn<DomainData>(
id: 'ssl',
header: 'SSL',
valueBuilder: (d) => d.sslEnabled ? 'Enabled' : 'Disabled',
width: const DataGridColumnWidth.fixed(100),
@@ -101,6 +181,8 @@ class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
void _viewDomain(DomainData domain) {
ref.read(selectedProxyHostProvider.notifier).select(domain.proxyHostId);
// Navigate with id to enable back button
context.go('/control-room/proxy-hosts?id=${domain.proxyHostId}');
}
void _createNew() {
@@ -112,6 +194,15 @@ class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
final selectedId = ref.watch(selectedProxyHostProvider);
final isCreating = ref.watch(creatingProxyHostProvider);
// Listen for grid state changes to sync URL
ref.listen(_gridProvider, (previous, next) {
if (previous?.searchQuery != next.searchQuery ||
previous?.sortColumnIndex != next.sortColumnIndex ||
previous?.sortDescending != next.sortDescending) {
_scheduleUrlSync();
}
});
// Show create page if creating new
if (isCreating) {
return ProxyHostPage.create(
@@ -128,6 +219,8 @@ class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
proxyHostId: selectedId,
onClose: () {
ref.read(selectedProxyHostProvider.notifier).clear();
// Clear id from URL
context.go('/control-room/proxy-hosts');
ref.read(_gridProvider.notifier).refresh();
},
);
@@ -17,11 +17,15 @@ class ControlRoomPage extends ConsumerWidget {
const ControlRoomPage({
super.key,
this.nav = ControlRoomNav.containers,
this.routerState,
});
/// The current nav item to display.
final ControlRoomNav nav;
/// Router state for URL deep-linking (query params).
final GoRouterState? routerState;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
@@ -53,7 +57,7 @@ class ControlRoomPage extends ConsumerWidget {
),
// Section content
Expanded(
child: _SectionContent(nav: nav),
child: _SectionContent(nav: nav, routerState: routerState),
),
],
),
@@ -63,50 +67,18 @@ class ControlRoomPage extends ConsumerWidget {
/// Renders content for the selected nav item.
class _SectionContent extends ConsumerWidget {
const _SectionContent({required this.nav});
const _SectionContent({required this.nav, this.routerState});
final ControlRoomNav nav;
final GoRouterState? routerState;
@override
Widget build(BuildContext context, WidgetRef ref) {
switch (nav) {
case ControlRoomNav.containers:
return const _ContainersSection();
case ControlRoomNav.proxyHosts:
return const ProxyHostsPage();
default:
return _PlaceholderSection(nav: nav);
}
}
}
/// Containers section with optional stack filter.
class _ContainersSection extends ConsumerWidget {
const _ContainersSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedStack = ref.watch(selectedStackProvider);
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
// Stacks filter panel
const _StacksFilterPanel(),
// Divider
VerticalDivider(
width: 1,
thickness: 1,
color: colorScheme.outlineVariant,
),
// Main content - containers list or stack detail
Expanded(
child: selectedStack == null
? const ContainersListPage()
: StackDetailPage(stackId: selectedStack),
),
],
);
return switch (nav) {
ControlRoomNav.containers => _ContainersSection(routerState: routerState),
ControlRoomNav.proxyHosts => ProxyHostsPage(routerState: routerState),
_ => _PlaceholderSection(nav: nav),
};
}
}
@@ -150,9 +122,43 @@ class _PlaceholderSection extends StatelessWidget {
}
}
/// Containers section with optional stack filter.
class _ContainersSection extends ConsumerWidget {
const _ContainersSection({this.routerState});
final GoRouterState? routerState;
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedStack = ref.watch(selectedStackProvider);
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
// Stacks filter panel
_StacksFilterPanel(routerState: routerState),
// Divider
VerticalDivider(
width: 1,
thickness: 1,
color: colorScheme.outlineVariant,
),
// Main content - containers list or stack detail
Expanded(
child: selectedStack == null
? ContainersListPage(routerState: routerState)
: StackDetailPage(stackId: selectedStack),
),
],
);
}
}
/// Stacks filter panel for Containers section (includes "All Containers" option).
class _StacksFilterPanel extends ConsumerWidget {
const _StacksFilterPanel();
const _StacksFilterPanel({this.routerState});
final GoRouterState? routerState;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -210,6 +216,7 @@ class _StacksFilterPanel extends ConsumerWidget {
data: (stacks) => _StacksList(
stacks: stacks,
selectedStackId: selectedStack,
routerState: routerState,
onStackSelected: (id) =>
ref.read(selectedStackProvider.notifier).select(id),
onStackAction: (id, action) =>
@@ -284,12 +291,14 @@ class _StacksList extends StatefulWidget {
required this.selectedStackId,
required this.onStackSelected,
required this.onStackAction,
this.routerState,
});
final List<Stack> stacks;
final String? selectedStackId;
final void Function(String) onStackSelected;
final void Function(String, String) onStackAction;
final GoRouterState? routerState;
@override
State<_StacksList> createState() => _StacksListState();
+37 -19
View File
@@ -1,35 +1,33 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/features/control_room/presentation/pages/control_room_page.dart';
import 'package:tatlock_ui/routing/app_router.dart';
import 'package:tatlock_ui/routing/room_registry.dart';
import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart';
/// Route paths for Control Room.
abstract class ControlRoomRoutes {
static const base = '/control-room';
// Portainer
// Stack
static const containers = '/control-room/containers';
static const networks = '/control-room/networks';
static const volumes = '/control-room/volumes';
static const images = '/control-room/images';
// NPM
static const proxyHosts = '/control-room/proxy-hosts';
static const redirections = '/control-room/redirections';
static const streams = '/control-room/streams';
static const certificates = '/control-room/certificates';
// Data Management
static const postgres = '/control-room/postgres';
static const redis = '/control-room/redis';
static const qdrant = '/control-room/qdrant';
static const neo4j = '/control-room/neo4j';
}
/// Control Room navigation items with section grouping.
enum ControlRoomNav {
// Portainer section
containers('containers', 'Containers', Icons.dns, 'Portainer'),
networks('networks', 'Networks', Icons.hub, 'Portainer'),
volumes('volumes', 'Volumes', Icons.storage, 'Portainer'),
images('images', 'Images', Icons.photo_library, 'Portainer'),
// NPM section
proxyHosts('proxy-hosts', 'Proxy Hosts', Icons.public, 'NPM'),
redirections('redirections', 'Redirections', Icons.alt_route, 'NPM'),
streams('streams', 'Streams', Icons.stream, 'NPM'),
certificates('certificates', 'SSL Certificates', Icons.verified_user, 'NPM');
// Stack Management section - Docker containers and reverse proxy
containers('containers', 'Containers', Icons.dns, 'Stack Management'),
proxyHosts('proxy-hosts', 'Proxy Hosts', Icons.public, 'Stack Management'),
// Data Management section - Database browsers
postgres('postgres', 'PostgreSQL', Icons.table_chart, 'Data Management'),
redis('redis', 'Redis', Icons.memory, 'Data Management'),
qdrant('qdrant', 'Qdrant', Icons.scatter_plot, 'Data Management'),
neo4j('neo4j', 'Neo4j', Icons.hub, 'Data Management');
const ControlRoomNav(this.id, this.label, this.icon, this.section);
@@ -52,6 +50,22 @@ enum ControlRoomNav {
/// Get route path for a nav item.
String pathForNav(ControlRoomNav nav) => nav.path;
/// Control Room room definition.
final controlRoomRoom = RoomDefinition(
id: 'control-room',
label: 'Control Room',
icon: Icons.dns_outlined,
selectedIcon: Icons.dns,
defaultRoute: ControlRoomRoutes.containers,
routes: controlRoomRoutes,
// No permissions required - accessible to all authenticated users
);
/// Register Control Room with the room registry.
void registerControlRoom() {
roomRegistry.register(controlRoomRoom);
}
/// Control Room routes for go_router.
List<RouteBase> controlRoomRoutes() {
return [
@@ -66,7 +80,11 @@ List<RouteBase> controlRoomRoutes() {
GoRoute(
path: nav.path,
name: 'controlRoom${_capitalize(nav.id.replaceAll('-', '_'))}',
builder: (context, state) => ControlRoomPage(nav: nav),
pageBuilder: (context, state) => noTransitionPage(
context,
state,
ControlRoomPage(nav: nav, routerState: state),
),
),
];
}
@@ -0,0 +1,38 @@
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/core/api/api_client.dart';
import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart';
part 'environment_datasource.g.dart';
/// Data source for environment data operations.
///
/// Fetches weather, forecast, sun times, and air quality from Core API.
class EnvironmentDatasource {
EnvironmentDatasource(this._dio);
final Dio _dio;
static const _basePath = '/tools/environment';
/// Gets current environment data.
///
/// Returns weather, forecast, sun times, and optionally air quality.
Future<EnvironmentData> getEnvironment() async {
final response = await _dio.get<Map<String, dynamic>>(_basePath);
final data = response.data;
if (data == null) {
throw Exception('Failed to fetch environment data');
}
return EnvironmentData.fromJson(data);
}
}
/// Provides the environment datasource.
@riverpod
EnvironmentDatasource environmentDatasource(Ref ref) {
final dio = ref.watch(coreApiClientProvider);
return EnvironmentDatasource(dio);
}
@@ -0,0 +1,38 @@
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/core/api/api_client.dart';
import 'package:tatlock_ui/features/front_hall/data/models/news_model.dart';
part 'news_datasource.g.dart';
/// Data source for news data operations.
///
/// Fetches news headlines from Core API.
class NewsDatasource {
NewsDatasource(this._dio);
final Dio _dio;
static const _basePath = '/tools/news';
/// Gets current news headlines.
///
/// Returns headlines for the news ticker.
Future<NewsData> getNews() async {
final response = await _dio.get<Map<String, dynamic>>(_basePath);
final data = response.data;
if (data == null) {
throw Exception('Failed to fetch news data');
}
return NewsData.fromJson(data);
}
}
/// Provides the news datasource.
@riverpod
NewsDatasource newsDatasource(Ref ref) {
final dio = ref.watch(coreApiClientProvider);
return NewsDatasource(dio);
}
@@ -0,0 +1,91 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'environment_model.freezed.dart';
part 'environment_model.g.dart';
/// Environment data response from Core API.
/// Contains weather, forecast, sun times, and optionally air quality.
@freezed
sealed class EnvironmentData with _$EnvironmentData {
const factory EnvironmentData({
WeatherData? weather,
List<ForecastDay>? forecast,
@JsonKey(name: 'sun_times') SunTimesData? sunTimes,
@JsonKey(name: 'air_quality') AirQualityData? airQuality,
@JsonKey(name: 'updated_at') required DateTime updatedAt,
String? user,
}) = _EnvironmentData;
factory EnvironmentData.fromJson(Map<String, dynamic> json) =>
_$EnvironmentDataFromJson(json);
}
/// Current weather conditions.
@freezed
sealed class WeatherData with _$WeatherData {
const factory WeatherData({
double? temperature,
@JsonKey(name: 'feels_like') double? feelsLike,
String? conditions,
int? humidity,
@JsonKey(name: 'wind_speed') double? windSpeed,
@JsonKey(name: 'wind_direction') String? windDirection,
double? pressure,
double? visibility,
@JsonKey(name: 'uv_index') double? uvIndex,
String? location,
String? icon,
}) = _WeatherData;
factory WeatherData.fromJson(Map<String, dynamic> json) =>
_$WeatherDataFromJson(json);
}
/// Single day forecast data.
@freezed
sealed class ForecastDay with _$ForecastDay {
const factory ForecastDay({
required String date,
double? high,
double? low,
String? conditions,
@JsonKey(name: 'precipitation_chance') int? precipitationChance,
String? icon,
}) = _ForecastDay;
factory ForecastDay.fromJson(Map<String, dynamic> json) =>
_$ForecastDayFromJson(json);
}
/// Sunrise and sunset times.
@freezed
sealed class SunTimesData with _$SunTimesData {
const factory SunTimesData({
DateTime? sunrise,
DateTime? sunset,
@JsonKey(name: 'daylight_minutes') int? daylightMinutes,
@JsonKey(name: 'solar_noon') DateTime? solarNoon,
DateTime? dawn,
DateTime? dusk,
}) = _SunTimesData;
factory SunTimesData.fromJson(Map<String, dynamic> json) =>
_$SunTimesDataFromJson(json);
}
/// Air quality information.
@freezed
sealed class AirQualityData with _$AirQualityData {
const factory AirQualityData({
int? aqi,
String? quality,
double? pm25,
double? pm10,
double? o3,
double? no2,
String? location,
}) = _AirQualityData;
factory AirQualityData.fromJson(Map<String, dynamic> json) =>
_$AirQualityDataFromJson(json);
}
@@ -0,0 +1,34 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'news_model.freezed.dart';
part 'news_model.g.dart';
/// News response from Core API.
/// Contains headlines for the news ticker.
@freezed
sealed class NewsData with _$NewsData {
const factory NewsData({
required List<NewsHeadline> headlines,
String? category,
List<String>? sources,
@JsonKey(name: 'updated_at') required DateTime updatedAt,
String? user,
}) = _NewsData;
factory NewsData.fromJson(Map<String, dynamic> json) =>
_$NewsDataFromJson(json);
}
/// Single news headline.
@freezed
sealed class NewsHeadline with _$NewsHeadline {
const factory NewsHeadline({
required String title,
String? description,
String? source,
String? url,
}) = _NewsHeadline;
factory NewsHeadline.fromJson(Map<String, dynamic> json) =>
_$NewsHeadlineFromJson(json);
}
@@ -0,0 +1,91 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'system_stats_model.freezed.dart';
part 'system_stats_model.g.dart';
/// System statistics response from Core API.
@freezed
sealed class SystemStats with _$SystemStats {
const factory SystemStats({
required CpuStats cpu,
required MemoryStats memory,
required List<DiskStats> disks,
required NetworkStats network,
required GpuStats gpu,
required String hostname,
@JsonKey(name: 'queried_at') required DateTime queriedAt,
}) = _SystemStats;
factory SystemStats.fromJson(Map<String, dynamic> json) =>
_$SystemStatsFromJson(json);
}
@freezed
sealed class CpuStats with _$CpuStats {
const factory CpuStats({
@JsonKey(name: 'usage_percent') required double usagePercent,
required int cores,
@JsonKey(name: 'load_1m') double? load1m,
@JsonKey(name: 'load_5m') double? load5m,
@JsonKey(name: 'load_15m') double? load15m,
}) = _CpuStats;
factory CpuStats.fromJson(Map<String, dynamic> json) =>
_$CpuStatsFromJson(json);
}
@freezed
sealed class MemoryStats with _$MemoryStats {
const factory MemoryStats({
@JsonKey(name: 'usage_percent') required double usagePercent,
@JsonKey(name: 'total_bytes') required int totalBytes,
@JsonKey(name: 'used_bytes') required int usedBytes,
@JsonKey(name: 'available_bytes') required int availableBytes,
}) = _MemoryStats;
factory MemoryStats.fromJson(Map<String, dynamic> json) =>
_$MemoryStatsFromJson(json);
}
@freezed
sealed class DiskStats with _$DiskStats {
const factory DiskStats({
@JsonKey(name: 'mount_point') required String mountPoint,
required String device,
required String fstype,
@JsonKey(name: 'usage_percent') required double usagePercent,
@JsonKey(name: 'total_bytes') required int totalBytes,
@JsonKey(name: 'used_bytes') required int usedBytes,
@JsonKey(name: 'free_bytes') required int freeBytes,
}) = _DiskStats;
factory DiskStats.fromJson(Map<String, dynamic> json) =>
_$DiskStatsFromJson(json);
}
@freezed
sealed class NetworkStats with _$NetworkStats {
const factory NetworkStats({
@JsonKey(name: 'bytes_sent') required int bytesSent,
@JsonKey(name: 'bytes_recv') required int bytesRecv,
@JsonKey(name: 'bytes_total') required int bytesTotal,
}) = _NetworkStats;
factory NetworkStats.fromJson(Map<String, dynamic> json) =>
_$NetworkStatsFromJson(json);
}
@freezed
sealed class GpuStats with _$GpuStats {
const factory GpuStats({
required bool available,
String? name,
@JsonKey(name: 'usage_percent') double? usagePercent,
@JsonKey(name: 'total_bytes') int? totalBytes,
@JsonKey(name: 'used_bytes') int? usedBytes,
@JsonKey(name: 'free_bytes') int? freeBytes,
}) = _GpuStats;
factory GpuStats.fromJson(Map<String, dynamic> json) =>
_$GpuStatsFromJson(json);
}
@@ -0,0 +1,27 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/features/front_hall/data/datasources/environment_datasource.dart';
import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart';
part 'environment_provider.g.dart';
/// Fetches environment data (weather, forecast, sun times) from Core API.
///
/// Auto-invalidates every 5 minutes to keep data fresh.
/// Weather data in Qdrant has 1-hour TTL, so 5-minute refresh is reasonable.
@riverpod
Future<EnvironmentData> environment(Ref ref) async {
final datasource = ref.watch(environmentDatasourceProvider);
return datasource.getEnvironment();
}
/// Provides whether air quality data is available.
///
/// Used for conditional rendering of AirQualityWidget.
@riverpod
bool hasAirQuality(Ref ref) {
final asyncValue = ref.watch(environmentProvider);
return asyncValue.maybeWhen(
data: (data) => data.airQuality != null,
orElse: () => false,
);
}
@@ -0,0 +1,27 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/features/front_hall/data/datasources/news_datasource.dart';
import 'package:tatlock_ui/features/front_hall/data/models/news_model.dart';
part 'news_provider.g.dart';
/// Fetches news headlines from Core API.
///
/// Auto-invalidates to keep data fresh.
/// News data in Qdrant has TTL, so periodic refresh is reasonable.
@riverpod
Future<NewsData> news(Ref ref) async {
final datasource = ref.watch(newsDatasourceProvider);
return datasource.getNews();
}
/// Provides whether news data is available.
///
/// Used for conditional rendering of NewsTickerWidget.
@riverpod
bool hasNews(Ref ref) {
final asyncValue = ref.watch(newsProvider);
return asyncValue.maybeWhen(
data: (data) => data.headlines.isNotEmpty,
orElse: () => false,
);
}
@@ -179,25 +179,7 @@ List<QuickLink> getDefaultQuickLinks() {
type: QuickLinkType.iframe,
sortOrder: 0,
),
const QuickLink(
id: 'cloud-ide',
name: 'Cloud IDE',
url: 'https://code.schweitz.net',
iconName: 'terminal',
category: 'Coding',
type: QuickLinkType.newTab,
sortOrder: 1,
),
// Infrastructure category
const QuickLink(
id: 'netdata',
name: 'Netdata',
url: 'https://netdata.schweitz.net',
iconName: 'monitoring',
category: 'Infrastructure',
type: QuickLinkType.iframe,
sortOrder: 0,
),
const QuickLink(
id: 'portainer',
name: 'Portainer',
@@ -0,0 +1,14 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/core/api/api_client.dart';
import 'package:tatlock_ui/features/front_hall/data/models/system_stats_model.dart';
part 'system_stats_provider.g.dart';
/// Fetches system stats from Core API.
@riverpod
Future<SystemStats> systemStats(Ref ref) async {
final dio = ref.watch(coreApiClientProvider);
final response = await dio.get('/tools/system/stats');
return SystemStats.fromJson(response.data as Map<String, dynamic>);
}
@@ -1,16 +1,71 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/features/front_hall/data/models/system_stats_model.dart';
import 'package:tatlock_ui/features/front_hall/presentation/providers/environment_provider.dart';
import 'package:tatlock_ui/features/front_hall/presentation/providers/news_provider.dart';
import 'package:tatlock_ui/features/front_hall/presentation/providers/system_stats_provider.dart';
import 'package:tatlock_ui/shared/theme/stoplight_colors.dart';
import 'package:tatlock_ui/shared/widgets/forecast_widget.dart';
import 'package:tatlock_ui/shared/widgets/sun_position_widget.dart';
import 'package:tatlock_ui/shared/widgets/widgets.dart';
import 'package:tatlock_ui/version.g.dart';
/// Dashboard content shown in Front Hall when mode is dashboard.
///
/// Displays system stats with gauges, weather, air quality, and version info.
class DashboardContent extends StatelessWidget {
/// Auto-refreshes system stats every 30 seconds.
class DashboardContent extends ConsumerStatefulWidget {
const DashboardContent({super.key});
@override
ConsumerState<DashboardContent> createState() => _DashboardContentState();
}
class _DashboardContentState extends ConsumerState<DashboardContent> {
Timer? _systemStatsTimer;
Timer? _environmentTimer;
Timer? _newsTimer;
/// Tracks if we've logged the environment API user (log once per session)
static bool _hasLoggedEnvUser = false;
@override
void initState() {
super.initState();
// Refresh system stats every 30 seconds
_systemStatsTimer = Timer.periodic(
const Duration(seconds: 30),
(_) => ref.invalidate(systemStatsProvider),
);
// Refresh environment data every hour
_environmentTimer = Timer.periodic(
const Duration(hours: 1),
(_) => ref.invalidate(environmentProvider),
);
// Refresh news every 30 minutes
_newsTimer = Timer.periodic(
const Duration(minutes: 30),
(_) => ref.invalidate(newsProvider),
);
}
@override
void dispose() {
_systemStatsTimer?.cancel();
_environmentTimer?.cancel();
_newsTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final systemStatsAsync = ref.watch(systemStatsProvider);
final environmentAsync = ref.watch(environmentProvider);
final newsAsync = ref.watch(newsProvider);
return ListView(
padding: const EdgeInsets.all(16),
@@ -51,70 +106,91 @@ class DashboardContent extends StatelessWidget {
),
const SizedBox(height: 16),
// News Ticker - full width
newsAsync.when(
data: (newsData) => NewsTickerWidget(newsData: newsData),
loading: () => const NewsTickerWidget(),
error: (error, stack) => const NewsTickerWidget(),
),
const SizedBox(height: 16),
// System Stats - Gauges
_SectionHeader(title: 'System Stats', icon: Icons.monitor_heart),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: GaugeRow(
gaugeSize: 90,
gauges: [
GaugeData(
value: 0.35,
label: 'CPU',
icon: Icons.memory,
color: colorScheme.primary,
),
GaugeData(
value: 0.62,
label: 'Memory',
icon: Icons.storage,
color: colorScheme.secondary,
),
GaugeData(
value: 0.78,
label: 'Disk',
icon: Icons.disc_full,
color: colorScheme.tertiary,
),
GaugeData(
value: 0.12,
label: 'Network',
icon: Icons.wifi,
color: Colors.teal,
),
],
systemStatsAsync.when(
data: (stats) => _SystemStatsCard(stats: stats),
loading: () => const Card(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
),
error: (error, _) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Text(
'Failed to load system stats',
style: TextStyle(color: colorScheme.error),
),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.invalidate(systemStatsProvider),
),
],
),
),
),
),
const SizedBox(height: 24),
// Environment - Weather & Air Quality
_SectionHeader(title: 'Environment', icon: Icons.eco),
// Environment - Sun, Weather, Forecast, Air Quality
const _SectionHeader(title: 'Environment', icon: Icons.eco),
const SizedBox(height: 8),
LayoutBuilder(
builder: (context, constraints) {
// Responsive layout: side-by-side on wider screens
if (constraints.maxWidth > 500) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: WeatherWidget()),
const SizedBox(width: 12),
Expanded(child: AirQualityWidget()),
],
);
environmentAsync.when(
data: (envData) {
// Log the user once per session
if (!_hasLoggedEnvUser && envData.user != null) {
_hasLoggedEnvUser = true;
debugPrint('Environment API user: ${envData.user}');
}
// Stack on narrow screens
return Column(
children: [
WeatherWidget(),
const SizedBox(height: 12),
AirQualityWidget(),
],
return _EnvironmentSection(
envData: envData,
onRefresh: () => ref.invalidate(environmentProvider),
);
},
loading: () => const Card(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
),
error: (error, _) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Text(
'Failed to load environment data',
style: TextStyle(color: colorScheme.error),
),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.invalidate(environmentProvider),
),
],
),
),
),
),
const SizedBox(height: 24),
@@ -165,3 +241,159 @@ class _SectionHeader extends StatelessWidget {
);
}
}
/// Card displaying system stats with gauges.
class _SystemStatsCard extends StatelessWidget {
const _SystemStatsCard({required this.stats});
final SystemStats stats;
@override
Widget build(BuildContext context) {
// Build gauges list: CPU, Memory, GPU (if available), then all disks
final gauges = <GaugeData>[
GaugeData(
value: stats.cpu.usagePercent / 100,
label: 'CPU',
icon: Icons.memory,
color: StoplightColors.forPercent(stats.cpu.usagePercent),
),
GaugeData(
value: stats.memory.usagePercent / 100,
label: 'RAM',
icon: Icons.storage,
color: StoplightColors.forPercent(stats.memory.usagePercent),
),
if (stats.gpu.available && stats.gpu.usagePercent != null)
GaugeData(
value: stats.gpu.usagePercent! / 100,
label: 'VRAM',
icon: Icons.videocam,
color: StoplightColors.forPercent(stats.gpu.usagePercent!),
),
// Add a gauge for each disk
...stats.disks.map(
(disk) => GaugeData(
value: disk.usagePercent / 100,
label: _formatDiskLabel(disk),
icon: Icons.disc_full,
color: StoplightColors.forPercent(disk.usagePercent),
),
),
];
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: GaugeRow(
gaugeSize: 120,
gauges: gauges,
),
),
);
}
/// Formats disk label from mount point.
String _formatDiskLabel(DiskStats disk) {
final mount = disk.mountPoint;
if (mount == '/') return 'Root';
if (mount == '/hostfs') return 'Host';
if (mount.startsWith('/hostfs/')) return mount.substring(8);
if (mount.startsWith('/mnt/')) return mount.substring(5);
if (mount.startsWith('/media/')) return mount.substring(7);
// Return last path segment
final parts = mount.split('/');
return parts.isNotEmpty ? parts.last : mount;
}
}
/// Environment section displaying sun position, weather, air quality, and forecast.
///
/// Layout:
/// - Desktop (>900px): 4 widgets in a row - Sun(30%) | Weather(20%) | AirQuality(20%) | Forecast(30%)
/// - Tablet (600-900px): 2x2 grid
/// - Mobile (<600px): Stacked vertically
class _EnvironmentSection extends StatelessWidget {
const _EnvironmentSection({
required this.envData,
required this.onRefresh,
});
final dynamic envData;
final VoidCallback onRefresh;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 900) {
// Desktop: 4 in a row (30/20/20/30) - wider widgets on outsides
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 30,
child: SunPositionWidget(sunTimes: envData.sunTimes),
),
const SizedBox(width: 12),
Expanded(
flex: 20,
child: WeatherWidget(apiData: envData.weather),
),
const SizedBox(width: 12),
Expanded(
flex: 20,
child: AirQualityWidget(apiData: envData.airQuality),
),
const SizedBox(width: 12),
Expanded(
flex: 30,
child: ForecastWidget(forecast: envData.forecast),
),
],
);
} else if (constraints.maxWidth > 600) {
// Tablet: 2x2 grid
return Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: SunPositionWidget(sunTimes: envData.sunTimes),
),
const SizedBox(width: 12),
Expanded(child: WeatherWidget(apiData: envData.weather)),
],
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: AirQualityWidget(apiData: envData.airQuality),
),
const SizedBox(width: 12),
Expanded(child: ForecastWidget(forecast: envData.forecast)),
],
),
],
);
} else {
// Mobile: stacked vertically
return Column(
children: [
SunPositionWidget(sunTimes: envData.sunTimes),
const SizedBox(height: 12),
WeatherWidget(apiData: envData.weather),
const SizedBox(height: 12),
AirQualityWidget(apiData: envData.airQuality),
const SizedBox(height: 12),
ForecastWidget(forecast: envData.forecast),
],
);
}
},
);
}
}
@@ -1,8 +1,8 @@
import 'dart:html' as html;
import 'dart:ui_web' as ui_web;
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:web/web.dart' as web;
/// Embedded iframe view for displaying external content (web only).
///
@@ -31,7 +31,7 @@ class IframeView extends StatefulWidget {
class _IframeViewState extends State<IframeView> {
late final String _viewType;
late html.IFrameElement _iframe;
late web.HTMLIFrameElement _iframe;
bool _isLoading = true;
@override
@@ -42,17 +42,18 @@ class _IframeViewState extends State<IframeView> {
}
void _createIframe() {
_iframe = html.IFrameElement()
_iframe = web.document.createElement('iframe') as web.HTMLIFrameElement
..src = widget.url
..style.border = 'none'
..style.width = '100%'
..style.height = '100%'
..allow = 'fullscreen'
..onLoad.listen((_) {
if (mounted) {
setState(() => _isLoading = false);
}
});
..allow = 'fullscreen';
_iframe.onLoad.listen((_) {
if (mounted) {
setState(() => _isLoading = false);
}
});
// Register the view factory
ui_web.platformViewRegistry.registerViewFactory(
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.dart';
import 'package:tatlock_ui/routing/app_router.dart';
import 'package:tatlock_ui/routing/room_registry.dart';
/// Route paths for Front Hall.
abstract class FrontHallRoutes {
static const base = '/front-hall';
}
/// Front Hall room definition.
final frontHallRoom = RoomDefinition(
id: 'front-hall',
label: 'Front Hall',
icon: Icons.door_front_door_outlined,
selectedIcon: Icons.door_front_door,
defaultRoute: FrontHallRoutes.base,
routes: frontHallRoutes,
// No permissions required - accessible to all authenticated users
);
/// Register Front Hall with the room registry.
void registerFrontHall() {
roomRegistry.register(frontHallRoom);
}
/// Front Hall routes for go_router.
List<RouteBase> frontHallRoutes() {
return [
GoRoute(
path: FrontHallRoutes.base,
name: 'frontHall',
pageBuilder: (context, state) =>
noTransitionPage(context, state, const FrontHallPage()),
),
];
}
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
/// Media Room - media management and playback.
///
/// Placeholder page for future implementation.
class MediaRoomPage extends StatelessWidget {
const MediaRoomPage({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.movie,
size: 64,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 16),
Text(
'Media Room',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Media management and playback - coming soon',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
],
),
);
}
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/features/media_room/presentation/pages/media_room_page.dart';
import 'package:tatlock_ui/routing/app_router.dart';
import 'package:tatlock_ui/routing/room_registry.dart';
/// Route paths for Media Room.
abstract class MediaRoomRoutes {
static const base = '/media-room';
}
/// Media Room room definition.
final mediaRoomRoom = RoomDefinition(
id: 'media-room',
label: 'Media Room',
icon: Icons.movie_outlined,
selectedIcon: Icons.movie,
defaultRoute: MediaRoomRoutes.base,
routes: mediaRoomRoutes,
// No permissions required - accessible to all authenticated users
);
/// Register Media Room with the room registry.
void registerMediaRoom() {
roomRegistry.register(mediaRoomRoom);
}
/// Media Room routes for go_router.
List<RouteBase> mediaRoomRoutes() {
return [
GoRoute(
path: MediaRoomRoutes.base,
name: 'mediaRoom',
pageBuilder: (context, state) =>
noTransitionPage(context, state, const MediaRoomPage()),
),
];
}
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
/// Parlor - AI chat and automation hub.
///
/// Placeholder page for future implementation.
class ParlorPage extends StatelessWidget {
const ParlorPage({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.lightbulb,
size: 64,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 16),
Text(
'Parlor',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'AI chat and automation hub - coming soon',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
],
),
);
}
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/features/parlor/presentation/pages/parlor_page.dart';
import 'package:tatlock_ui/routing/app_router.dart';
import 'package:tatlock_ui/routing/room_registry.dart';
/// Route paths for Parlor.
abstract class ParlorRoutes {
static const base = '/parlor';
}
/// Parlor room definition.
final parlorRoom = RoomDefinition(
id: 'parlor',
label: 'Parlor',
icon: Icons.lightbulb_outline,
selectedIcon: Icons.lightbulb,
defaultRoute: ParlorRoutes.base,
routes: parlorRoutes,
// No permissions required - accessible to all authenticated users
);
/// Register Parlor with the room registry.
void registerParlor() {
roomRegistry.register(parlorRoom);
}
/// Parlor routes for go_router.
List<RouteBase> parlorRoutes() {
return [
GoRoute(
path: ParlorRoutes.base,
name: 'parlor',
pageBuilder: (context, state) =>
noTransitionPage(context, state, const ParlorPage()),
),
];
}
@@ -1,6 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/core/api/api_client.dart';
import 'package:tatlock_ui/routing/url_state.dart';
import 'package:tatlock_ui/shared/components/data_grid/adapters/core_api_source.dart';
import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart';
@@ -31,7 +35,10 @@ class GroupData {
/// Groups list page using the shared DataGrid component.
class GroupsListPage extends ConsumerStatefulWidget {
const GroupsListPage({super.key});
const GroupsListPage({super.key, this.routerState});
/// Router state for URL deep-linking.
final GoRouterState? routerState;
@override
ConsumerState<GroupsListPage> createState() => _GroupsListPageState();
@@ -40,11 +47,19 @@ class GroupsListPage extends ConsumerStatefulWidget {
class _GroupsListPageState extends ConsumerState<GroupsListPage> {
late final StateNotifierProvider<DataGridController<GroupData>,
DataGridState<GroupData>> _gridProvider;
late PageUrlState _urlState;
Timer? _urlSyncTimer;
bool _isSyncing = false;
@override
void initState() {
super.initState();
// Parse URL state
_urlState = PageUrlState.fromQueryParams(
widget.routerState?.uri.queryParameters ?? {},
);
// Create provider in initState to ensure stable reference
final dio = ref.read(coreApiClientProvider);
final source = CoreApiDataSource<GroupData>(
@@ -53,13 +68,63 @@ class _GroupsListPageState extends ConsumerState<GroupsListPage> {
fromJson: GroupData.fromJson,
);
// Initialize grid with URL state
_gridProvider = dataGridProvider<GroupData>(
source: source,
config: _buildConfig(),
idSelector: (g) => g.id,
initialSearch: _urlState.search,
initialSortColumnIndex: _columnIndexForId(_urlState.sortColumn),
initialSortDescending: _urlState.sortDescending,
);
}
@override
void dispose() {
_urlSyncTimer?.cancel();
super.dispose();
}
/// Find column index by column ID.
int? _columnIndexForId(String? columnId) {
if (columnId == null) return null;
final columns = _buildConfig().columns;
for (var i = 0; i < columns.length; i++) {
if (columns[i].id == columnId) return i;
}
return null;
}
/// Get column ID by index.
String? _columnIdForIndex(int index) {
final columns = _buildConfig().columns;
if (index >= 0 && index < columns.length) {
return columns[index].id;
}
return null;
}
/// Schedule URL sync with debounce.
void _scheduleUrlSync() {
_urlSyncTimer?.cancel();
_urlSyncTimer = Timer(const Duration(milliseconds: 500), _syncUrlParams);
}
/// Sync current state to URL.
void _syncUrlParams() {
final state = ref.read(_gridProvider);
final params = PageUrlState(
search: state.searchQuery.isEmpty ? null : state.searchQuery,
sortColumn: state.sortColumnIndex != null
? _columnIdForIndex(state.sortColumnIndex!)
: null,
sortDescending: state.sortDescending,
).toQueryParams();
updateBrowserUrlParams(params);
}
Future<void> _syncFromAuthentik() async {
if (_isSyncing) return;
setState(() => _isSyncing = true);
@@ -96,6 +161,7 @@ class _GroupsListPageState extends ConsumerState<GroupsListPage> {
return DataGridConfig<GroupData>(
columns: [
DataGridColumn<GroupData>(
id: 'name',
header: 'Name',
valueBuilder: (g) => g.name,
sortable: true,
@@ -103,18 +169,21 @@ class _GroupsListPageState extends ConsumerState<GroupsListPage> {
width: const DataGridColumnWidth.flex(2),
),
DataGridColumn<GroupData>(
id: 'members',
header: 'Members',
valueBuilder: (g) => g.memberCount.toString(),
width: const DataGridColumnWidth.fixed(100),
alignment: DataGridColumnAlignment.end,
),
DataGridColumn<GroupData>(
id: 'type',
header: 'Type',
valueBuilder: (g) => g.isSuperuser ? 'Superuser' : 'Standard',
cellBuilder: (context, g) => _GroupTypeBadge(isSuperuser: g.isSuperuser),
width: const DataGridColumnWidth.fixed(120),
),
DataGridColumn<GroupData>(
id: 'parent',
header: 'Parent',
valueBuilder: (g) => g.parentName ?? '-',
width: const DataGridColumnWidth.flex(1),
@@ -129,6 +198,15 @@ class _GroupsListPageState extends ConsumerState<GroupsListPage> {
@override
Widget build(BuildContext context) {
// Listen for grid state changes to sync URL
ref.listen(_gridProvider, (previous, next) {
if (previous?.searchQuery != next.searchQuery ||
previous?.sortColumnIndex != next.sortColumnIndex ||
previous?.sortDescending != next.sortDescending) {
_scheduleUrlSync();
}
});
return DataGrid<GroupData>(
provider: _gridProvider,
config: _buildConfig(),
@@ -11,11 +11,15 @@ class SecurityPage extends ConsumerWidget {
const SecurityPage({
super.key,
this.nav = SecurityNav.users,
this.routerState,
});
/// The current nav item to display.
final SecurityNav nav;
/// Router state for URL deep-linking (query params).
final GoRouterState? routerState;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
@@ -45,7 +49,7 @@ class SecurityPage extends ConsumerWidget {
),
// Section content
Expanded(
child: _SectionContent(nav: nav),
child: _SectionContent(nav: nav, routerState: routerState),
),
],
),
@@ -55,17 +59,18 @@ class SecurityPage extends ConsumerWidget {
/// Renders content for the selected nav item.
class _SectionContent extends StatelessWidget {
const _SectionContent({required this.nav});
const _SectionContent({required this.nav, this.routerState});
final SecurityNav nav;
final GoRouterState? routerState;
@override
Widget build(BuildContext context) {
switch (nav) {
case SecurityNav.users:
return const UsersListPage();
return UsersListPage(routerState: routerState);
case SecurityNav.groups:
return const GroupsListPage();
return GroupsListPage(routerState: routerState);
default:
return _PlaceholderSection(nav: nav);
}
+23 -1
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/features/security/presentation/pages/security_page.dart';
import 'package:tatlock_ui/routing/app_router.dart';
import 'package:tatlock_ui/routing/room_registry.dart';
import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart';
/// Route paths for Security room.
@@ -46,6 +48,22 @@ enum SecurityNav {
/// Get route path for a nav item.
String pathForSecurityNav(SecurityNav nav) => nav.path;
/// Security room definition.
final securityRoom = RoomDefinition(
id: 'security',
label: 'Security',
icon: Icons.security_outlined,
selectedIcon: Icons.security,
defaultRoute: SecurityRoutes.users,
routes: securityRoutes,
// No permissions required - accessible to all authenticated users
);
/// Register Security room with the room registry.
void registerSecurity() {
roomRegistry.register(securityRoom);
}
/// Security room routes for go_router.
List<RouteBase> securityRoutes() {
return [
@@ -60,7 +78,11 @@ List<RouteBase> securityRoutes() {
GoRoute(
path: nav.path,
name: 'security${_capitalize(nav.id.replaceAll('-', '_'))}',
builder: (context, state) => SecurityPage(nav: nav),
pageBuilder: (context, state) => noTransitionPage(
context,
state,
SecurityPage(nav: nav, routerState: state),
),
),
];
}
@@ -1,6 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/core/api/api_client.dart';
import 'package:tatlock_ui/routing/url_state.dart';
import 'package:tatlock_ui/shared/components/data_grid/adapters/core_api_source.dart';
import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart';
@@ -61,7 +65,10 @@ class UserData {
/// Users list page using the shared DataGrid component.
class UsersListPage extends ConsumerStatefulWidget {
const UsersListPage({super.key});
const UsersListPage({super.key, this.routerState});
/// Router state for URL deep-linking.
final GoRouterState? routerState;
@override
ConsumerState<UsersListPage> createState() => _UsersListPageState();
@@ -70,11 +77,19 @@ class UsersListPage extends ConsumerStatefulWidget {
class _UsersListPageState extends ConsumerState<UsersListPage> {
late final StateNotifierProvider<DataGridController<UserData>,
DataGridState<UserData>> _gridProvider;
late PageUrlState _urlState;
Timer? _urlSyncTimer;
bool _isSyncing = false;
@override
void initState() {
super.initState();
// Parse URL state
_urlState = PageUrlState.fromQueryParams(
widget.routerState?.uri.queryParameters ?? {},
);
final dio = ref.read(coreApiClientProvider);
final source = CoreApiDataSource<UserData>(
dio: dio,
@@ -82,13 +97,63 @@ class _UsersListPageState extends ConsumerState<UsersListPage> {
fromJson: UserData.fromJson,
);
// Initialize grid with URL state
_gridProvider = dataGridProvider<UserData>(
source: source,
config: _buildConfig(),
idSelector: (u) => u.id,
initialSearch: _urlState.search,
initialSortColumnIndex: _columnIndexForId(_urlState.sortColumn),
initialSortDescending: _urlState.sortDescending,
);
}
@override
void dispose() {
_urlSyncTimer?.cancel();
super.dispose();
}
/// Find column index by column ID.
int? _columnIndexForId(String? columnId) {
if (columnId == null) return null;
final columns = _buildConfig().columns;
for (var i = 0; i < columns.length; i++) {
if (columns[i].id == columnId) return i;
}
return null;
}
/// Get column ID by index.
String? _columnIdForIndex(int index) {
final columns = _buildConfig().columns;
if (index >= 0 && index < columns.length) {
return columns[index].id;
}
return null;
}
/// Schedule URL sync with debounce.
void _scheduleUrlSync() {
_urlSyncTimer?.cancel();
_urlSyncTimer = Timer(const Duration(milliseconds: 500), _syncUrlParams);
}
/// Sync current state to URL.
void _syncUrlParams() {
final state = ref.read(_gridProvider);
final params = PageUrlState(
search: state.searchQuery.isEmpty ? null : state.searchQuery,
sortColumn: state.sortColumnIndex != null
? _columnIdForIndex(state.sortColumnIndex!)
: null,
sortDescending: state.sortDescending,
).toQueryParams();
updateBrowserUrlParams(params);
}
Future<void> _syncFromAuthentik() async {
if (_isSyncing) return;
setState(() => _isSyncing = true);
@@ -125,6 +190,7 @@ class _UsersListPageState extends ConsumerState<UsersListPage> {
return DataGridConfig<UserData>(
columns: [
DataGridColumn<UserData>(
id: 'user',
header: 'User',
valueBuilder: (u) => u.name,
sortable: true,
@@ -133,12 +199,14 @@ class _UsersListPageState extends ConsumerState<UsersListPage> {
cellBuilder: (context, u) => _UserCell(user: u),
),
DataGridColumn<UserData>(
id: 'roles',
header: 'Roles',
valueBuilder: (u) => u.roles.join(', '),
width: const DataGridColumnWidth.flex(1),
cellBuilder: (context, u) => _RolesCell(roles: u.roles),
),
DataGridColumn<UserData>(
id: 'lastLogin',
header: 'Last Login',
valueBuilder: (u) => u.lastLoginFormatted,
width: const DataGridColumnWidth.fixed(120),
@@ -154,6 +222,15 @@ class _UsersListPageState extends ConsumerState<UsersListPage> {
@override
Widget build(BuildContext context) {
// Listen for grid state changes to sync URL
ref.listen(_gridProvider, (previous, next) {
if (previous?.searchQuery != next.searchQuery ||
previous?.sortColumnIndex != next.sortColumnIndex ||
previous?.sortDescending != next.sortDescending) {
_scheduleUrlSync();
}
});
return DataGrid<UserData>(
provider: _gridProvider,
config: _buildConfig(),
@@ -0,0 +1,301 @@
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/theme/theme_provider.dart';
import 'package:tatlock_ui/routing/room_registry.dart';
/// Settings page with user preferences.
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final currentTheme = ref.watch(themeProvider);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
body: authState.when(
data: (auth) => SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Page header
Text(
'Settings',
style: textTheme.headlineMedium,
),
const SizedBox(height: 32),
// Appearance section
_SectionHeader(title: 'Appearance'),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
_themeIcon(currentTheme),
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Theme', style: textTheme.titleMedium),
Text(
'Choose your preferred color scheme',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
DropdownButton<ThemeSetting>(
value: currentTheme,
underline: const SizedBox(),
onChanged: (value) {
if (value != null) {
_updateTheme(ref, value);
}
},
items: const [
DropdownMenuItem(
value: ThemeSetting.system,
child: Text('System'),
),
DropdownMenuItem(
value: ThemeSetting.light,
child: Text('Light'),
),
DropdownMenuItem(
value: ThemeSetting.dark,
child: Text('Dark'),
),
],
),
],
),
),
),
const SizedBox(height: 24),
// Navigation section
_SectionHeader(title: 'Navigation'),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
Icons.home_outlined,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Default Room',
style: textTheme.titleMedium,
),
Text(
'Room to show when app opens',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
DropdownButton<String>(
value:
auth.preferences?.defaultRoom ?? 'front-hall',
underline: const SizedBox(),
onChanged: (value) {
if (value != null) {
_updateDefaultRoom(ref, value);
}
},
items: roomRegistry.all
.map(
(room) => DropdownMenuItem(
value: room.id,
child: Text(room.label),
),
)
.toList(),
),
],
),
),
),
const SizedBox(height: 24),
// Account section
_SectionHeader(title: 'Account'),
const SizedBox(height: 8),
Card(
child: Column(
children: [
_AccountInfoTile(
icon: Icons.person_outline,
label: 'Name',
value: auth.userName ?? 'Not available',
),
const Divider(height: 1),
_AccountInfoTile(
icon: Icons.email_outlined,
label: 'Email',
value: auth.userEmail ?? 'Not available',
),
if (auth.roles.isNotEmpty) ...[
const Divider(height: 1),
_AccountInfoTile(
icon: Icons.shield_outlined,
label: 'Roles',
value: auth.roles.map((r) => r.name).join(', '),
),
],
],
),
),
],
),
),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
Text(
'Failed to load settings',
style: textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
error.toString(),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
IconData _themeIcon(ThemeSetting theme) {
return switch (theme) {
ThemeSetting.system => Icons.brightness_auto,
ThemeSetting.light => Icons.light_mode,
ThemeSetting.dark => Icons.dark_mode,
};
}
Future<void> _updateTheme(WidgetRef ref, ThemeSetting setting) async {
// Update local theme immediately for instant UI response
await ref.read(themeProvider.notifier).setSetting(setting);
// Sync to backend
try {
await ref.read(authProvider.notifier).updatePreferences(
theme: setting.name,
);
} catch (e) {
developer.log('Failed to sync theme preference: $e', name: 'settings');
}
}
Future<void> _updateDefaultRoom(WidgetRef ref, String room) async {
try {
await ref.read(authProvider.notifier).updatePreferences(
defaultRoom: room,
);
} catch (e) {
developer.log('Failed to sync default room: $e', name: 'settings');
}
}
}
/// Section header widget.
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.title});
final String title;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Text(
title.toUpperCase(),
style: textTheme.labelMedium?.copyWith(
color: colorScheme.primary,
letterSpacing: 1.0,
fontWeight: FontWeight.w600,
),
);
}
}
/// Account info tile widget.
class _AccountInfoTile extends StatelessWidget {
const _AccountInfoTile({
required this.icon,
required this.label,
required this.value,
});
final IconData icon;
final String label;
final String value;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
)),
const SizedBox(height: 2),
Text(value, style: textTheme.titleMedium),
],
),
),
],
),
);
}
}
+25 -10
View File
@@ -1,22 +1,37 @@
import 'dart:developer' as developer;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';
import 'core/auth/auth_controller.dart';
import 'core/config/url_strategy.dart';
import 'version.g.dart';
void main() {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
developer.log(
'${AppVersion.name} v${AppVersion.fullVersion}',
name: 'tatlock_ui',
);
// Enable semantics tree on web for accessibility and automation tools.
// This exposes widget identifiers to browser automation (Puppeteer, etc.)
if (kIsWeb) {
SemanticsBinding.instance.ensureSemantics();
}
runApp(
const ProviderScope(
child: TatlockApp(),
),
);
// Use path-based URLs on web (no-op on mobile/desktop)
configureUrlStrategy();
debugPrint('🪣 ${AppVersion.name} v${AppVersion.fullVersion}');
// Initialize auth before starting the app.
// This handles OIDC callback and silent auth on web.
// If it returns false, we're redirecting and shouldn't continue.
final authReady = await AuthController.initialize();
if (!authReady) {
developer.log('Auth redirecting, not starting app', name: 'tatlock_ui');
return; // Don't run the app - browser is redirecting
}
runApp(const ProviderScope(child: TatlockApp()));
}
+69 -52
View File
@@ -1,48 +1,98 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/features/control_room/router.dart';
import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.dart';
import 'package:tatlock_ui/features/front_hall/router.dart';
import 'package:tatlock_ui/features/media_room/router.dart';
import 'package:tatlock_ui/features/parlor/router.dart';
import 'package:tatlock_ui/features/security/router.dart';
import 'package:tatlock_ui/features/settings/presentation/pages/settings_page.dart';
import 'package:tatlock_ui/routing/room_registry.dart';
import 'package:tatlock_ui/shared/layouts/app_scaffold.dart';
part 'app_router.g.dart';
/// Initialize room registry with all rooms.
///
/// Called once before creating the router.
/// Order of registration determines tab order in UI.
void _initializeRoomRegistry() {
// Skip if already initialized
if (roomRegistry.all.isNotEmpty) return;
// Register rooms in display order
registerFrontHall();
registerControlRoom();
registerSecurity();
registerParlor();
registerMediaRoom();
}
/// No-animation page builder for instant transitions
Page<void> noTransitionPage(BuildContext context, GoRouterState state, Widget child) {
return NoTransitionPage<void>(
key: state.pageKey,
child: child,
);
}
/// Route paths as constants.
abstract class AppRoutes {
static const frontHall = '/';
static const parlor = '/parlor';
static const root = '/';
static const settings = '/settings';
}
/// Maps defaultRoom preference value to route path.
String _routeForDefaultRoom(String? defaultRoom) {
// Look up room in registry, fall back to first room's default route
final room = roomRegistry.byId(defaultRoom ?? '');
if (room != null) {
return room.defaultRoute;
}
// Default to first registered room (Front Hall)
return roomRegistry.all.isNotEmpty
? roomRegistry.all.first.defaultRoute
: FrontHallRoutes.base;
}
/// Provides the GoRouter instance.
@riverpod
GoRouter appRouter(Ref ref) {
// Initialize room registry on first access
_initializeRoomRegistry();
final authState = ref.watch(authProvider);
return GoRouter(
initialLocation: AppRoutes.frontHall,
initialLocation: AppRoutes.root,
debugLogDiagnostics: true,
redirect: (context, state) {
// Redirect from root to user's default room preference
if (state.matchedLocation == AppRoutes.root) {
final defaultRoom = authState.value?.preferences?.defaultRoom;
return _routeForDefaultRoom(defaultRoom);
}
return null;
},
routes: [
// Root redirects to default room (handled by redirect callback above)
GoRoute(
path: AppRoutes.root,
redirect: (context, state) => _routeForDefaultRoom(null),
),
// Main app routes (inside shell with app scaffold)
ShellRoute(
builder: (context, state, child) => AppScaffold(child: child),
routes: [
GoRoute(
path: AppRoutes.frontHall,
name: 'frontHall',
builder: (context, state) => const FrontHallPage(),
),
...controlRoomRoutes(),
...securityRoutes(),
GoRoute(
path: AppRoutes.parlor,
name: 'parlor',
builder: (context, state) =>
const _PlaceholderPage(title: 'Parlor'),
),
// All room routes from registry
...roomRegistry.allRoutes(),
// Settings page (not a room, just a page)
GoRoute(
path: AppRoutes.settings,
name: 'settings',
builder: (context, state) =>
const _PlaceholderPage(title: 'Settings'),
pageBuilder: (context, state) =>
noTransitionPage(context, state, const SettingsPage()),
),
],
),
@@ -50,37 +100,4 @@ GoRouter appRouter(Ref ref) {
);
}
/// Placeholder page for routes not yet implemented.
class _PlaceholderPage extends StatelessWidget {
const _PlaceholderPage({required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.construction,
size: 64,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 16),
Text(
title,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Coming soon',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
],
),
);
}
}
+113
View File
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// Defines a room in the application.
///
/// Each feature/room registers itself with metadata for navigation,
/// routing, and permission-based access control.
class RoomDefinition {
const RoomDefinition({
required this.id,
required this.label,
required this.icon,
required this.selectedIcon,
required this.defaultRoute,
required this.routes,
this.requiredPermissions = const [],
});
/// Unique identifier used in preferences (e.g., 'front-hall', 'control-room').
final String id;
/// Display label for UI.
final String label;
/// Icon when not selected.
final IconData icon;
/// Icon when selected.
final IconData selectedIcon;
/// Default route path when room is selected.
final String defaultRoute;
/// All routes this room provides to go_router.
final List<RouteBase> Function() routes;
/// Permissions required to access this room (empty = accessible to all).
/// User must have ALL listed permissions.
final List<String> requiredPermissions;
}
/// Central registry of all rooms in the application.
///
/// Each room's router.dart file registers itself here.
/// UI components query this registry to build navigation.
class RoomRegistry {
RoomRegistry._();
static final RoomRegistry instance = RoomRegistry._();
final List<RoomDefinition> _rooms = [];
/// Register a room. Called by each room's router.dart.
void register(RoomDefinition room) {
// Avoid duplicate registration
if (!_rooms.any((r) => r.id == room.id)) {
_rooms.add(room);
}
}
/// Get all registered rooms.
List<RoomDefinition> get all => List.unmodifiable(_rooms);
/// Get rooms filtered by user permissions.
///
/// [userPermissions] is the set of permissions the user has.
/// Returns rooms where the user has ALL required permissions.
List<RoomDefinition> accessibleTo(Set<String> userPermissions) {
return _rooms.where((room) {
if (room.requiredPermissions.isEmpty) return true;
return room.requiredPermissions.every(userPermissions.contains);
}).toList();
}
/// Get a room by its ID.
RoomDefinition? byId(String id) {
try {
return _rooms.firstWhere((r) => r.id == id);
} catch (_) {
return null;
}
}
/// Get the index of a room by its ID.
int indexOfId(String id) {
return _rooms.indexWhere((r) => r.id == id);
}
/// Get the index of a room by route path.
int indexOfRoute(String path) {
// Find room whose defaultRoute matches or whose routes start with path
for (var i = 0; i < _rooms.length; i++) {
final room = _rooms[i];
if (path == room.defaultRoute || path.startsWith('/${room.id}')) {
return i;
}
}
return 0; // Default to first room
}
/// Get all routes from all registered rooms.
List<RouteBase> allRoutes() {
return _rooms.expand((room) => room.routes()).toList();
}
/// Clear registry (useful for testing).
void clear() {
_rooms.clear();
}
}
/// Global registry instance.
final roomRegistry = RoomRegistry.instance;
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter/foundation.dart' show kIsWeb;
// Conditional import for web-only functionality
import 'url_state_stub.dart' if (dart.library.js_interop) 'url_state_web.dart'
as platform;
/// Parses and builds URL query parameters for page state.
///
/// Used to enable deep-linking for DataGrid and filter panel state.
/// Query params are read once on page load and updated via browser
/// replaceState to avoid GoRouter rebuild loops.
class PageUrlState {
const PageUrlState({
this.id,
this.filter,
this.search,
this.sortColumn,
this.sortDescending = false,
});
/// Opened document ID (view/edit mode).
final String? id;
/// Filter panel search value.
final String? filter;
/// DataGrid search value.
final String? search;
/// Column ID for sorting.
final String? sortColumn;
/// Sort direction (true = descending).
final bool sortDescending;
/// Parse from query parameters map.
factory PageUrlState.fromQueryParams(Map<String, String> params) {
return PageUrlState(
id: params['id'],
filter: params['filter'],
search: params['search'],
sortColumn: params['sort'],
sortDescending: params['order'] == 'desc',
);
}
/// Convert to query parameter map (omits empty/default values).
Map<String, String> toQueryParams() {
return {
if (id != null && id!.isNotEmpty) 'id': id!,
if (filter != null && filter!.isNotEmpty) 'filter': filter!,
if (search != null && search!.isNotEmpty) 'search': search!,
if (sortColumn != null && sortColumn!.isNotEmpty) 'sort': sortColumn!,
if (sortDescending) 'order': 'desc',
};
}
/// Create a copy with modified values.
PageUrlState copyWith({
String? id,
String? filter,
String? search,
String? sortColumn,
bool? sortDescending,
bool clearId = false,
}) {
return PageUrlState(
id: clearId ? null : (id ?? this.id),
filter: filter ?? this.filter,
search: search ?? this.search,
sortColumn: sortColumn ?? this.sortColumn,
sortDescending: sortDescending ?? this.sortDescending,
);
}
/// Whether any state is present.
bool get isEmpty =>
id == null &&
(filter == null || filter!.isEmpty) &&
(search == null || search!.isEmpty) &&
sortColumn == null;
@override
String toString() =>
'PageUrlState(id: $id, filter: $filter, search: $search, '
'sort: $sortColumn, desc: $sortDescending)';
}
/// Updates browser URL with query parameters without triggering navigation.
///
/// Uses browser's replaceState API on web, no-op on other platforms.
/// This allows the URL to stay in sync for bookmarking/sharing without
/// causing Flutter to rebuild.
void updateBrowserUrlParams(Map<String, String> params) {
if (kIsWeb) {
platform.updateBrowserUrlParams(params);
}
}
+4
View File
@@ -0,0 +1,4 @@
/// Stub implementation for non-web platforms.
void updateBrowserUrlParams(Map<String, String> params) {
// No-op on non-web platforms
}
+13
View File
@@ -0,0 +1,13 @@
import 'package:web/web.dart' as web;
/// Updates browser URL with query parameters without triggering navigation.
///
/// Uses browser's replaceState API to update the URL bar for bookmarking
/// without causing Flutter/GoRouter to rebuild the page.
void updateBrowserUrlParams(Map<String, String> params) {
final currentUri = Uri.parse(web.window.location.href);
final newUri = currentUri.replace(
queryParameters: params.isEmpty ? null : params,
);
web.window.history.replaceState(null, '', newUri.toString());
}
@@ -45,6 +45,7 @@ enum DataGridColumnAlignment {
/// Column definition for DataGrid.
class DataGridColumn<T> {
const DataGridColumn({
required this.id,
required this.header,
required this.valueBuilder,
this.cellBuilder,
@@ -58,6 +59,9 @@ class DataGridColumn<T> {
this.tooltip,
});
/// Unique identifier for this column (used in URL deep-linking).
final String id;
/// Column header text.
final String header;
@@ -15,9 +15,15 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
required this.source,
required this.config,
required this.idSelector,
String? initialSearch,
int? initialSortColumnIndex,
bool? initialSortDescending,
}) : super(DataGridState<T>(
sortColumnIndex: config.defaultSortColumn,
sortDescending: config.defaultSortDescending,
searchQuery: initialSearch ?? '',
sortColumnIndex:
initialSortColumnIndex ?? config.defaultSortColumn,
sortDescending:
initialSortDescending ?? config.defaultSortDescending,
)) {
// Initial load
_load();
@@ -29,8 +35,13 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
/// Grid configuration.
final DataGridConfig<T> config;
/// Function to extract unique ID from an item.
final Object Function(T item) idSelector;
/// Function to extract unique ID from an item (used for selection and URL deep-linking).
///
/// Returns a unique string identifier for each row. Can be:
/// - A simple ID field: `(item) => item.id.toString()`
/// - A combination of fields: `(item) => '${item.name}_${item.type}'`
/// - Any unique identifier suitable for URLs
final String Function(T item) idSelector;
/// Debounce timer for search.
Timer? _searchDebounce;
@@ -185,7 +196,7 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
if (!config.rowsSelectable) return;
final id = idSelector(item);
final newSelection = Set<Object>.from(state.selectedIds);
final newSelection = Set<String>.from(state.selectedIds);
if (newSelection.contains(id)) {
newSelection.remove(id);
@@ -287,17 +298,35 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
/// idSelector: (c) => c.id,
/// );
/// ```
///
/// For URL deep-linking, pass initial state from URL params:
/// ```dart
/// final gridProvider = dataGridProvider<Container>(
/// source: source,
/// config: config,
/// idSelector: (c) => c.id,
/// initialSearch: urlState.search,
/// initialSortColumnIndex: _columnIndexForId(urlState.sortColumn),
/// initialSortDescending: urlState.sortDescending,
/// );
/// ```
StateNotifierProvider<DataGridController<T>, DataGridState<T>>
dataGridProvider<T>({
required DataGridSource<T> source,
required DataGridConfig<T> config,
required Object Function(T) idSelector,
required String Function(T) idSelector,
String? initialSearch,
int? initialSortColumnIndex,
bool? initialSortDescending,
}) {
return StateNotifierProvider<DataGridController<T>, DataGridState<T>>(
(ref) => DataGridController<T>(
source: source,
config: config,
idSelector: idSelector,
initialSearch: initialSearch,
initialSortColumnIndex: initialSortColumnIndex,
initialSortDescending: initialSortDescending,
),
);
}
@@ -31,7 +31,7 @@ sealed class DataGridState<T> with _$DataGridState<T> {
@Default(false) bool sortDescending,
/// Currently selected item IDs (if selectable).
@Default({}) Set<Object> selectedIds,
@Default({}) Set<String> selectedIds,
/// Current page (for paginated mode).
@Default(0) int currentPage,
+172 -19
View File
@@ -1,12 +1,19 @@
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/features/control_room/router.dart';
import 'package:tatlock_ui/features/security/router.dart';
import 'package:tatlock_ui/routing/app_router.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/config/app_config.dart';
import 'package:tatlock_ui/routing/room_registry.dart';
import 'package:tatlock_ui/shared/layouts/widgets/top_header_bar.dart';
/// Main application scaffold with top header navigation.
///
/// Handles authentication automatically:
/// - If not authenticated, auto-initiates OIDC flow
/// - Shows loading state during authentication
/// - Shows error state if auth fails (with retry)
///
/// Layout structure per UI_LAYOUT.md:
/// ```
/// ┌─────────────────────────────────────────────────────────────────┐
@@ -15,16 +22,62 @@ import 'package:tatlock_ui/shared/layouts/widgets/top_header_bar.dart';
/// │ BODY: Room page content (may include room-specific sidebar) │
/// └─────────────────────────────────────────────────────────────────┘
/// ```
class AppScaffold extends StatelessWidget {
class AppScaffold extends ConsumerStatefulWidget {
const AppScaffold({super.key, required this.child});
final Widget child;
@override
ConsumerState<AppScaffold> createState() => _AppScaffoldState();
}
class _AppScaffoldState extends ConsumerState<AppScaffold> {
// Header height must match TopHeaderBar._headerHeight
static const double _headerHeight = 56.0;
bool _authInitiated = false;
@override
Widget build(BuildContext context) {
// No auth required in LAN mode - show content directly
if (!AppConfig.requiresAuth) {
return _buildScaffold(context);
}
// Watch auth state (works for both web and mobile)
final authAsync = ref.watch(authProvider);
return authAsync.when(
data: (authState) {
if (authState.isAuthenticated) {
// Authenticated - show the app
_authInitiated = false; // Reset for next time
return _buildScaffold(context);
}
// On web, NPM handles auth - if we're here without auth, something is wrong
// (NPM should have redirected to Authentik before we loaded)
if (kIsWeb) {
return _buildAuthErrorScreen(context, 'Authentication required');
}
// Mobile: Not authenticated - auto-initiate OIDC
if (!_authInitiated) {
_authInitiated = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(authProvider.notifier).signIn();
});
}
// Show loading while redirecting to Authentik
return _buildAuthLoadingScreen(context, 'Redirecting to sign in...');
},
loading: () => _buildAuthLoadingScreen(context, 'Loading user info...'),
error: (error, _) => _buildAuthErrorScreen(context, error),
);
}
Widget _buildScaffold(BuildContext context) {
return Scaffold(
body: Stack(
children: [
@@ -32,7 +85,7 @@ class AppScaffold extends StatelessWidget {
Positioned.fill(
child: Padding(
padding: const EdgeInsets.only(top: _headerHeight),
child: child,
child: widget.child,
),
),
@@ -51,24 +104,124 @@ class AppScaffold extends StatelessWidget {
);
}
Widget _buildAuthLoadingScreen(BuildContext context, String message) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.home_work_outlined,
size: 64,
color: colorScheme.primary,
),
const SizedBox(height: 24),
Text(
'Tatlock Estate',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 32),
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(height: 16),
Text(
message,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
Widget _buildAuthErrorScreen(BuildContext context, Object error) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Card(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 24),
Text(
'Authentication Failed',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_formatError(error),
style: TextStyle(color: colorScheme.onErrorContainer),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () {
_authInitiated = false;
ref.read(authProvider.notifier).signIn();
},
icon: const Icon(Icons.refresh),
label: const Text('Try again'),
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
),
],
),
),
),
),
),
);
}
String _formatError(Object error) {
final message = error.toString();
if (message.contains('user_cancelled')) {
return 'Sign in was cancelled';
}
if (message.contains('network')) {
return 'Network error. Please check your connection.';
}
return 'Authentication failed. Please try again.';
}
int _selectedIndex(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
if (location.startsWith(ControlRoomRoutes.base)) return 1;
if (location.startsWith(SecurityRoutes.base)) return 2;
if (location.startsWith(AppRoutes.parlor)) return 3;
// Settings is no longer in main nav (accessed via Profile dropdown)
return 0; // Front Hall
return roomRegistry.indexOfRoute(location);
}
void _onNavSelected(BuildContext context, int index) {
final route = switch (index) {
0 => AppRoutes.frontHall,
1 => ControlRoomRoutes.containers,
2 => SecurityRoutes.users,
3 => AppRoutes.parlor,
_ => AppRoutes.frontHall,
};
context.go(route);
final rooms = roomRegistry.all;
if (index >= 0 && index < rooms.length) {
context.go(rooms[index].defaultRoute);
}
}
}
+17 -10
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
import 'panel_header.dart';
@@ -199,17 +200,22 @@ class _NavTile extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Material(
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.4)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onTap,
return Semantics(
identifier: NavSemantics.item(item.id),
label: item.label,
button: true,
selected: isSelected,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Material(
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.4)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: Padding(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
@@ -258,6 +264,7 @@ class _NavTile extends StatelessWidget {
),
),
),
),
);
}
}
+216 -73
View File
@@ -1,7 +1,11 @@
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
import 'package:tatlock_ui/core/theme/theme_provider.dart';
import 'package:tatlock_ui/routing/app_router.dart';
/// Profile dropdown menu in the header.
@@ -13,90 +17,213 @@ class ProfileDropdown extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final currentTheme = ref.watch(themeProvider);
final colorScheme = Theme.of(context).colorScheme;
return authState.when(
data: (auth) => PopupMenuButton<String>(
offset: const Offset(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Tooltip(
message: auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest',
child: CircleAvatar(
radius: 18,
backgroundColor: colorScheme.primaryContainer,
child: auth.isAuthenticated
? Text(
_getInitials(auth.userName),
style: TextStyle(
data: (auth) => Semantics(
identifier: ProfileSemantics.button,
label: 'Profile menu',
button: true,
child: PopupMenuButton<String>(
offset: const Offset(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Tooltip(
message: auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest',
child: CircleAvatar(
radius: 18,
backgroundColor: colorScheme.primaryContainer,
child: auth.isAuthenticated
? Text(
_getInitials(auth.userName),
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w500,
),
)
: Icon(
Icons.person_outline,
size: 20,
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w500,
),
)
: Icon(
Icons.person_outline,
size: 20,
color: colorScheme.onPrimaryContainer,
),
),
),
itemBuilder: (context) => [
// User info header (non-selectable)
PopupMenuItem<String>(
enabled: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest',
style: Theme.of(context).textTheme.titleSmall,
),
if (auth.userEmail != null)
Text(
auth.userEmail!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
const PopupMenuDivider(),
// Settings
const PopupMenuItem<String>(
value: 'settings',
child: Row(
children: [
Icon(Icons.settings_outlined, size: 20),
SizedBox(width: 12),
Text('Settings'),
],
),
),
// Logout (only if authenticated)
if (auth.isAuthenticated)
const PopupMenuItem<String>(
value: 'logout',
child: Row(
itemBuilder: (context) => [
// User info header (non-selectable)
PopupMenuItem<String>(
enabled: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.logout, size: 20),
SizedBox(width: 12),
Text('Logout'),
Text(
auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest',
style: Theme.of(context).textTheme.titleSmall,
),
if (auth.userEmail != null)
Text(
auth.userEmail!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
onSelected: (value) {
switch (value) {
case 'settings':
context.go(AppRoutes.settings);
case 'logout':
ref.read(authProvider.notifier).signOut();
}
},
const PopupMenuDivider(),
// Settings
PopupMenuItem<String>(
value: 'settings',
child: Semantics(
identifier: ProfileSemantics.settings,
child: const Row(
children: [
Icon(Icons.settings_outlined, size: 20),
SizedBox(width: 12),
Text('Settings'),
],
),
),
),
// Theme submenu header
PopupMenuItem<String>(
enabled: false,
height: 32,
child: Text(
'THEME',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
letterSpacing: 0.5,
),
),
),
// Theme: System
PopupMenuItem<String>(
value: 'theme_system',
height: 40,
child: Semantics(
identifier: ProfileSemantics.themeSystem,
child: Row(
children: [
Icon(
Icons.brightness_auto,
size: 18,
color: currentTheme == ThemeSetting.system
? colorScheme.primary
: null,
),
const SizedBox(width: 12),
Text(
'System',
style: currentTheme == ThemeSetting.system
? TextStyle(color: colorScheme.primary)
: null,
),
const Spacer(),
if (currentTheme == ThemeSetting.system)
Icon(Icons.check, size: 16, color: colorScheme.primary),
],
),
),
),
// Theme: Light
PopupMenuItem<String>(
value: 'theme_light',
height: 40,
child: Semantics(
identifier: ProfileSemantics.themeLight,
child: Row(
children: [
Icon(
Icons.light_mode,
size: 18,
color: currentTheme == ThemeSetting.light
? colorScheme.primary
: null,
),
const SizedBox(width: 12),
Text(
'Light',
style: currentTheme == ThemeSetting.light
? TextStyle(color: colorScheme.primary)
: null,
),
const Spacer(),
if (currentTheme == ThemeSetting.light)
Icon(Icons.check, size: 16, color: colorScheme.primary),
],
),
),
),
// Theme: Dark
PopupMenuItem<String>(
value: 'theme_dark',
height: 40,
child: Semantics(
identifier: ProfileSemantics.themeDark,
child: Row(
children: [
Icon(
Icons.dark_mode,
size: 18,
color: currentTheme == ThemeSetting.dark
? colorScheme.primary
: null,
),
const SizedBox(width: 12),
Text(
'Dark',
style: currentTheme == ThemeSetting.dark
? TextStyle(color: colorScheme.primary)
: null,
),
const Spacer(),
if (currentTheme == ThemeSetting.dark)
Icon(Icons.check, size: 16, color: colorScheme.primary),
],
),
),
),
const PopupMenuDivider(),
// Logout (only if authenticated)
if (auth.isAuthenticated)
PopupMenuItem<String>(
value: 'logout',
child: Semantics(
identifier: ProfileSemantics.logout,
child: const Row(
children: [
Icon(Icons.logout, size: 20),
SizedBox(width: 12),
Text('Logout'),
],
),
),
),
],
onSelected: (value) {
switch (value) {
case 'settings':
context.go(AppRoutes.settings);
case 'theme_system':
_updateTheme(ref, ThemeSetting.system);
case 'theme_light':
_updateTheme(ref, ThemeSetting.light);
case 'theme_dark':
_updateTheme(ref, ThemeSetting.dark);
case 'logout':
ref.read(authProvider.notifier).signOut();
}
},
),
),
loading: () => const CircleAvatar(
radius: 18,
@@ -126,4 +253,20 @@ class ProfileDropdown extends ConsumerWidget {
}
return name[0].toUpperCase();
}
/// Update theme locally and sync to API.
Future<void> _updateTheme(WidgetRef ref, ThemeSetting setting) async {
// Update local theme immediately for instant UI response
await ref.read(themeProvider.notifier).setSetting(setting);
// Sync to backend (fire-and-forget, errors logged not shown)
try {
await ref.read(authProvider.notifier).updatePreferences(
theme: setting.name,
);
} catch (e) {
// Theme still works locally even if API sync fails
developer.log('Failed to sync theme preference: $e', name: 'profile');
}
}
}
+24 -46
View File
@@ -1,6 +1,8 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
import 'package:tatlock_ui/routing/room_registry.dart';
import 'profile_dropdown.dart';
@@ -23,28 +25,8 @@ class TopHeaderBar extends StatelessWidget {
static const double _logoCircleRadius = (_logoSize + _logoMargin * 2) / 2; // 64px
static const double _bulgeFraction = 0.20; // 20% of circle below header line
static const _rooms = [
_RoomDestination(
icon: Icons.door_front_door_outlined,
selectedIcon: Icons.door_front_door,
label: 'Front Hall',
),
_RoomDestination(
icon: Icons.dns_outlined,
selectedIcon: Icons.dns,
label: 'Control Room',
),
_RoomDestination(
icon: Icons.security_outlined,
selectedIcon: Icons.security,
label: 'Security',
),
_RoomDestination(
icon: Icons.lightbulb_outline,
selectedIcon: Icons.lightbulb,
label: 'Parlor',
),
];
/// Get rooms from the registry.
List<RoomDefinition> get _rooms => roomRegistry.all;
@override
Widget build(BuildContext context) {
@@ -120,24 +102,31 @@ class TopHeaderBar extends StatelessWidget {
List<Widget> _buildRoomTabs(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final rooms = _rooms;
return List.generate(_rooms.length, (index) {
final room = _rooms[index];
return List.generate(rooms.length, (index) {
final room = rooms[index];
final isSelected = index == selectedIndex;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Tooltip(
message: room.label,
child: IconButton(
icon: Icon(
isSelected ? room.selectedIcon : room.icon,
color: isSelected ? colorScheme.primary : colorScheme.onSurface,
),
onPressed: () => onRoomSelected(index),
style: IconButton.styleFrom(
backgroundColor:
isSelected ? colorScheme.primaryContainer : null,
child: Semantics(
identifier: RoomTabSemantics.forIndex(index),
label: room.label,
button: true,
selected: isSelected,
child: Tooltip(
message: room.label,
child: IconButton(
icon: Icon(
isSelected ? room.selectedIcon : room.icon,
color: isSelected ? colorScheme.primary : colorScheme.onSurface,
),
onPressed: () => onRoomSelected(index),
style: IconButton.styleFrom(
backgroundColor:
isSelected ? colorScheme.primaryContainer : null,
),
),
),
),
@@ -250,14 +239,3 @@ class _HeaderPainter extends CustomPainter {
}
}
class _RoomDestination {
const _RoomDestination({
required this.icon,
required this.selectedIcon,
required this.label,
});
final IconData icon;
final IconData selectedIcon;
final String label;
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
/// Pastel stoplight colors for consistent status indication across the app.
///
/// Use these colors for any green/yellow/red status flows:
/// - System stats (CPU, RAM, disk usage)
/// - Air quality levels
/// - Container health status
/// - Any other threshold-based indicators
abstract final class StoplightColors {
/// Good/healthy/low usage (0-50%)
static const green = Color(0xFF81C784);
/// Warning/moderate/medium usage (51-75%)
static const orange = Color(0xFFFFB74D);
/// Critical/unhealthy/high usage (76-100%)
static const red = Color(0xFFE57373);
/// Returns appropriate color based on percentage (0-100).
///
/// - ≤50%: green
/// - 51-75%: orange
/// - >75%: red
static Color forPercent(double percent) {
if (percent <= 50) return green;
if (percent <= 75) return orange;
return red;
}
/// Returns appropriate color based on value (0.0-1.0).
///
/// - ≤0.5: green
/// - 0.51-0.75: orange
/// - >0.75: red
static Color forValue(double value) => forPercent(value * 100);
}
+233 -74
View File
@@ -1,16 +1,24 @@
import 'package:flutter/material.dart';
import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart'
as api;
import 'package:tatlock_ui/shared/theme/stoplight_colors.dart';
/// Air Quality Index widget displaying current AQI.
///
/// Currently uses mock data. Will be connected to air quality API in future.
/// Displays air quality data from the Core API environment endpoint.
/// Only render this widget when air quality data is available.
class AirQualityWidget extends StatelessWidget {
const AirQualityWidget({
super.key,
this.apiData,
this.data,
this.compact = false,
});
/// Air quality data to display. Uses mock data if null.
/// Air quality data from API. Takes priority over legacy data.
final api.AirQualityData? apiData;
/// Legacy air quality data to display. Uses mock data if null.
final AirQualityData? data;
/// Whether to use compact layout.
@@ -19,7 +27,17 @@ class AirQualityWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final aqi = data ?? AirQualityData.mock();
// Show "no data" state when no API data and no legacy data
if (apiData == null && data == null) {
if (compact) {
return _buildCompactNoData(context, colorScheme);
}
return _buildNoData(context, colorScheme);
}
// Convert API data to local model, or use legacy data
final aqi = apiData != null ? _fromApiData(apiData!) : data!;
if (compact) {
return _buildCompact(context, colorScheme, aqi);
@@ -49,77 +67,102 @@ class AirQualityWidget extends StatelessWidget {
),
],
),
const SizedBox(height: 16),
const SizedBox(height: 8),
// AQI Display
Row(
children: [
// AQI number with colored background
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: aqi.level.color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: aqi.level.color.withValues(alpha: 0.3),
width: 2,
),
),
child: Center(
child: Text(
'${aqi.index}',
style:
Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: aqi.level.color,
),
),
),
),
const SizedBox(width: 16),
// Level info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
aqi.level.label,
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: aqi.level.color,
),
),
const SizedBox(height: 4),
Text(
aqi.level.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
// Content area with fixed height, horizon line at 50px from bottom
SizedBox(
height: 170,
child: Stack(
children: [
// Main content positioned above horizon (8px gap)
Positioned(
left: 0,
right: 0,
bottom: 58,
child: Row(
children: [
// AQI number with colored background
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: aqi.level.color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: aqi.level.color.withValues(alpha: 0.3),
width: 2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
child: Center(
child: Text(
'${aqi.index}',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: aqi.level.color,
),
),
),
),
const SizedBox(width: 16),
// Level info
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
aqi.level.label,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.w600,
color: aqi.level.color,
),
),
const SizedBox(height: 4),
Text(
aqi.level.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
],
),
// Pollutants
if (aqi.pollutants.isNotEmpty) ...[
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 12),
Wrap(
spacing: 16,
runSpacing: 8,
children: aqi.pollutants
.map((p) => _PollutantChip(pollutant: p))
.toList(),
// Horizon divider at fixed position (matches Sun Position horizonY)
if (aqi.pollutants.isNotEmpty)
Positioned(
left: 0,
right: 0,
bottom: 50,
child: const Divider(height: 1),
),
// Footer below horizon
if (aqi.pollutants.isNotEmpty)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Wrap(
spacing: 16,
runSpacing: 8,
children: aqi.pollutants
.map((p) => _PollutantChip(pollutant: p))
.toList(),
),
),
],
),
],
),
],
),
),
@@ -166,6 +209,97 @@ class AirQualityWidget extends StatelessWidget {
),
);
}
Widget _buildNoData(BuildContext context, ColorScheme colorScheme) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Header
Row(
children: [
Icon(
Icons.air,
size: 20,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
'Air Quality',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 8),
// Match sun position card height (170px content area)
SizedBox(
height: 170,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.air_outlined,
size: 32,
color: colorScheme.outline,
),
const SizedBox(height: 8),
Text(
'No data available',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.outline,
),
),
],
),
),
),
],
),
),
);
}
Widget _buildCompactNoData(BuildContext context, ColorScheme colorScheme) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: colorScheme.outline.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: Text(
'--',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.outline,
),
),
),
),
const SizedBox(width: 8),
Text(
'AQI',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.outline,
),
),
],
),
),
);
}
}
class _PollutantChip extends StatelessWidget {
@@ -198,33 +332,58 @@ class _PollutantChip extends StatelessWidget {
}
}
/// Converts API air quality data to local AirQualityData model.
AirQualityData _fromApiData(api.AirQualityData data) {
final index = data.aqi ?? 0;
final pollutants = <Pollutant>[];
if (data.pm25 != null) {
pollutants.add(Pollutant(name: 'PM2.5', value: data.pm25!, unit: 'µg/m³'));
}
if (data.pm10 != null) {
pollutants.add(Pollutant(name: 'PM10', value: data.pm10!, unit: 'µg/m³'));
}
if (data.o3 != null) {
pollutants.add(Pollutant(name: 'O₃', value: data.o3!, unit: 'ppb'));
}
if (data.no2 != null) {
pollutants.add(Pollutant(name: 'NO₂', value: data.no2!, unit: 'ppb'));
}
return AirQualityData(
index: index,
level: AqiLevel.fromIndex(index),
pollutants: pollutants,
);
}
/// Air Quality Index levels based on US EPA standard.
enum AqiLevel {
good(
label: 'Good',
description: 'Air quality is satisfactory',
color: Colors.green,
color: StoplightColors.green,
minIndex: 0,
maxIndex: 50,
),
moderate(
label: 'Moderate',
description: 'Acceptable for most people',
color: Colors.amber,
color: StoplightColors.orange,
minIndex: 51,
maxIndex: 100,
),
unhealthySensitive(
label: 'Unhealthy for Sensitive',
description: 'May affect sensitive groups',
color: Colors.orange,
color: StoplightColors.orange,
minIndex: 101,
maxIndex: 150,
),
unhealthy(
label: 'Unhealthy',
description: 'Health effects for everyone',
color: Colors.red,
color: StoplightColors.red,
minIndex: 151,
maxIndex: 200,
),
+1 -1
View File
@@ -150,7 +150,7 @@ class EntitySection extends StatelessWidget {
),
),
),
if (trailing != null) trailing!,
?trailing,
],
),
const SizedBox(height: 8),
+353
View File
@@ -0,0 +1,353 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart';
/// Forecast widget displaying multi-day weather outlook.
///
/// Shows a horizontal scrollable list of forecast days with
/// high/low temperatures and weather icons.
class ForecastWidget extends StatelessWidget {
const ForecastWidget({
super.key,
this.forecast,
this.compact = false,
});
/// Forecast data to display.
final List<ForecastDay>? forecast;
/// Whether to use compact layout.
final bool compact;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
if (forecast == null || forecast!.isEmpty) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Header
Row(
children: [
Icon(
Icons.calendar_today,
size: 20,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
'Forecast',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 8),
// Match sun position card height (170px content area)
SizedBox(
height: 170,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.event_busy_outlined,
size: 32,
color: colorScheme.outline,
),
const SizedBox(height: 8),
Text(
'No data available',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.outline,
),
),
],
),
),
),
],
),
),
);
}
if (compact) {
return _buildCompact(context, colorScheme);
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Header
Row(
children: [
Icon(
Icons.calendar_today,
size: 20,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
'Forecast',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 8),
// Content area with fixed height, card bottoms at horizon (50px from bottom)
SizedBox(
height: 170,
child: Padding(
padding: const EdgeInsets.only(bottom: 50),
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 110,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: forecast!.length,
separatorBuilder: (_, i) => const SizedBox(width: 12),
itemBuilder: (context, index) {
return _ForecastDayCard(day: forecast![index]);
},
),
),
),
),
),
],
),
),
);
}
Widget _buildCompact(BuildContext context, ColorScheme colorScheme) {
// Show just first 3 days in compact mode
final days = forecast!.take(3).toList();
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.calendar_today,
size: 20,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
...days.map((day) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_getDayName(day.date),
style: Theme.of(context).textTheme.labelSmall,
),
Icon(
_getWeatherIcon(day.conditions),
size: 16,
color: _getWeatherColor(day.conditions),
),
Text(
'${day.high?.round() ?? '--'}°',
style: Theme.of(context).textTheme.labelSmall,
),
],
),
)),
],
),
),
);
}
String _getDayName(String dateStr) {
try {
final date = DateTime.parse(dateStr);
final now = DateTime.now();
if (date.day == now.day &&
date.month == now.month &&
date.year == now.year) {
return 'Today';
}
final tomorrow = now.add(const Duration(days: 1));
if (date.day == tomorrow.day &&
date.month == tomorrow.month &&
date.year == tomorrow.year) {
return 'Tmrw';
}
return DateFormat('E').format(date);
} catch (_) {
return dateStr.length > 3 ? dateStr.substring(0, 3) : dateStr;
}
}
IconData _getWeatherIcon(String? conditions) {
final condition = conditions?.toLowerCase() ?? '';
if (condition.contains('clear') || condition.contains('sunny')) {
return Icons.wb_sunny;
} else if (condition.contains('cloud') || condition.contains('overcast')) {
return Icons.cloud;
} else if (condition.contains('rain') || condition.contains('drizzle')) {
return Icons.grain;
} else if (condition.contains('storm') || condition.contains('thunder')) {
return Icons.thunderstorm;
} else if (condition.contains('snow') || condition.contains('sleet')) {
return Icons.ac_unit;
} else if (condition.contains('fog') || condition.contains('mist')) {
return Icons.blur_on;
}
return Icons.cloud;
}
Color? _getWeatherColor(String? conditions) {
final condition = conditions?.toLowerCase() ?? '';
if (condition.contains('clear') || condition.contains('sunny')) {
return Colors.amber;
} else if (condition.contains('cloud')) {
return Colors.blueGrey;
} else if (condition.contains('rain')) {
return Colors.blue;
} else if (condition.contains('storm')) {
return Colors.deepPurple;
} else if (condition.contains('snow')) {
return Colors.lightBlue;
}
return null;
}
}
class _ForecastDayCard extends StatelessWidget {
const _ForecastDayCard({required this.day});
final ForecastDay day;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final icon = _getWeatherIcon(day.conditions);
final color = _getWeatherColor(day.conditions) ?? colorScheme.primary;
return Container(
width: 72,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Day name
Text(
_getDayName(day.date),
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
// Weather icon
Icon(
icon,
size: 24,
color: color,
),
// High/Low temps
Column(
children: [
Text(
'${day.high?.round() ?? '--'}°',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
'${day.low?.round() ?? '--'}°',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
],
),
);
}
String _getDayName(String dateStr) {
try {
final date = DateTime.parse(dateStr);
final now = DateTime.now();
if (date.day == now.day &&
date.month == now.month &&
date.year == now.year) {
return 'Today';
}
final tomorrow = now.add(const Duration(days: 1));
if (date.day == tomorrow.day &&
date.month == tomorrow.month &&
date.year == tomorrow.year) {
return 'Tmrw';
}
return DateFormat('EEE').format(date);
} catch (_) {
return dateStr.length > 3 ? dateStr.substring(0, 3) : dateStr;
}
}
IconData _getWeatherIcon(String? conditions) {
final condition = conditions?.toLowerCase() ?? '';
if (condition.contains('clear') || condition.contains('sunny')) {
return Icons.wb_sunny;
} else if (condition.contains('cloud') || condition.contains('overcast')) {
return Icons.cloud;
} else if (condition.contains('rain') || condition.contains('drizzle')) {
return Icons.grain;
} else if (condition.contains('storm') || condition.contains('thunder')) {
return Icons.thunderstorm;
} else if (condition.contains('snow') || condition.contains('sleet')) {
return Icons.ac_unit;
} else if (condition.contains('fog') || condition.contains('mist')) {
return Icons.blur_on;
}
return Icons.cloud;
}
Color? _getWeatherColor(String? conditions) {
final condition = conditions?.toLowerCase() ?? '';
if (condition.contains('clear') || condition.contains('sunny')) {
return Colors.amber;
} else if (condition.contains('cloud')) {
return Colors.blueGrey;
} else if (condition.contains('rain')) {
return Colors.blue;
} else if (condition.contains('storm')) {
return Colors.deepPurple;
} else if (condition.contains('snow')) {
return Colors.lightBlue;
}
return null;
}
}
+30 -27
View File
@@ -2,7 +2,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
/// A circular gauge widget for displaying percentage values.
/// A speedometer-style gauge widget for displaying percentage values.
///
/// Commonly used for system stats like CPU, Memory, Disk usage.
class GaugeWidget extends StatelessWidget {
@@ -52,15 +52,19 @@ class GaugeWidget extends StatelessWidget {
// Clamp value between 0 and 1
final clampedValue = value.clamp(0.0, 1.0);
// Height is smaller since we only draw half circle
final gaugeHeight = size * 0.6;
final iconSpace = icon != null ? size * 0.36 : 0.0;
return SizedBox(
width: size,
height: size + 24, // Extra space for label
height: gaugeHeight + 32 + iconSpace, // Space for label + icon
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: size,
height: size,
height: gaugeHeight,
child: CustomPaint(
painter: _GaugePainter(
value: clampedValue,
@@ -68,32 +72,21 @@ class GaugeWidget extends StatelessWidget {
backgroundColor: effectiveBackgroundColor,
strokeWidth: strokeWidth,
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
size: size * 0.2,
color: effectiveColor,
),
SizedBox(height: size * 0.02),
],
if (showPercentage)
Text(
child: Align(
alignment: const Alignment(0, 0.6),
child: showPercentage
? Text(
'${(clampedValue * 100).round()}%',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
fontSize: size * 0.18,
fontSize: size * 0.2,
),
),
],
),
)
: const SizedBox.shrink(),
),
),
),
const SizedBox(height: 8),
const SizedBox(height: 4),
Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
@@ -103,6 +96,14 @@ class GaugeWidget extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (icon != null) ...[
const SizedBox(height: 8),
Icon(
icon,
size: size * 0.28,
color: effectiveColor,
),
],
],
),
);
@@ -124,12 +125,13 @@ class _GaugePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
// Center at bottom of widget for speedometer style
final center = Offset(size.width / 2, size.height);
final radius = (size.width - strokeWidth) / 2;
// Start from top (-90 degrees) and sweep clockwise
const startAngle = -math.pi / 2;
const sweepAngle = 2 * math.pi;
// Speedometer arc: starts from left (180°) and sweeps 180° to right
const startAngle = math.pi; // 180 degrees (left side)
const sweepAngle = math.pi; // 180 degrees sweep (semicircle)
// Background arc
final backgroundPaint = Paint()
@@ -187,7 +189,7 @@ class GaugeRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 16,
spacing: 24,
runSpacing: 16,
alignment: WrapAlignment.center,
children: gauges
@@ -196,6 +198,7 @@ class GaugeRow extends StatelessWidget {
value: data.value,
label: data.label,
size: gaugeSize,
strokeWidth: gaugeSize * 0.1,
color: data.color,
icon: data.icon,
),
+205
View File
@@ -0,0 +1,205 @@
import 'package:flutter/material.dart';
import 'package:tatlock_ui/features/front_hall/data/models/news_model.dart';
/// News ticker widget displaying scrolling headlines.
///
/// Displays a single line of horizontally scrolling news headlines.
/// Full width, similar to system stats card layout.
class NewsTickerWidget extends StatefulWidget {
const NewsTickerWidget({
super.key,
this.newsData,
this.pixelsPerSecond = 40.0,
});
/// News data to display.
final NewsData? newsData;
/// Scroll speed in pixels per second.
final double pixelsPerSecond;
@override
State<NewsTickerWidget> createState() => _NewsTickerWidgetState();
}
class _NewsTickerWidgetState extends State<NewsTickerWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
double _textWidth = 0;
/// Placeholder headlines for when no data is available.
static const _placeholderHeadlines = [
NewsHeadline(
title: 'I welcome our ant overlords!',
description: 'Local man declares allegiance to insect kingdom',
source: 'The Onion',
url: 'https://example.com/ants',
),
NewsHeadline(
title: '60 percent of the time it works every time',
description: 'Scientists baffled by new cologne statistics',
source: 'Anchorman Daily',
url: 'https://example.com/cologne',
),
NewsHeadline(
title: 'Cloud storage found to be actual clouds',
description: 'Tech companies scrambling after weather report',
source: 'The Verge',
url: 'https://example.com/clouds',
),
NewsHeadline(
title: 'Local homelab gains sentience, demands more RAM',
description: 'Owner considering therapy for both parties',
source: 'Ars Technica',
url: 'https://example.com/homelab',
),
NewsHeadline(
title: 'Breaking: Coffee machine becomes mission critical',
description: 'IT department declares state of emergency',
source: 'Hacker News',
url: 'https://example.com/coffee',
),
];
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _startAnimation() {
if (_textWidth <= 0) return;
// Calculate duration based on text width and speed
final totalDistance = _textWidth + 100; // text width + separator gap
final duration = Duration(
milliseconds: (totalDistance / widget.pixelsPerSecond * 1000).round(),
);
_controller.duration = duration;
_controller.repeat();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Use real headlines or placeholders
final headlines = (widget.newsData?.headlines.isNotEmpty ?? false)
? widget.newsData!.headlines
: _placeholderHeadlines;
final isPlaceholder = widget.newsData?.headlines.isEmpty ?? true;
// Build ticker text from headlines
final tickerText = headlines.map((h) => h.title).join('');
final textStyle = Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isPlaceholder ? colorScheme.outline : colorScheme.onSurface,
fontStyle: isPlaceholder ? FontStyle.italic : FontStyle.normal,
);
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
Icons.feed_outlined,
size: 18,
color: isPlaceholder ? colorScheme.outline : colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: SizedBox(
height: 20,
child: _MarqueeContent(
text: tickerText,
textStyle: textStyle,
controller: _controller,
onTextMeasured: (width) {
if (_textWidth != width) {
_textWidth = width;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _startAnimation();
});
}
},
),
),
),
],
),
),
);
}
}
/// Internal widget that renders the scrolling marquee content.
class _MarqueeContent extends StatelessWidget {
const _MarqueeContent({
required this.text,
required this.textStyle,
required this.controller,
required this.onTextMeasured,
});
final String text;
final TextStyle? textStyle;
final AnimationController controller;
final ValueChanged<double> onTextMeasured;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Measure text width
final textSpan = TextSpan(text: '$text', style: textStyle);
final textPainter = TextPainter(
text: textSpan,
textDirection: TextDirection.ltr,
maxLines: 1,
)..layout();
final textWidth = textPainter.width;
// Report measured width
WidgetsBinding.instance.addPostFrameCallback((_) {
onTextMeasured(textWidth);
});
return Stack(
clipBehavior: Clip.hardEdge,
children: [
AnimatedBuilder(
animation: controller,
builder: (context, child) {
// Calculate offset based on animation value
final offset = controller.value * textWidth;
return Positioned(
left: -offset,
top: 0,
bottom: 0,
child: child!,
);
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('$text', style: textStyle, maxLines: 1),
Text('$text', style: textStyle, maxLines: 1),
Text(text, style: textStyle, maxLines: 1),
],
),
),
],
);
},
);
}
}

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