Compare commits

...
40 Commits
Author SHA1 Message Date
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
76 changed files with 9086 additions and 583 deletions
+2 -4
View File
@@ -59,10 +59,8 @@ This project uses version-tag-based CI/CD. Releases trigger automated Docker bui
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)
CI/CD auto-triggers when a tag starting with `v` is pushed.
**What happens on release:**
+288
View File
@@ -7,6 +7,294 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
+12 -4
View File
@@ -3,16 +3,24 @@ 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
# Generate health.json with version info
+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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+223 -9
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:
@@ -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 |
@@ -509,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
+3 -2
View File
@@ -2,6 +2,7 @@ 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;
@@ -9,7 +10,7 @@ import 'api_client_native.dart' if (dart.library.html) 'api_client_web.dart'
part 'api_client.g.dart';
/// Provides the Dio instance for Core API.
@riverpod
@persistentRiverpod
Dio coreApiClient(Ref ref) {
final options = BaseOptions(
baseUrl: AppConfig.coreApiUrl,
@@ -33,7 +34,7 @@ Dio coreApiClient(Ref ref) {
}
/// Provides the Dio instance for Tatlock API.
@riverpod
@persistentRiverpod
Dio tatlockApiClient(Ref ref) {
final options = BaseOptions(
baseUrl: AppConfig.tatlockApiUrl,
+6 -20
View File
@@ -10,8 +10,7 @@ import 'package:tatlock_ui/core/error/app_exception.dart';
/// Adds authentication token to requests.
///
/// - **LAN mode**: Skipped entirely (no auth required)
/// - **Web**: Skipped (cookies handle auth via NPM forward auth)
/// - **Mobile**: Adds Bearer token from OIDC authentication
/// - **Web + Mobile**: Adds Bearer token from OIDC authentication
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._ref);
@@ -25,17 +24,11 @@ class AuthInterceptor extends Interceptor {
return;
}
// Skip Bearer token on web - cookies handle auth via NPM forward auth
if (kIsWeb) {
handler.next(options);
return;
}
// Mobile: Add Bearer token from OIDC authentication
// Add Bearer token for all platforms (web + mobile)
final authState = _ref.read(authProvider);
authState.whenData((auth) {
if (auth.isAuthenticated && auth.accessToken != null && auth.accessToken != 'web-session') {
if (auth.isAuthenticated && auth.accessToken != null) {
options.headers['Authorization'] = 'Bearer ${auth.accessToken}';
}
});
@@ -45,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);
}
}
+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);
}
}
+48 -52
View File
@@ -1,4 +1,4 @@
import 'dart:convert' show base64Url, jsonDecode, jsonEncode, utf8;
import 'dart:convert' show jsonDecode, jsonEncode;
import 'dart:developer' as developer;
import 'package:flutter/foundation.dart' show kIsWeb;
@@ -6,6 +6,7 @@ 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';
@@ -24,7 +25,7 @@ part 'auth_provider.g.dart';
///
/// After OIDC authentication, syncs with core-api via POST /auth/sync
/// to get user profile, roles, and preferences.
@riverpod
@persistentRiverpod
class AuthNotifier extends _$AuthNotifier {
// Storage keys
static const _accessTokenKey = 'auth_access_token';
@@ -40,7 +41,9 @@ class AuthNotifier extends _$AuthNotifier {
@override
Future<AuthState> build() async {
// Load stored auth on all platforms
// 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();
}
@@ -265,27 +268,28 @@ class AuthNotifier extends _$AuthNotifier {
final oidcService = OidcServiceWeb();
final tokens = await oidcService.exchangeCode(code, callbackState);
// Step 2: Decode JWT to extract user info (skip core-api sync)
final claims = _decodeJwtClaims(tokens.accessToken);
final userName = claims['name'] as String? ??
claims['preferred_username'] as String? ??
'User';
final userEmail = claims['email'] as String? ?? '';
final authentikId = claims['sub'] as String?;
final groups = (claims['groups'] as List<dynamic>?)?.cast<String>() ?? [];
// 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('JWT claims: name=$userName, email=$userEmail, groups=$groups', name: 'auth');
developer.log(
'Synced user: ${syncResponse.name} with ${syncResponse.roles.length} roles',
name: 'auth',
);
// Step 3: Store credentials and user data from JWT
// Step 3: Store credentials and user data from sync response
await _storeAuth(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
authentikId: authentikId,
userName: userName,
userEmail: userEmail,
// Roles from groups - for now just store group names
// Full role parsing can be done later if needed
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
);
state = AsyncData(AuthState(
@@ -293,12 +297,16 @@ class AuthNotifier extends _$AuthNotifier {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
authentikId: authentikId,
userName: userName,
userEmail: userEmail,
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 $userName', name: 'auth');
developer.log('Authenticated as ${syncResponse.name}', name: 'auth');
// Clean up the URL by removing the query parameters
web_utils.replaceUrl('/');
@@ -311,40 +319,28 @@ class AuthNotifier extends _$AuthNotifier {
}
}
/// Decode JWT payload without verification (validation happens server-side).
Map<String, dynamic> _decodeJwtClaims(String jwt) {
try {
final parts = jwt.split('.');
if (parts.length != 3) {
developer.log('Invalid JWT format', name: 'auth');
return {};
}
// Decode the payload (second part)
String payload = parts[1];
// Add padding if needed for base64
switch (payload.length % 4) {
case 2:
payload += '==';
break;
case 3:
payload += '=';
break;
}
final decoded = utf8.decode(base64Url.decode(payload));
return jsonDecode(decoded) as Map<String, dynamic>;
} catch (e) {
developer.log('Failed to decode JWT: $e', name: 'auth');
return {};
}
}
/// 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 user preferences.
+35 -2
View File
@@ -34,7 +34,12 @@ class OidcServiceWeb implements OidcService {
///
/// Returns a URL that the browser should navigate to for authentication.
/// The [codeVerifier] and [state] are stored for later verification.
Future<String> getAuthorizationUrl() async {
///
/// 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;
@@ -59,10 +64,11 @@ class OidcServiceWeb implements OidcService {
'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: $uri', name: 'oidc_web');
developer.log('Authorization URL (silent=$silent): $uri', name: 'oidc_web');
return uri.toString();
}
@@ -209,4 +215,31 @@ class OidcServiceWeb implements OidcService {
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();
}
}
+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;
@@ -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,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,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,
);
}
@@ -3,8 +3,11 @@ 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/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';
@@ -20,20 +23,35 @@ class DashboardContent extends ConsumerStatefulWidget {
}
class _DashboardContentState extends ConsumerState<DashboardContent> {
Timer? _refreshTimer;
Timer? _systemStatsTimer;
Timer? _environmentTimer;
/// Tracks if we've logged the environment API user (log once per session)
static bool _hasLoggedEnvUser = false;
static String? _envApiUser;
/// Get the environment API user (available after first load)
static String? get envApiUser => _envApiUser;
@override
void initState() {
super.initState();
_refreshTimer = Timer.periodic(
// 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),
);
}
@override
void dispose() {
_refreshTimer?.cancel();
_systemStatsTimer?.cancel();
_environmentTimer?.cancel();
super.dispose();
}
@@ -41,6 +59,7 @@ class _DashboardContentState extends ConsumerState<DashboardContent> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final systemStatsAsync = ref.watch(systemStatsProvider);
final environmentAsync = ref.watch(environmentProvider);
return ListView(
padding: const EdgeInsets.all(16),
@@ -116,31 +135,49 @@ class _DashboardContentState extends ConsumerState<DashboardContent> {
),
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) {
// Store and log user once per session
if (!_hasLoggedEnvUser && envData.user != null) {
_envApiUser = envData.user;
_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),
@@ -256,3 +293,94 @@ class _SystemStatsCard extends StatelessWidget {
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),
],
),
),
],
),
);
}
}
+20 -5
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();
// Enable semantics tree on web for accessibility and automation tools.
// This exposes widget identifiers to browser automation (Puppeteer, etc.)
if (kIsWeb) {
SemanticsBinding.instance.ensureSemantics();
}
// Use path-based URLs on web (no-op on mobile/desktop)
configureUrlStrategy();
developer.log(
'${AppVersion.name} v${AppVersion.fullVersion}',
name: 'tatlock_ui',
);
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()));
}
+65 -196
View File
@@ -1,63 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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';
static const callback = '/callback';
}
/// 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: [
// OIDC callback route (handles auth code exchange)
// Root redirects to default room (handled by redirect callback above)
GoRoute(
path: AppRoutes.callback,
name: 'callback',
builder: (context, state) => _OidcCallbackPage(
code: state.uri.queryParameters['code'],
callbackState: state.uri.queryParameters['state'],
error: state.uri.queryParameters['error'],
errorDescription: state.uri.queryParameters['error_description'],
),
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()),
),
],
),
@@ -65,170 +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,
),
),
],
),
);
}
}
/// OIDC callback page that handles the authorization code exchange.
class _OidcCallbackPage extends ConsumerStatefulWidget {
const _OidcCallbackPage({
this.code,
this.callbackState,
this.error,
this.errorDescription,
});
final String? code;
final String? callbackState;
final String? error;
final String? errorDescription;
@override
ConsumerState<_OidcCallbackPage> createState() => _OidcCallbackPageState();
}
class _OidcCallbackPageState extends ConsumerState<_OidcCallbackPage> {
bool _isProcessing = true;
String? _error;
@override
void initState() {
super.initState();
// Defer callback processing to avoid Riverpod state modification during build
WidgetsBinding.instance.addPostFrameCallback((_) {
_processCallback();
});
}
Future<void> _processCallback() async {
// Check for error from Authentik
if (widget.error != null) {
setState(() {
_isProcessing = false;
_error = widget.errorDescription ?? widget.error;
});
return;
}
// Check for required parameters
if (widget.code == null || widget.callbackState == null) {
setState(() {
_isProcessing = false;
_error = 'Invalid callback - missing code or state parameter';
});
return;
}
// Exchange code for tokens
try {
await ref.read(authProvider.notifier).handleOidcCallback(
widget.code!,
widget.callbackState!,
);
// Navigate to home on success
if (mounted) {
context.go(AppRoutes.frontHall);
}
} catch (e) {
if (mounted) {
setState(() {
_isProcessing = false;
_error = e.toString();
});
}
}
}
@override
Widget build(BuildContext context) {
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(
_error != null ? Icons.error_outline : Icons.home_work_outlined,
size: 64,
color: _error != null ? colorScheme.error : colorScheme.primary,
),
const SizedBox(height: 24),
Text(
_error != null ? 'Authentication Failed' : 'Signing in...',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
if (_isProcessing)
const CircularProgressIndicator()
else if (_error != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_error!,
style: TextStyle(color: colorScheme.onErrorContainer),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => context.go(AppRoutes.frontHall),
icon: const Icon(Icons.refresh),
label: const Text('Try again'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
),
],
],
),
),
),
),
),
);
}
}
+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,
+16 -19
View File
@@ -1,11 +1,10 @@
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/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/config/app_config.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/routing/room_registry.dart';
import 'package:tatlock_ui/shared/layouts/widgets/top_header_bar.dart';
/// Main application scaffold with top header navigation.
@@ -45,6 +44,7 @@ class _AppScaffoldState extends ConsumerState<AppScaffold> {
return _buildScaffold(context);
}
// Watch auth state (works for both web and mobile)
final authAsync = ref.watch(authProvider);
return authAsync.when(
@@ -55,7 +55,13 @@ class _AppScaffoldState extends ConsumerState<AppScaffold> {
return _buildScaffold(context);
}
// Not authenticated - auto-initiate OIDC
// 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((_) {
@@ -66,7 +72,7 @@ class _AppScaffoldState extends ConsumerState<AppScaffold> {
// Show loading while redirecting to Authentik
return _buildAuthLoadingScreen(context, 'Redirecting to sign in...');
},
loading: () => _buildAuthLoadingScreen(context, 'Checking authentication...'),
loading: () => _buildAuthLoadingScreen(context, 'Loading user info...'),
error: (error, _) => _buildAuthErrorScreen(context, error),
);
}
@@ -209,22 +215,13 @@ class _AppScaffoldState extends ConsumerState<AppScaffold> {
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;
}
+136 -3
View File
@@ -1,17 +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.
@@ -20,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);
@@ -167,6 +184,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 {
@@ -199,6 +307,31 @@ 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(
+344
View File
@@ -0,0 +1,344 @@
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: 16),
// Forecast days - horizontal scroll
SizedBox(
height: 100,
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;
}
}
+600
View File
@@ -0,0 +1,600 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart';
/// Sun position widget displaying sunrise/sunset with animated arc.
///
/// Shows a semicircle arc representing the day, with sun/moon icon
/// moving along based on current time. Uses color gradients:
/// - Blue tones for night
/// - Orange tones for sunrise/sunset
/// - Yellow tones for daytime
///
/// Position updates every 10 minutes based on system clock.
/// When sun times are unavailable, assumes 12-hour day/night cycles
/// (6am sunrise, 6pm sunset).
class SunPositionWidget extends StatefulWidget {
const SunPositionWidget({
super.key,
this.sunTimes,
this.compact = false,
});
/// Sun times data to display.
final SunTimesData? sunTimes;
/// Whether to use compact layout.
final bool compact;
@override
State<SunPositionWidget> createState() => _SunPositionWidgetState();
}
class _SunPositionWidgetState extends State<SunPositionWidget> {
Timer? _positionTimer;
@override
void initState() {
super.initState();
_scheduleNextUpdate();
}
/// Schedule update at next 10-minute wall clock mark (0/10/20/30/40/50)
void _scheduleNextUpdate() {
final now = DateTime.now();
final currentMinute = now.minute;
// Calculate minutes until next 10-minute mark
final minutesUntilNext = 10 - (currentMinute % 10);
final secondsUntilNext = (minutesUntilNext * 60) - now.second;
_positionTimer = Timer(
Duration(seconds: secondsUntilNext),
() {
if (mounted) {
setState(() {});
_scheduleNextUpdate();
}
},
);
}
@override
void dispose() {
_positionTimer?.cancel();
super.dispose();
}
/// Get sunrise time, defaulting to 7am if not available (asymmetric for visual effect)
DateTime get _sunrise {
final now = DateTime.now();
return widget.sunTimes?.sunrise ?? DateTime(now.year, now.month, now.day, 7, 0);
}
/// Get sunset time, defaulting to 5pm if not available (asymmetric for visual effect)
DateTime get _sunset {
final now = DateTime.now();
return widget.sunTimes?.sunset ?? DateTime(now.year, now.month, now.day, 17, 0);
}
/// Calculate daylight minutes from sunrise/sunset
int get _daylightMinutes {
return _sunset.difference(_sunrise).inMinutes;
}
bool get _isDaytime {
final now = DateTime.now();
return now.isAfter(_sunrise) && now.isBefore(_sunset);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
if (widget.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(
_isDaytime ? Icons.wb_sunny : Icons.nightlight_round,
size: 20,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
'Sun Position',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 8),
// Arc with integrated horizon labels
SizedBox(
height: 170,
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
clipBehavior: Clip.none,
children: [
// Arc painter
Positioned.fill(
child: CustomPaint(
painter: _SunArcPainter(
sunTimes: widget.sunTimes,
isDark: colorScheme.brightness == Brightness.dark,
),
),
),
// Sunrise widget at left horizon (10px up for balance)
Positioned(
left: 0,
bottom: 10,
child: _HorizonTimeDisplay(
icon: Icons.wb_twilight,
label: 'Sunrise',
time: _sunrise,
iconColor: Colors.orange,
alignment: CrossAxisAlignment.start,
),
),
// Daylight display centered (at bottom for balance)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Center(
child: _DaylightDisplay(minutes: _daylightMinutes),
),
),
// Sunset widget at right horizon (10px up for balance)
Positioned(
right: 0,
bottom: 10,
child: _HorizonTimeDisplay(
icon: Icons.nights_stay,
label: 'Sunset',
time: _sunset,
iconColor: Colors.deepOrange,
alignment: CrossAxisAlignment.end,
),
),
],
);
},
),
),
],
),
),
);
}
Widget _buildCompact(BuildContext context, ColorScheme colorScheme) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_isDaytime ? Icons.wb_sunny : Icons.nightlight_round,
size: 24,
color: _isDaytime ? Colors.amber : Colors.indigo,
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(_sunrise),
style: Theme.of(context).textTheme.labelSmall,
),
Text(
_formatTime(_sunset),
style: Theme.of(context).textTheme.labelSmall,
),
],
),
],
),
),
);
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
}
/// Horizon-aligned time display with icon above time and label.
class _HorizonTimeDisplay extends StatelessWidget {
const _HorizonTimeDisplay({
required this.icon,
required this.label,
required this.time,
this.iconColor,
this.alignment = CrossAxisAlignment.center,
});
final IconData icon;
final String label;
final DateTime time;
final Color? iconColor;
final CrossAxisAlignment alignment;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: alignment,
children: [
Icon(
icon,
size: 18,
color: iconColor ?? colorScheme.primary,
),
const SizedBox(height: 2),
Text(
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 10,
),
),
],
);
}
}
class _DaylightDisplay extends StatelessWidget {
const _DaylightDisplay({required this.minutes});
final int minutes;
@override
Widget build(BuildContext context) {
final hours = minutes ~/ 60;
final mins = minutes % 60;
return Column(
children: [
Icon(
Icons.access_time,
size: 16,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 4),
Text(
'${hours}h ${mins}m',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
'Daylight',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
);
}
}
/// Custom painter for the sun arc visualization.
///
/// The arc is proportional to the day/night duration:
/// - During day: shows a day arc spanning (daylight hours / 24) × 360°
/// - During night: shows a night arc spanning (night hours / 24) × 360°
/// - Arc endpoints touch the horizon line at sunrise/sunset positions
class _SunArcPainter extends CustomPainter {
_SunArcPainter({
this.sunTimes,
this.isDark = false,
});
final SunTimesData? sunTimes;
final bool isDark;
// Color palette
static const _nightBlue = Color(0xFF1a237e);
static const _dawnOrange = Color(0xFFff6f00);
static const _sunriseOrange = Color(0xFFffa000);
static const _dayYellow = Color(0xFFffc107);
static const _dayGold = Color(0xFFffb300);
static const _duskOrange = Color(0xFFef6c00);
static const _nightIndigo = Color(0xFF283593);
static const _moonSilver = Color(0xFFB0BEC5);
@override
void paint(Canvas canvas, Size size) {
final now = DateTime.now();
// Get sunrise/sunset times (default to 7am/5pm for asymmetric visual)
final sunrise = sunTimes?.sunrise ?? DateTime(now.year, now.month, now.day, 7, 0);
final sunset = sunTimes?.sunset ?? DateTime(now.year, now.month, now.day, 17, 0);
// Calculate durations
final daylightMinutes = sunset.difference(sunrise).inMinutes;
final nightMinutes = 1440 - daylightMinutes; // 24 hours = 1440 minutes
// Determine if currently day or night
final isDaytime = now.isAfter(sunrise) && now.isBefore(sunset);
// Calculate arc angles (proportional to duration)
final dayArcAngle = (daylightMinutes / 1440) * 2 * math.pi;
final nightArcAngle = (nightMinutes / 1440) * 2 * math.pi;
// Layout constants - horizon is 50px from bottom to leave room for labels
const horizonY = 50.0;
const horizontalPadding = 40.0;
final horizonWidth = size.width - (horizontalPadding * 2);
final centerX = size.width / 2;
// Draw horizon line
final horizonPaint = Paint()
..color = isDark ? Colors.white24 : Colors.black12
..strokeWidth = 1;
canvas.drawLine(
Offset(horizontalPadding - 5, size.height - horizonY),
Offset(size.width - horizontalPadding + 5, size.height - horizonY),
horizonPaint,
);
if (isDaytime) {
_drawDayArc(canvas, size, centerX, horizonWidth, horizonY, dayArcAngle, now, sunrise, sunset);
} else {
_drawNightArc(canvas, size, centerX, horizonWidth, horizonY, nightArcAngle, now, sunrise, sunset);
}
}
void _drawDayArc(
Canvas canvas,
Size size,
double centerX,
double horizonWidth,
double horizonY,
double arcAngle,
DateTime now,
DateTime sunrise,
DateTime sunset,
) {
// Clamp arc angle between 30° and 180° for visual sanity
final clampedAngle = arcAngle.clamp(math.pi / 6, math.pi);
// Calculate radius so arc endpoints touch horizon
// For a chord of width W and arc angle θ: R = W / (2 * sin(θ/2))
var radius = horizonWidth / (2 * math.sin(clampedAngle / 2));
// Constrain arc height to fit within available space (leave 25px margin for sun)
final maxArcHeight = size.height - horizonY - 25;
final arcHeight = radius * (1 - math.cos(clampedAngle / 2));
if (arcHeight > maxArcHeight) {
// Scale radius down to fit
radius = maxArcHeight / (1 - math.cos(clampedAngle / 2));
}
// Arc center is below the horizon for an upward-bulging arc
// Distance from chord to center = R * cos(θ/2)
final horizonYPos = size.height - horizonY;
final centerY = horizonYPos + radius * math.cos(clampedAngle / 2);
final center = Offset(centerX, centerY);
// The arc starts at the LEFT horizon point
// In canvas coords (Y down), angle to left point = 3π/2 - θ/2
// The arc sweeps clockwise (positive) through angle θ to reach right point
final startAngle = (3 * math.pi / 2) - (clampedAngle / 2);
final arcRect = Rect.fromCenter(
center: center,
width: radius * 2,
height: radius * 2,
);
// Draw arc background with day gradient
final arcPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 6
..strokeCap = StrokeCap.round
..shader = SweepGradient(
center: Alignment.center,
startAngle: 0,
endAngle: 2 * math.pi,
colors: [
_dawnOrange.withValues(alpha: 0.6),
_sunriseOrange.withValues(alpha: 0.7),
_dayYellow.withValues(alpha: 0.8),
_dayGold.withValues(alpha: 0.8),
_dayYellow.withValues(alpha: 0.7),
_duskOrange.withValues(alpha: 0.6),
],
stops: const [0.0, 0.15, 0.4, 0.6, 0.85, 1.0],
transform: GradientRotation(startAngle),
).createShader(arcRect);
// Draw the arc (positive sweep = clockwise in canvas coords = visually upward arc)
canvas.drawArc(arcRect, startAngle, clampedAngle, false, arcPaint);
// Calculate sun position along the arc
final totalDaylight = sunset.difference(sunrise).inMinutes;
final minutesSinceSunrise = now.difference(sunrise).inMinutes;
final progress = (minutesSinceSunrise / totalDaylight).clamp(0.0, 1.0);
// Map progress to angle along the arc (0 = sunrise/left, 1 = sunset/right)
final sunAngle = startAngle + (progress * clampedAngle);
final sunX = center.dx + radius * math.cos(sunAngle);
final sunY = center.dy + radius * math.sin(sunAngle);
// Determine sun color based on position (orange near horizon, yellow at peak)
final isNearHorizon = progress < 0.15 || progress > 0.85;
final sunColor = isNearHorizon ? _sunriseOrange : _dayYellow;
final glowColor = isNearHorizon ? _dawnOrange : _dayYellow;
// Draw sun glow
final glowPaint = Paint()
..shader = RadialGradient(
colors: [
glowColor.withValues(alpha: 0.6),
glowColor.withValues(alpha: 0.0),
],
).createShader(Rect.fromCircle(center: Offset(sunX, sunY), radius: 20));
canvas.drawCircle(Offset(sunX, sunY), 20, glowPaint);
// Draw sun
final sunPaint = Paint()
..style = PaintingStyle.fill
..color = sunColor;
canvas.drawCircle(Offset(sunX, sunY), 10, sunPaint);
// Draw sun rays
final rayPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2
..color = (isNearHorizon ? _dawnOrange : _dayGold).withValues(alpha: 0.8);
for (var i = 0; i < 8; i++) {
final rayAngle = (i * math.pi / 4);
canvas.drawLine(
Offset(sunX + 12 * math.cos(rayAngle), sunY + 12 * math.sin(rayAngle)),
Offset(sunX + 16 * math.cos(rayAngle), sunY + 16 * math.sin(rayAngle)),
rayPaint,
);
}
}
void _drawNightArc(
Canvas canvas,
Size size,
double centerX,
double horizonWidth,
double horizonY,
double arcAngle,
DateTime now,
DateTime sunrise,
DateTime sunset,
) {
// Clamp arc angle between 30° and 180° for visual sanity
final clampedAngle = arcAngle.clamp(math.pi / 6, math.pi);
// Calculate radius so arc endpoints touch horizon
var radius = horizonWidth / (2 * math.sin(clampedAngle / 2));
// Constrain arc height to fit within available space (leave 20px margin for moon)
final maxArcHeight = size.height - horizonY - 20;
final arcHeight = radius * (1 - math.cos(clampedAngle / 2));
if (arcHeight > maxArcHeight) {
// Scale radius down to fit
radius = maxArcHeight / (1 - math.cos(clampedAngle / 2));
}
// Arc center is below the horizon for an upward-bulging arc
final horizonYPos = size.height - horizonY;
final centerY = horizonYPos + radius * math.cos(clampedAngle / 2);
final center = Offset(centerX, centerY);
// The arc starts at the LEFT horizon point
// In canvas coords (Y down), angle to left point = 3π/2 - θ/2
final startAngle = (3 * math.pi / 2) - (clampedAngle / 2);
final arcRect = Rect.fromCenter(
center: center,
width: radius * 2,
height: radius * 2,
);
// Draw arc background with night gradient
final arcPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 6
..strokeCap = StrokeCap.round
..shader = SweepGradient(
center: Alignment.center,
startAngle: 0,
endAngle: 2 * math.pi,
colors: [
_duskOrange.withValues(alpha: 0.4),
_nightIndigo.withValues(alpha: 0.5),
_nightBlue.withValues(alpha: 0.6),
_nightBlue.withValues(alpha: 0.6),
_nightIndigo.withValues(alpha: 0.5),
_dawnOrange.withValues(alpha: 0.4),
],
stops: const [0.0, 0.15, 0.4, 0.6, 0.85, 1.0],
transform: GradientRotation(startAngle),
).createShader(arcRect);
// Draw the arc (positive sweep = clockwise in canvas coords)
canvas.drawArc(arcRect, startAngle, clampedAngle, false, arcPaint);
// Calculate moon position
// Night spans from sunset to next sunrise
final double progress;
if (now.isAfter(sunset)) {
// After sunset: progress from sunset toward midnight and beyond
final nextSunrise = DateTime(now.year, now.month, now.day + 1, sunrise.hour, sunrise.minute);
final nightDuration = nextSunrise.difference(sunset).inMinutes;
final minutesSinceSunset = now.difference(sunset).inMinutes;
progress = (minutesSinceSunset / nightDuration).clamp(0.0, 1.0);
} else {
// Before sunrise: progress toward sunrise
final prevSunset = DateTime(now.year, now.month, now.day - 1, sunset.hour, sunset.minute);
final nightDuration = sunrise.difference(prevSunset).inMinutes;
final minutesSincePrevSunset = now.difference(prevSunset).inMinutes;
progress = (minutesSincePrevSunset / nightDuration).clamp(0.0, 1.0);
}
// Map progress to angle along the arc (0 = sunset/left, 1 = sunrise/right)
final moonAngle = startAngle + (progress * clampedAngle);
final moonX = center.dx + radius * math.cos(moonAngle);
final moonY = center.dy + radius * math.sin(moonAngle);
// Draw moon glow
final glowPaint = Paint()
..shader = RadialGradient(
colors: [
_moonSilver.withValues(alpha: 0.3),
_moonSilver.withValues(alpha: 0.0),
],
).createShader(Rect.fromCircle(center: Offset(moonX, moonY), radius: 16));
canvas.drawCircle(Offset(moonX, moonY), 16, glowPaint);
// Draw moon
final moonPaint = Paint()
..style = PaintingStyle.fill
..color = _moonSilver;
canvas.drawCircle(Offset(moonX, moonY), 8, moonPaint);
// Draw crescent shadow for moon effect
final shadowPaint = Paint()
..style = PaintingStyle.fill
..color = _nightBlue.withValues(alpha: 0.7);
canvas.drawCircle(Offset(moonX + 3, moonY - 1), 6, shadowPaint);
}
@override
bool shouldRepaint(covariant _SunArcPainter oldDelegate) {
return oldDelegate.sunTimes != sunTimes || oldDelegate.isDark != isDark;
}
}
+153 -3
View File
@@ -1,16 +1,22 @@
import 'package:flutter/material.dart';
import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart'
as api;
/// Weather widget displaying current conditions.
///
/// Currently uses mock data. Will be connected to weather API in future.
/// Displays weather data from the Core API environment endpoint.
class WeatherWidget extends StatelessWidget {
const WeatherWidget({
super.key,
this.apiData,
this.data,
this.compact = false,
});
/// Weather data to display. Uses mock data if null.
/// Weather data from API. Takes priority over legacy data.
final api.WeatherData? apiData;
/// Legacy weather data to display. Uses mock data if null.
final WeatherData? data;
/// Whether to use compact layout.
@@ -19,7 +25,17 @@ class WeatherWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final weather = data ?? WeatherData.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 weather = apiData != null ? _fromApiData(apiData!) : data!;
if (compact) {
return _buildCompact(context, colorScheme, weather);
@@ -170,6 +186,86 @@ class WeatherWidget 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.wb_sunny_outlined,
size: 20,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
'Weather',
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.cloud_off_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: [
Icon(
Icons.cloud_off_outlined,
size: 24,
color: colorScheme.outline,
),
const SizedBox(width: 8),
Text(
'--',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.outline,
),
),
],
),
),
);
}
}
class _DetailChip extends StatelessWidget {
@@ -206,6 +302,60 @@ class _DetailChip extends StatelessWidget {
}
}
/// Converts API weather data to local WeatherData model.
WeatherData _fromApiData(api.WeatherData data) {
return WeatherData(
location: data.location ?? 'Unknown',
temperature: data.temperature ?? 0,
condition: data.conditions ?? 'Unknown',
icon: _getWeatherIcon(data.conditions, data.icon),
humidity: data.humidity,
windSpeed: data.windSpeed,
feelsLike: data.feelsLike,
iconColor: _getWeatherColor(data.conditions),
);
}
/// Maps weather condition to icon.
IconData _getWeatherIcon(String? conditions, String? iconCode) {
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;
} else if (condition.contains('wind')) {
return Icons.air;
}
return Icons.cloud;
}
/// Maps weather condition to color.
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;
}
/// Temperature unit for weather display.
enum TemperatureUnit {
celsius('C'),
+53
View File
@@ -0,0 +1,53 @@
# Increase buffer size for large headers from Authentik
proxy_buffers 8 16k;
proxy_buffer_size 32k;
# Exclude static assets from forward auth
# These paths bypass auth_request but still proxy to upstream
location ~ ^/(manifest\.json|favicon\.(ico|png)|health|icons|assets) {
auth_request off;
proxy_pass $forward_scheme://$server:$port;
}
# Forward authentication via standalone outpost
auth_request /outpost.goauthentik.io/auth/nginx;
error_page 401 = @goauthentik_proxy_signin;
# Capture auth response headers
auth_request_set $auth_cookie $upstream_http_set_cookie;
auth_request_set $authentik_username $upstream_http_x_authentik_username;
auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
auth_request_set $authentik_email $upstream_http_x_authentik_email;
auth_request_set $authentik_name $upstream_http_x_authentik_name;
auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
# Forward auth headers to application
add_header Set-Cookie $auth_cookie;
proxy_set_header X-authentik-username $authentik_username;
proxy_set_header X-authentik-groups $authentik_groups;
proxy_set_header X-authentik-email $authentik_email;
proxy_set_header X-authentik-name $authentik_name;
proxy_set_header X-authentik-uid $authentik_uid;
# Outpost proxy location
location /outpost.goauthentik.io {
proxy_pass https://localhost:9444/outpost.goauthentik.io;
proxy_set_header Host $host;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
# Signin redirect handler
location @goauthentik_proxy_signin {
internal;
return 302 /outpost.goauthentik.io/start?rd=$request_uri;
}
+4 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.12+1
version: 1.5.6+1
environment:
sdk: ^3.10.4
@@ -57,6 +57,9 @@ dependencies:
crypto: ^3.0.3
web: ^1.1.0
# Internationalization
intl: ^0.19.0
# UI
flex_color_scheme: ^8.1.0
flutter_adaptive_scaffold: ^0.3.1
@@ -0,0 +1,365 @@
import 'package:flutter/material.dart' hide Container;
import 'package:flutter_test/flutter_test.dart';
import 'package:tatlock_ui/features/control_room/containers/presentation/pages/containers_list_page.dart';
import '../../../../../harness/screen_sizes.dart';
import '../../../../../harness/test_harness.dart';
void main() {
final harness = TestHarness();
setUp(() => harness.setUp());
tearDown(() => harness.tearDown());
group('ContainersListPage', () {
testWidgets('displays loading state initially', (tester) async {
harness.givenAuthenticatedUser();
harness.givenContainers(); // Need response to avoid timeout
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
// Just pump once without settling to catch loading state
await tester.pump();
// Should show loading indicator (before data loads)
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Now settle to complete the test cleanly
await tester.pumpAndSettle();
});
testWidgets('displays containers from API', (tester) async {
harness.givenAuthenticatedUser();
harness.givenContainers();
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
await tester.pumpAndSettle();
// Should display container names from fixtures
expect(find.text('nginx-proxy'), findsOneWidget);
expect(find.text('postgres-db'), findsOneWidget);
expect(find.text('redis-cache'), findsOneWidget);
});
testWidgets('displays container images', (tester) async {
harness.givenAuthenticatedUser();
harness.givenContainers();
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
await tester.pumpAndSettle();
// Should display container images from fixtures
expect(find.text('nginx:latest'), findsOneWidget);
expect(find.text('postgres:15'), findsOneWidget);
expect(find.text('redis:7-alpine'), findsOneWidget);
});
testWidgets('displays search input', (tester) async {
harness.givenAuthenticatedUser();
harness.givenContainers();
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
await tester.pumpAndSettle();
// Should have search field
expect(find.byType(TextField), findsOneWidget);
expect(find.text('Search containers...'), findsOneWidget);
});
testWidgets('displays refresh button', (tester) async {
harness.givenAuthenticatedUser();
harness.givenContainers();
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
await tester.pumpAndSettle();
// Should have refresh button
expect(find.byIcon(Icons.refresh), findsOneWidget);
});
testWidgets('displays port information', (tester) async {
harness.givenAuthenticatedUser();
harness.givenContainers();
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
await tester.pumpAndSettle();
// Should display port mappings from fixtures
expect(find.textContaining('80:80'), findsOneWidget);
expect(find.textContaining('443:443'), findsOneWidget);
expect(find.textContaining('5432:5432'), findsOneWidget);
});
testWidgets('displays status information', (tester) async {
harness.givenAuthenticatedUser();
harness.givenContainers();
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
await tester.pumpAndSettle();
// Should display status from fixtures (health indicator stripped)
expect(find.textContaining('Up 3 days'), findsOneWidget);
expect(find.textContaining('Up 5 days'), findsOneWidget);
});
testWidgets('search filters containers', (tester) async {
harness.givenAuthenticatedUser();
harness.givenContainers();
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
await tester.pumpAndSettle();
// Find search field and enter text
final searchField = find.byType(TextField);
await tester.enterText(searchField, 'nginx');
await tester.pumpAndSettle();
// Should show nginx container
expect(find.text('nginx-proxy'), findsOneWidget);
// Other containers should be filtered out
expect(find.text('postgres-db'), findsNothing);
expect(find.text('redis-cache'), findsNothing);
});
testWidgets('displays empty state when no containers match search',
(tester) async {
harness.givenAuthenticatedUser();
harness.givenContainers();
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
await tester.pumpAndSettle();
// Search for non-existent container
final searchField = find.byType(TextField);
await tester.enterText(searchField, 'nonexistent');
await tester.pumpAndSettle();
// No containers should be visible
expect(find.text('nginx-proxy'), findsNothing);
expect(find.text('postgres-db'), findsNothing);
});
testWidgets('displays error state on API failure', (tester) async {
harness.givenAuthenticatedUser();
harness.givenApiError(
method: 'GET',
path: '/infrastructure/containers',
statusCode: 500,
message: 'Internal server error',
);
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
// Pump a few frames to allow error to propagate
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// Should show error state (check for error icon or text)
expect(
find.byIcon(Icons.error_outline).evaluate().isNotEmpty ||
find.textContaining('error').evaluate().isNotEmpty ||
find.textContaining('Error').evaluate().isNotEmpty,
isTrue,
reason: 'Should display an error indicator',
);
});
testWidgets('calls API on mount', (tester) async {
harness.givenAuthenticatedUser();
harness.givenContainers();
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
await tester.pumpAndSettle();
// Verify API was called
harness.verifyApiCalled('GET', '/infrastructure/containers');
});
});
group('ContainerData', () {
test('parses JSON correctly', () {
final json = Fixtures.containers[0];
final container = ContainerData.fromJson(json);
expect(container.name, 'nginx-proxy');
expect(container.image, 'nginx:latest');
expect(container.state, 'running');
expect(container.ports.length, 2);
expect(container.health, ContainerHealth.healthy);
});
test('strips leading slash from name', () {
final json = {
'Id': 'abc123',
'Names': ['/my-container'],
'Image': 'test:latest',
'State': 'running',
'Status': 'Up 1 day',
'Ports': [],
};
final container = ContainerData.fromJson(json);
expect(container.name, 'my-container');
});
test('truncates ID to 12 characters', () {
final json = {
'Id': 'abcdef123456789',
'Names': ['/test'],
'Image': 'test:latest',
'State': 'running',
'Status': 'Up 1 day',
'Ports': [],
};
final container = ContainerData.fromJson(json);
expect(container.id, 'abcdef123456');
expect(container.fullId, 'abcdef123456789');
});
test('parses health status correctly', () {
expect(
ContainerData.fromJson({
...Fixtures.containers[0],
'Status': 'Up 1 day (healthy)',
}).health,
ContainerHealth.healthy,
);
expect(
ContainerData.fromJson({
...Fixtures.containers[0],
'Status': 'Up 1 day (unhealthy)',
}).health,
ContainerHealth.unhealthy,
);
expect(
ContainerData.fromJson({
...Fixtures.containers[0],
'Status': 'Up 1 day (health: starting)',
}).health,
ContainerHealth.starting,
);
expect(
ContainerData.fromJson({
...Fixtures.containers[0],
'Status': 'Up 1 day',
}).health,
ContainerHealth.none,
);
});
test('displayStatus strips health indicator', () {
final container = ContainerData.fromJson({
...Fixtures.containers[0],
'Status': 'Up 3 days (healthy)',
});
expect(container.displayStatus, 'Up 3 days');
});
test('canStart is true for exited containers', () {
final container = ContainerData.fromJson({
...Fixtures.containers[0],
'State': 'exited',
});
expect(container.canStart, isTrue);
expect(container.canStop, isFalse);
expect(container.canRestart, isFalse);
});
test('canStop and canRestart are true for running containers', () {
final container = ContainerData.fromJson({
...Fixtures.containers[0],
'State': 'running',
});
expect(container.canStart, isFalse);
expect(container.canStop, isTrue);
expect(container.canRestart, isTrue);
});
});
group('ContainerPort', () {
test('parses JSON correctly', () {
final port = ContainerPort.fromJson({
'PrivatePort': 80,
'PublicPort': 8080,
'Type': 'tcp',
});
expect(port.privatePort, 80);
expect(port.publicPort, 8080);
expect(port.type, 'tcp');
});
test('formatted shows port mapping', () {
final port = ContainerPort(privatePort: 80, publicPort: 8080);
expect(port.formatted, '8080:80');
});
test('formatted shows only private port when no public port', () {
final port = ContainerPort(privatePort: 6379);
expect(port.formatted, '6379');
});
});
group('ContainersListPage Responsive', () {
testWidgets('renders at desktop size (1920x1080)', (tester) async {
configureScreenSize(tester, ScreenSizes.desktopLarge);
harness.givenAuthenticatedUser();
harness.givenContainers();
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
await tester.pumpAndSettle();
expect(find.text('nginx-proxy'), findsOneWidget);
expect(find.byType(TextField), findsOneWidget);
});
testWidgets('renders at tablet landscape (1024x768)', (tester) async {
configureScreenSize(tester, ScreenSizes.tabletLandscape);
harness.givenAuthenticatedUser();
harness.givenContainers();
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
await tester.pumpAndSettle();
expect(find.text('nginx-proxy'), findsOneWidget);
});
testWidgets('renders at tablet portrait (768x1024)', (tester) async {
configureScreenSize(tester, ScreenSizes.tabletPortrait);
harness.givenAuthenticatedUser();
harness.givenContainers();
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
await tester.pumpAndSettle();
expect(find.byType(ContainersListPage), findsOneWidget);
});
testWidgets('renders at mobile size (375x812)', (tester) async {
configureScreenSize(tester, ScreenSizes.mobile);
harness.givenAuthenticatedUser();
harness.givenContainers();
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
await tester.pumpAndSettle();
expect(find.byType(ContainersListPage), findsOneWidget);
});
testWidgets('renders at small mobile size (320x568)', (tester) async {
configureScreenSize(tester, ScreenSizes.mobileSmall);
harness.givenAuthenticatedUser();
harness.givenContainers();
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
await tester.pumpAndSettle();
expect(find.byType(ContainersListPage), findsOneWidget);
});
});
}
@@ -0,0 +1,276 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tatlock_ui/features/control_room/npm/presentation/pages/proxy_hosts_page.dart';
import '../../../../../harness/screen_sizes.dart';
import '../../../../../harness/test_harness.dart';
void main() {
final harness = TestHarness();
setUp(() => harness.setUp());
tearDown(() => harness.tearDown());
group('ProxyHostsPage', () {
testWidgets('displays loading state initially', (tester) async {
harness.givenAuthenticatedUser();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.pumpAndSettle();
});
testWidgets('displays domains from API', (tester) async {
harness.givenAuthenticatedUser();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
await tester.pumpAndSettle();
expect(find.text('home.schweitz.net'), findsOneWidget);
expect(find.text('api.schweitz.net'), findsOneWidget);
expect(find.text('git.schweitz.net'), findsOneWidget);
});
testWidgets('displays service information', (tester) async {
harness.givenAuthenticatedUser();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
await tester.pumpAndSettle();
expect(find.text('192.168.86.149:8092'), findsOneWidget);
expect(find.text('192.168.86.149:8083'), findsOneWidget);
expect(find.text('192.168.86.149:3000'), findsOneWidget);
});
testWidgets('displays SSL badges', (tester) async {
harness.givenAuthenticatedUser();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
await tester.pumpAndSettle();
// Should show SSL badges for enabled domains
expect(find.text('SSL'), findsWidgets);
// Should show HTTP badge for non-SSL domain
expect(find.text('HTTP'), findsOneWidget);
});
testWidgets('displays lock icons for SSL status', (tester) async {
harness.givenAuthenticatedUser();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
await tester.pumpAndSettle();
// Should have lock icons for SSL-enabled domains
expect(find.byIcon(Icons.lock), findsWidgets);
// Should have open lock for non-SSL domain
expect(find.byIcon(Icons.lock_open), findsOneWidget);
});
testWidgets('displays search input', (tester) async {
harness.givenAuthenticatedUser();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
expect(find.text('Search domains...'), findsOneWidget);
});
testWidgets('displays refresh button', (tester) async {
harness.givenAuthenticatedUser();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.refresh), findsOneWidget);
});
testWidgets('displays New button', (tester) async {
harness.givenAuthenticatedUser();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
await tester.pumpAndSettle();
expect(find.text('New'), findsOneWidget);
expect(find.byIcon(Icons.add), findsOneWidget);
});
testWidgets('search filters domains', (tester) async {
harness.givenAuthenticatedUser();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
await tester.pumpAndSettle();
final searchField = find.byType(TextField);
await tester.enterText(searchField, 'api');
await tester.pumpAndSettle();
expect(find.text('api.schweitz.net'), findsOneWidget);
expect(find.text('home.schweitz.net'), findsNothing);
expect(find.text('git.schweitz.net'), findsNothing);
});
testWidgets('displays empty state when no domains match search',
(tester) async {
harness.givenAuthenticatedUser();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
await tester.pumpAndSettle();
final searchField = find.byType(TextField);
await tester.enterText(searchField, 'nonexistent');
await tester.pumpAndSettle();
expect(find.text('home.schweitz.net'), findsNothing);
expect(find.text('api.schweitz.net'), findsNothing);
});
testWidgets('displays error state on API failure', (tester) async {
harness.givenAuthenticatedUser();
harness.givenApiError(
method: 'GET',
path: '/infrastructure/domains',
statusCode: 500,
message: 'Internal server error',
);
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(
find.byIcon(Icons.error_outline).evaluate().isNotEmpty ||
find.textContaining('error').evaluate().isNotEmpty ||
find.textContaining('Error').evaluate().isNotEmpty,
isTrue,
reason: 'Should display an error indicator',
);
});
testWidgets('calls API on mount', (tester) async {
harness.givenAuthenticatedUser();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
await tester.pumpAndSettle();
harness.verifyApiCalled('GET', '/infrastructure/domains');
});
});
group('DomainData', () {
test('parses JSON correctly', () {
final json = Fixtures.domains[0];
final domain = DomainData.fromJson(json);
expect(domain.domain, 'home.schweitz.net');
expect(domain.service, '192.168.86.149:8092');
expect(domain.proxyHostId, 1);
expect(domain.sslEnabled, isTrue);
expect(domain.certificateId, 1);
});
test('handles missing certificate_id', () {
final json = {
'domain': 'test.example.com',
'service': '192.168.1.1:80',
'proxy_host_id': 99,
'ssl_enabled': false,
};
final domain = DomainData.fromJson(json);
expect(domain.certificateId, isNull);
});
test('defaults ssl_enabled to false when missing', () {
final json = {
'domain': 'test.example.com',
'service': '192.168.1.1:80',
'proxy_host_id': 99,
};
final domain = DomainData.fromJson(json);
expect(domain.sslEnabled, isFalse);
});
test('parses all fixture domains correctly', () {
for (final json in Fixtures.domains) {
final domain = DomainData.fromJson(json);
expect(domain.domain, isNotEmpty);
expect(domain.service, isNotEmpty);
expect(domain.proxyHostId, isPositive);
}
});
});
group('ProxyHostsPage Responsive', () {
testWidgets('renders at desktop size (1920x1080)', (tester) async {
configureScreenSize(tester, ScreenSizes.desktopLarge);
harness.givenAuthenticatedUser();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
await tester.pumpAndSettle();
expect(find.text('home.schweitz.net'), findsOneWidget);
expect(find.byType(TextField), findsOneWidget);
});
testWidgets('renders at tablet landscape (1024x768)', (tester) async {
configureScreenSize(tester, ScreenSizes.tabletLandscape);
harness.givenAuthenticatedUser();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
await tester.pumpAndSettle();
expect(find.text('home.schweitz.net'), findsOneWidget);
});
testWidgets('renders at tablet portrait (768x1024)', (tester) async {
configureScreenSize(tester, ScreenSizes.tabletPortrait);
harness.givenAuthenticatedUser();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
await tester.pumpAndSettle();
expect(find.byType(ProxyHostsPage), findsOneWidget);
});
testWidgets('renders at mobile size (375x812)', (tester) async {
configureScreenSize(tester, ScreenSizes.mobile);
harness.givenAuthenticatedUser();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
await tester.pumpAndSettle();
expect(find.byType(ProxyHostsPage), findsOneWidget);
});
testWidgets('renders at small mobile size (320x568)', (tester) async {
configureScreenSize(tester, ScreenSizes.mobileSmall);
harness.givenAuthenticatedUser();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
await tester.pumpAndSettle();
expect(find.byType(ProxyHostsPage), findsOneWidget);
});
});
}
@@ -0,0 +1,322 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tatlock_ui/features/control_room/presentation/pages/control_room_page.dart';
import 'package:tatlock_ui/features/control_room/router.dart';
import '../../../../harness/screen_sizes.dart';
import '../../../../harness/test_harness.dart';
void main() {
final harness = TestHarness();
setUp(() => harness.setUp());
tearDown(() => harness.tearDown());
// Set larger window size and suppress overflow errors
Future<void> setLargeWindowSize(WidgetTester tester) async {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
// Suppress overflow errors - not what we're testing
final originalOnError = FlutterError.onError;
FlutterError.onError = (details) {
if (details.exceptionAsString().contains('overflowed')) {
return; // Ignore overflow errors
}
originalOnError?.call(details);
};
addTearDown(() => FlutterError.onError = originalOnError);
}
group('ControlRoomPage', () {
testWidgets('displays Sections nav panel header', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenContainers();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ControlRoomPage()));
await tester.pumpAndSettle();
expect(find.text('Sections'), findsOneWidget);
expect(find.byIcon(Icons.dns_outlined), findsWidgets);
});
testWidgets('displays Stack Management section items', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenContainers();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ControlRoomPage()));
await tester.pumpAndSettle();
expect(find.text('STACK MANAGEMENT'), findsOneWidget);
expect(find.text('Containers'), findsOneWidget);
expect(find.text('Proxy Hosts'), findsOneWidget);
});
testWidgets('displays Data Management section items', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenContainers();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ControlRoomPage()));
await tester.pumpAndSettle();
expect(find.text('DATA MANAGEMENT'), findsOneWidget);
expect(find.text('PostgreSQL'), findsOneWidget);
expect(find.text('Redis'), findsOneWidget);
expect(find.text('Qdrant'), findsOneWidget);
expect(find.text('Neo4j'), findsOneWidget);
});
testWidgets('displays Containers section by default', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenContainers();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ControlRoomPage()));
await tester.pumpAndSettle();
// Default nav is containers - should show stacks filter and containers
expect(find.text('Stacks'), findsOneWidget);
expect(find.text('All Containers'), findsOneWidget);
});
testWidgets('displays Stacks filter panel in Containers section',
(tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenContainers();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ControlRoomPage()));
await tester.pumpAndSettle();
// Stacks filter panel elements
expect(find.text('Stacks'), findsOneWidget);
expect(find.byIcon(Icons.layers), findsOneWidget);
expect(find.text('All Containers'), findsOneWidget);
expect(find.byIcon(Icons.all_inbox), findsOneWidget);
});
testWidgets('displays containers list in main content', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenContainers();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ControlRoomPage()));
await tester.pumpAndSettle();
// Should show container names from fixtures
expect(find.text('nginx-proxy'), findsOneWidget);
expect(find.text('postgres-db'), findsOneWidget);
expect(find.text('redis-cache'), findsOneWidget);
});
testWidgets('displays Proxy Hosts section when nav selected',
(tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenContainers();
harness.givenDomains();
await tester.pumpWidget(
harness.wrap(const ControlRoomPage(nav: ControlRoomNav.proxyHosts)),
);
await tester.pumpAndSettle();
// Should show domain names from fixtures
expect(find.text('home.schweitz.net'), findsOneWidget);
expect(find.text('api.schweitz.net'), findsOneWidget);
});
testWidgets('displays placeholder for unimplemented sections',
(tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenContainers();
await tester.pumpWidget(
harness.wrap(const ControlRoomPage(nav: ControlRoomNav.postgres)),
);
await tester.pumpAndSettle();
// Should show placeholder with "Coming soon"
expect(find.text('PostgreSQL'), findsWidgets);
expect(find.text('Data Management • Coming soon'), findsOneWidget);
});
testWidgets('displays search field for stacks', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenContainers();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ControlRoomPage()));
await tester.pumpAndSettle();
expect(find.text('Search stacks...'), findsOneWidget);
});
testWidgets('displays refresh button in stacks panel', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenContainers();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ControlRoomPage()));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.refresh), findsWidgets);
});
testWidgets('displays nav icons for all sections', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenContainers();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ControlRoomPage()));
await tester.pumpAndSettle();
// Stack Management icons
expect(find.byIcon(Icons.dns), findsWidgets);
expect(find.byIcon(Icons.public), findsOneWidget);
// Data Management icons
expect(find.byIcon(Icons.table_chart), findsOneWidget);
expect(find.byIcon(Icons.memory), findsOneWidget);
expect(find.byIcon(Icons.scatter_plot), findsOneWidget);
expect(find.byIcon(Icons.hub), findsOneWidget);
});
testWidgets('displays empty stacks state when no stacks found',
(tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenContainers(); // Fixtures have no stack labels
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ControlRoomPage()));
await tester.pumpAndSettle();
// Stacks are derived from container labels - fixtures have none
expect(find.text('No stacks found'), findsOneWidget);
});
testWidgets('displays loading state for containers initially',
(tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenContainers();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ControlRoomPage()));
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsWidgets);
await tester.pumpAndSettle();
});
});
group('ControlRoomNav', () {
test('all nav items have unique IDs', () {
final ids = ControlRoomNav.values.map((n) => n.id).toSet();
expect(ids.length, ControlRoomNav.values.length);
});
test('all nav items have labels', () {
for (final nav in ControlRoomNav.values) {
expect(nav.label, isNotEmpty);
}
});
test('all nav items have sections', () {
for (final nav in ControlRoomNav.values) {
expect(nav.section, isNotEmpty);
}
});
test('path returns correct route', () {
expect(ControlRoomNav.containers.path, '/control-room/containers');
expect(ControlRoomNav.proxyHosts.path, '/control-room/proxy-hosts');
expect(ControlRoomNav.postgres.path, '/control-room/postgres');
});
test('toNavItem creates valid NavItem', () {
final navItem = ControlRoomNav.containers.toNavItem();
expect(navItem.id, 'containers');
expect(navItem.label, 'Containers');
expect(navItem.section, 'Stack Management');
});
});
group('ControlRoomPage Responsive', () {
testWidgets('renders at desktop size (1920x1080)', (tester) async {
configureScreenSize(tester, ScreenSizes.desktopLarge);
harness.givenAuthenticatedUser();
harness.givenContainers();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ControlRoomPage()));
await tester.pumpAndSettle();
expect(find.text('Sections'), findsOneWidget);
expect(find.text('Containers'), findsOneWidget);
});
testWidgets('renders at tablet landscape (1024x768)', (tester) async {
configureScreenSize(tester, ScreenSizes.tabletLandscape);
harness.givenAuthenticatedUser();
harness.givenContainers();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ControlRoomPage()));
await tester.pumpAndSettle();
expect(find.text('Sections'), findsOneWidget);
});
testWidgets('renders at tablet portrait (768x1024)', (tester) async {
configureScreenSize(tester, ScreenSizes.tabletPortrait);
harness.givenAuthenticatedUser();
harness.givenContainers();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ControlRoomPage()));
await tester.pumpAndSettle();
expect(find.byType(ControlRoomPage), findsOneWidget);
});
testWidgets('renders at mobile size (375x812)', (tester) async {
configureScreenSize(tester, ScreenSizes.mobile);
harness.givenAuthenticatedUser();
harness.givenContainers();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ControlRoomPage()));
await tester.pumpAndSettle();
expect(find.byType(ControlRoomPage), findsOneWidget);
});
testWidgets('renders at small mobile size (320x568)', (tester) async {
configureScreenSize(tester, ScreenSizes.mobileSmall);
harness.givenAuthenticatedUser();
harness.givenContainers();
harness.givenDomains();
await tester.pumpWidget(harness.wrap(const ControlRoomPage()));
await tester.pumpAndSettle();
expect(find.byType(ControlRoomPage), findsOneWidget);
});
});
}
@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.dart';
import 'package:tatlock_ui/shared/widgets/air_quality_widget.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/weather_widget.dart';
import '../../harness/screen_sizes.dart';
import '../../harness/test_harness.dart';
void main() {
final harness = TestHarness();
setUp(() => harness.setUp());
tearDown(() => harness.tearDown());
// Set larger window size and suppress overflow errors
Future<void> setLargeWindowSize(WidgetTester tester) async {
tester.view.physicalSize = const Size(1400, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
// Suppress overflow errors - not what we're testing
final originalOnError = FlutterError.onError;
FlutterError.onError = (details) {
if (details.exceptionAsString().contains('overflowed')) {
return;
}
originalOnError?.call(details);
};
addTearDown(() => FlutterError.onError = originalOnError);
}
group('Environment Widgets', () {
testWidgets('displays Sun Position widget', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.byType(SunPositionWidget), findsOneWidget);
expect(find.text('Sun Position'), findsOneWidget);
});
testWidgets('displays Weather widget', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.byType(WeatherWidget), findsOneWidget);
});
testWidgets('displays Forecast widget', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.byType(ForecastWidget), findsOneWidget);
expect(find.text('Forecast'), findsOneWidget);
});
testWidgets('displays Air Quality widget when data available',
(tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment(); // Has air quality data
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.byType(AirQualityWidget), findsOneWidget);
expect(find.text('Air Quality'), findsOneWidget);
});
testWidgets('shows Air Quality widget with no data state when data unavailable',
(tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironmentNoAirQuality(); // No air quality data
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
// Air Quality widget should still be present, showing "no data" state
expect(find.byType(AirQualityWidget), findsOneWidget);
expect(find.text('Air Quality'), findsOneWidget);
});
testWidgets('calls environment API on mount', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
harness.verifyApiCalled('GET', '/tools/environment');
});
testWidgets('displays error state when environment fails', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenApiError(
method: 'GET',
path: '/tools/environment',
statusCode: 500,
message: 'Internal server error',
);
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
// Should show environment error message or error indicator
expect(
find.text('Failed to load environment data').evaluate().isNotEmpty ||
find.byIcon(Icons.error_outline).evaluate().isNotEmpty,
isTrue,
reason: 'Should display environment error',
);
});
testWidgets('displays sunrise and sunset times', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
// Should display sunrise/sunset labels
expect(find.text('Sunrise'), findsOneWidget);
expect(find.text('Sunset'), findsOneWidget);
});
testWidgets('displays daylight duration', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
// Should display daylight label
expect(find.text('Daylight'), findsOneWidget);
});
});
group('Environment Widgets Responsive', () {
testWidgets('renders environment widgets at desktop size', (tester) async {
configureScreenSize(tester, ScreenSizes.desktopLarge);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.byType(SunPositionWidget), findsOneWidget);
expect(find.byType(WeatherWidget), findsOneWidget);
expect(find.byType(ForecastWidget), findsOneWidget);
});
testWidgets('renders environment widgets at tablet size', (tester) async {
configureScreenSize(tester, ScreenSizes.tabletLandscape);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.byType(SunPositionWidget), findsOneWidget);
expect(find.byType(WeatherWidget), findsOneWidget);
expect(find.byType(ForecastWidget), findsOneWidget);
});
testWidgets('renders environment widgets at mobile size', (tester) async {
configureScreenSize(tester, ScreenSizes.mobile);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
// Page should render without crashing at mobile size
// Widgets may be below the fold due to scrolling
expect(find.byType(FrontHallPage), findsOneWidget);
});
});
}
@@ -0,0 +1,316 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.dart';
import '../../../../harness/screen_sizes.dart';
import '../../../../harness/test_harness.dart';
void main() {
final harness = TestHarness();
setUp(() => harness.setUp());
tearDown(() => harness.tearDown());
// Set larger window size and suppress overflow errors
Future<void> setLargeWindowSize(WidgetTester tester) async {
tester.view.physicalSize = const Size(1400, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
// Suppress overflow errors - not what we're testing
final originalOnError = FlutterError.onError;
FlutterError.onError = (details) {
if (details.exceptionAsString().contains('overflowed')) {
return; // Ignore overflow errors
}
originalOnError?.call(details);
};
addTearDown(() => FlutterError.onError = originalOnError);
}
group('FrontHallPage', () {
testWidgets('displays welcome message', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.text('Welcome to Tatlock'), findsOneWidget);
expect(find.text('Your homelab dashboard is ready.'), findsOneWidget);
});
testWidgets('displays Quick Links panel header', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.text('Quick Links'), findsOneWidget);
expect(find.byIcon(Icons.link), findsOneWidget);
});
testWidgets('displays quick links from API', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
// Should display quick link names from fixtures
expect(find.text('Portainer'), findsOneWidget);
expect(find.text('Gitea'), findsOneWidget);
expect(find.text('Jellyfin'), findsOneWidget);
});
testWidgets('displays System Stats section header', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.text('System Stats'), findsOneWidget);
expect(find.byIcon(Icons.monitor_heart), findsWidgets);
});
testWidgets('displays Environment section header', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.text('Environment'), findsOneWidget);
expect(find.byIcon(Icons.eco), findsOneWidget);
});
testWidgets('displays Settings button', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.text('Settings'), findsOneWidget);
expect(find.byIcon(Icons.settings), findsOneWidget);
});
testWidgets('displays refresh button in Quick Links panel', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.refresh), findsWidgets);
});
testWidgets('displays loading state for system stats initially',
(tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pump();
// Should show loading indicator before data loads
expect(find.byType(CircularProgressIndicator), findsWidgets);
await tester.pumpAndSettle();
});
testWidgets('displays system stats gauges after loading', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
// Should display gauge labels
expect(find.text('CPU'), findsOneWidget);
expect(find.text('RAM'), findsOneWidget);
});
testWidgets('displays error state when system stats fails', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenEnvironment();
harness.givenApiError(
method: 'GET',
path: '/tools/system/stats',
statusCode: 500,
message: 'Internal server error',
);
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
// Should show system stats error message or error indicator
expect(
find.text('Failed to load system stats').evaluate().isNotEmpty ||
find.byIcon(Icons.error_outline).evaluate().isNotEmpty,
isTrue,
reason: 'Should display system stats error',
);
});
testWidgets('calls quick links API on mount', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
harness.verifyApiCalled('GET', '/dashboard/quick-links');
});
testWidgets('calls system stats API on mount', (tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
harness.verifyApiCalled('GET', '/tools/system/stats');
});
testWidgets('uses default links when API returns empty list',
(tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks([]); // Empty list triggers fallback
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
// Should display default links (from getDefaultQuickLinks)
expect(find.text('Jellyfin'), findsOneWidget);
expect(find.text('Open WebUI'), findsOneWidget);
});
testWidgets('displays category headers when multiple categories present',
(tester) async {
await setLargeWindowSize(tester);
harness.givenAuthenticatedUser();
harness.givenQuickLinks(); // Fixtures have Infrastructure, Development, Home
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
// Should display category headers (uppercase)
// At least INFRASTRUCTURE should be visible since Portainer is first
expect(find.text('INFRASTRUCTURE'), findsOneWidget);
});
});
group('FrontHallPage Responsive', () {
testWidgets('renders at desktop size (1920x1080)', (tester) async {
configureScreenSize(tester, ScreenSizes.desktopLarge);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.text('Welcome to Tatlock'), findsOneWidget);
expect(find.text('Quick Links'), findsOneWidget);
});
testWidgets('renders at tablet landscape (1024x768)', (tester) async {
configureScreenSize(tester, ScreenSizes.tabletLandscape);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.text('Welcome to Tatlock'), findsOneWidget);
expect(find.text('Quick Links'), findsOneWidget);
});
testWidgets('renders at tablet portrait (768x1024)', (tester) async {
configureScreenSize(tester, ScreenSizes.tabletPortrait);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
expect(find.text('Welcome to Tatlock'), findsOneWidget);
});
testWidgets('renders at mobile size (375x812)', (tester) async {
configureScreenSize(tester, ScreenSizes.mobile);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
// Core content should still be accessible
expect(find.byType(FrontHallPage), findsOneWidget);
});
testWidgets('renders at small mobile size (320x568)', (tester) async {
configureScreenSize(tester, ScreenSizes.mobileSmall);
harness.givenAuthenticatedUser();
harness.givenQuickLinks();
harness.givenSystemStats();
harness.givenEnvironment();
await tester.pumpWidget(harness.wrap(const FrontHallPage()));
await tester.pumpAndSettle();
// Should render without crashing
expect(find.byType(FrontHallPage), findsOneWidget);
});
});
}
@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tatlock_ui/features/security/groups/groups_list_page.dart';
import '../../../harness/screen_sizes.dart';
import '../../../harness/test_harness.dart';
void main() {
final harness = TestHarness();
setUp(() => harness.setUp());
tearDown(() => harness.tearDown());
group('GroupsListPage', () {
testWidgets('displays loading state initially', (tester) async {
harness.givenAuthenticatedUser();
harness.givenGroups();
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.pumpAndSettle();
});
testWidgets('displays groups from API', (tester) async {
harness.givenAuthenticatedUser();
harness.givenGroups();
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pumpAndSettle();
expect(find.text('admins'), findsOneWidget);
// 'users' appears twice: as group name and as parent of developers
expect(find.text('users'), findsNWidgets(2));
expect(find.text('developers'), findsOneWidget);
});
testWidgets('displays member counts', (tester) async {
harness.givenAuthenticatedUser();
harness.givenGroups();
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pumpAndSettle();
// Member counts from fixtures: admins=1, users=3, developers=1
expect(find.text('1'), findsNWidgets(2)); // admins and developers
expect(find.text('3'), findsOneWidget); // users
});
testWidgets('displays Superuser badge for superuser groups', (tester) async {
harness.givenAuthenticatedUser();
harness.givenGroups();
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pumpAndSettle();
// admins group is superuser
expect(find.text('Superuser'), findsOneWidget);
// users and developers are standard
expect(find.text('Standard'), findsNWidgets(2));
});
testWidgets('displays parent group name', (tester) async {
harness.givenAuthenticatedUser();
harness.givenGroups();
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pumpAndSettle();
// developers has parent_name = 'users'
// Find 'users' appearing twice: once as group name, once as parent
expect(find.text('users'), findsNWidgets(2));
});
testWidgets('displays dash for groups without parent', (tester) async {
harness.givenAuthenticatedUser();
harness.givenGroups();
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pumpAndSettle();
// admins and users have null parent_name, shown as '-'
expect(find.text('-'), findsNWidgets(2));
});
testWidgets('displays search input', (tester) async {
harness.givenAuthenticatedUser();
harness.givenGroups();
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
expect(find.text('Search groups...'), findsOneWidget);
});
testWidgets('displays refresh button', (tester) async {
harness.givenAuthenticatedUser();
harness.givenGroups();
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.refresh), findsOneWidget);
});
testWidgets('displays Sync from Authentik button', (tester) async {
harness.givenAuthenticatedUser();
harness.givenGroups();
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pumpAndSettle();
expect(find.text('Sync from Authentik'), findsOneWidget);
expect(find.byIcon(Icons.sync), findsOneWidget);
});
testWidgets('search filters groups', (tester) async {
harness.givenAuthenticatedUser();
harness.givenGroups();
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pumpAndSettle();
final searchField = find.byType(TextField);
await tester.enterText(searchField, 'admin');
await tester.pumpAndSettle();
expect(find.text('admins'), findsOneWidget);
expect(find.text('users'), findsNothing);
expect(find.text('developers'), findsNothing);
});
testWidgets('displays empty state when no groups match search',
(tester) async {
harness.givenAuthenticatedUser();
harness.givenGroups();
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pumpAndSettle();
final searchField = find.byType(TextField);
await tester.enterText(searchField, 'nonexistent');
await tester.pumpAndSettle();
expect(find.text('admins'), findsNothing);
expect(find.text('users'), findsNothing);
});
testWidgets('displays error state on API failure', (tester) async {
harness.givenAuthenticatedUser();
harness.givenApiError(
method: 'GET',
path: '/auth/groups',
statusCode: 500,
message: 'Internal server error',
);
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(
find.byIcon(Icons.error_outline).evaluate().isNotEmpty ||
find.textContaining('error').evaluate().isNotEmpty ||
find.textContaining('Error').evaluate().isNotEmpty,
isTrue,
reason: 'Should display an error indicator',
);
});
testWidgets('calls API on mount', (tester) async {
harness.givenAuthenticatedUser();
harness.givenGroups();
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pumpAndSettle();
harness.verifyApiCalled('GET', '/auth/groups');
});
});
group('GroupData', () {
test('parses JSON correctly', () {
final json = Fixtures.groups[0];
final group = GroupData.fromJson(json);
expect(group.id, 'group-001');
expect(group.name, 'admins');
expect(group.isSuperuser, isTrue);
expect(group.memberCount, 1);
expect(group.parentName, isNull);
});
test('parses group with parent correctly', () {
final json = Fixtures.groups[2]; // developers with parent
final group = GroupData.fromJson(json);
expect(group.name, 'developers');
expect(group.parentName, 'users');
});
test('defaults isSuperuser to false when missing', () {
final json = {
'id': 'test',
'name': 'test-group',
'member_count': 5,
};
final group = GroupData.fromJson(json);
expect(group.isSuperuser, isFalse);
});
test('defaults memberCount to 0 when missing', () {
final json = {
'id': 'test',
'name': 'test-group',
'is_superuser': false,
};
final group = GroupData.fromJson(json);
expect(group.memberCount, 0);
});
test('parses all fixture groups correctly', () {
for (final json in Fixtures.groups) {
final group = GroupData.fromJson(json);
expect(group.id, isNotEmpty);
expect(group.name, isNotEmpty);
}
});
});
group('GroupsListPage Responsive', () {
testWidgets('renders at desktop size (1920x1080)', (tester) async {
configureScreenSize(tester, ScreenSizes.desktopLarge);
harness.givenAuthenticatedUser();
harness.givenGroups();
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pumpAndSettle();
expect(find.text('admins'), findsOneWidget);
expect(find.byType(TextField), findsOneWidget);
});
testWidgets('renders at tablet landscape (1024x768)', (tester) async {
configureScreenSize(tester, ScreenSizes.tabletLandscape);
harness.givenAuthenticatedUser();
harness.givenGroups();
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pumpAndSettle();
expect(find.text('admins'), findsOneWidget);
});
testWidgets('renders at tablet portrait (768x1024)', (tester) async {
configureScreenSize(tester, ScreenSizes.tabletPortrait);
harness.givenAuthenticatedUser();
harness.givenGroups();
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pumpAndSettle();
expect(find.byType(GroupsListPage), findsOneWidget);
});
testWidgets('renders at mobile size (375x812)', (tester) async {
configureScreenSize(tester, ScreenSizes.mobile);
harness.givenAuthenticatedUser();
harness.givenGroups();
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pumpAndSettle();
expect(find.byType(GroupsListPage), findsOneWidget);
});
testWidgets('renders at small mobile size (320x568)', (tester) async {
configureScreenSize(tester, ScreenSizes.mobileSmall);
harness.givenAuthenticatedUser();
harness.givenGroups();
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
await tester.pumpAndSettle();
expect(find.byType(GroupsListPage), findsOneWidget);
});
});
}
@@ -0,0 +1,315 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tatlock_ui/features/security/users/users_list_page.dart';
import '../../../harness/screen_sizes.dart';
import '../../../harness/test_harness.dart';
void main() {
final harness = TestHarness();
setUp(() => harness.setUp());
tearDown(() => harness.tearDown());
group('UsersListPage', () {
testWidgets('displays loading state initially', (tester) async {
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.pumpAndSettle();
});
testWidgets('displays users from API', (tester) async {
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pumpAndSettle();
expect(find.text('Admin User'), findsOneWidget);
expect(find.text('John Doe'), findsOneWidget);
expect(find.text('Jane Smith'), findsOneWidget);
});
testWidgets('displays user emails', (tester) async {
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pumpAndSettle();
expect(find.text('admin@example.com'), findsOneWidget);
expect(find.text('john@example.com'), findsOneWidget);
expect(find.text('jane@example.com'), findsOneWidget);
});
testWidgets('displays Admin badge for admin users', (tester) async {
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pumpAndSettle();
// Admin User should have Admin badge
expect(find.text('Admin'), findsOneWidget);
});
testWidgets('displays user initials in avatar', (tester) async {
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pumpAndSettle();
// Should display initials for users without avatar
expect(find.text('AU'), findsOneWidget); // Admin User
expect(find.text('JD'), findsOneWidget); // John Doe
expect(find.text('JS'), findsOneWidget); // Jane Smith
});
testWidgets('displays search input', (tester) async {
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
expect(find.text('Search users...'), findsOneWidget);
});
testWidgets('displays refresh button', (tester) async {
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.refresh), findsOneWidget);
});
testWidgets('displays Sync from Authentik button', (tester) async {
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pumpAndSettle();
expect(find.text('Sync from Authentik'), findsOneWidget);
expect(find.byIcon(Icons.sync), findsOneWidget);
});
testWidgets('search filters users', (tester) async {
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pumpAndSettle();
final searchField = find.byType(TextField);
await tester.enterText(searchField, 'Admin');
await tester.pumpAndSettle();
expect(find.text('Admin User'), findsOneWidget);
expect(find.text('John Doe'), findsNothing);
expect(find.text('Jane Smith'), findsNothing);
});
testWidgets('displays empty state when no users match search',
(tester) async {
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pumpAndSettle();
final searchField = find.byType(TextField);
await tester.enterText(searchField, 'nonexistent');
await tester.pumpAndSettle();
expect(find.text('Admin User'), findsNothing);
expect(find.text('John Doe'), findsNothing);
});
testWidgets('displays "No roles" for users without roles', (tester) async {
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pumpAndSettle();
// Jane Smith has no roles
expect(find.text('No roles'), findsOneWidget);
});
testWidgets('displays role badges', (tester) async {
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pumpAndSettle();
// Should display role names (last part after colon)
expect(find.text('admin'), findsOneWidget);
expect(find.text('viewer'), findsOneWidget);
});
testWidgets('displays error state on API failure', (tester) async {
harness.givenAuthenticatedUser();
harness.givenApiError(
method: 'GET',
path: '/auth/users',
statusCode: 500,
message: 'Internal server error',
);
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(
find.byIcon(Icons.error_outline).evaluate().isNotEmpty ||
find.textContaining('error').evaluate().isNotEmpty ||
find.textContaining('Error').evaluate().isNotEmpty,
isTrue,
reason: 'Should display an error indicator',
);
});
testWidgets('calls API on mount', (tester) async {
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pumpAndSettle();
harness.verifyApiCalled('GET', '/auth/users');
});
});
group('UserData', () {
test('parses JSON correctly', () {
final json = Fixtures.users[0];
final user = UserData.fromJson(json);
expect(user.id, 'user-001');
expect(user.email, 'admin@example.com');
expect(user.name, 'Admin User');
expect(user.roles, contains('admin.general:admin'));
expect(user.isAdmin, isTrue);
});
test('isAdmin returns false for non-admin users', () {
final json = Fixtures.users[1];
final user = UserData.fromJson(json);
expect(user.isAdmin, isFalse);
});
test('initials returns correct value for two-word names', () {
final user = UserData.fromJson(Fixtures.users[0]);
expect(user.initials, 'AU'); // Admin User
});
test('initials returns correct value for single-word names', () {
final json = {
'id': 'test',
'email': 'test@test.com',
'name': 'Madonna',
'created_at': '2024-01-01T00:00:00Z',
'roles': [],
};
final user = UserData.fromJson(json);
expect(user.initials, 'MA');
});
test('lastLoginFormatted returns "Never" for null lastLogin', () {
final json = Fixtures.users[2]; // Jane Smith has null last_login
final user = UserData.fromJson(json);
expect(user.lastLoginFormatted, 'Never');
});
test('handles missing roles gracefully', () {
final json = {
'id': 'test',
'email': 'test@test.com',
'name': 'Test User',
'created_at': '2024-01-01T00:00:00Z',
};
final user = UserData.fromJson(json);
expect(user.roles, isEmpty);
});
test('parses all fixture users correctly', () {
for (final json in Fixtures.users) {
final user = UserData.fromJson(json);
expect(user.id, isNotEmpty);
expect(user.email, isNotEmpty);
expect(user.name, isNotEmpty);
}
});
});
group('UsersListPage Responsive', () {
testWidgets('renders at desktop size (1920x1080)', (tester) async {
configureScreenSize(tester, ScreenSizes.desktopLarge);
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pumpAndSettle();
expect(find.text('Admin User'), findsOneWidget);
expect(find.byType(TextField), findsOneWidget);
});
testWidgets('renders at tablet landscape (1024x768)', (tester) async {
configureScreenSize(tester, ScreenSizes.tabletLandscape);
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pumpAndSettle();
expect(find.text('Admin User'), findsOneWidget);
});
testWidgets('renders at tablet portrait (768x1024)', (tester) async {
configureScreenSize(tester, ScreenSizes.tabletPortrait);
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pumpAndSettle();
expect(find.byType(UsersListPage), findsOneWidget);
});
testWidgets('renders at mobile size (375x812)', (tester) async {
configureScreenSize(tester, ScreenSizes.mobile);
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pumpAndSettle();
expect(find.byType(UsersListPage), findsOneWidget);
});
testWidgets('renders at small mobile size (320x568)', (tester) async {
configureScreenSize(tester, ScreenSizes.mobileSmall);
harness.givenAuthenticatedUser();
harness.givenUsers();
await tester.pumpWidget(harness.wrap(const UsersListPage()));
await tester.pumpAndSettle();
expect(find.byType(UsersListPage), findsOneWidget);
});
});
}
@@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tatlock_ui/core/theme/theme_provider.dart';
import 'package:tatlock_ui/features/settings/presentation/pages/settings_page.dart';
import '../../../../harness/screen_sizes.dart';
import '../../../../harness/test_harness.dart';
void main() {
final harness = TestHarness();
setUp(() => harness.setUp());
tearDown(() => harness.tearDown());
group('SettingsPage', () {
testWidgets('displays Settings header', (tester) async {
harness.givenAuthenticatedUser();
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
expect(find.text('Settings'), findsOneWidget);
});
testWidgets('displays Appearance section', (tester) async {
harness.givenAuthenticatedUser();
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
expect(find.text('APPEARANCE'), findsOneWidget);
expect(find.text('Theme'), findsOneWidget);
expect(find.text('Choose your preferred color scheme'), findsOneWidget);
});
testWidgets('displays theme dropdown with options', (tester) async {
harness.givenAuthenticatedUser();
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
// Find and tap the theme dropdown
final dropdown = find.byType(DropdownButton<ThemeSetting>);
expect(dropdown, findsOneWidget);
await tester.tap(dropdown);
await tester.pumpAndSettle();
// Should show all theme options (may appear twice: selected + menu item)
expect(find.text('System'), findsWidgets);
expect(find.text('Light'), findsWidgets);
expect(find.text('Dark'), findsWidgets);
});
testWidgets('displays Navigation section', (tester) async {
harness.givenAuthenticatedUser();
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
expect(find.text('NAVIGATION'), findsOneWidget);
expect(find.text('Default Room'), findsOneWidget);
expect(find.text('Room to show when app opens'), findsOneWidget);
});
testWidgets('displays room dropdown with options', (tester) async {
harness.givenAuthenticatedUser();
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
// Find the room dropdown (second DropdownButton)
final dropdowns = find.byType(DropdownButton<String>);
expect(dropdowns, findsOneWidget);
await tester.tap(dropdowns);
await tester.pumpAndSettle();
// Should show room options from registry
expect(find.text('Front Hall'), findsWidgets);
});
testWidgets('displays Account section', (tester) async {
harness.givenAuthenticatedUser();
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
expect(find.text('ACCOUNT'), findsOneWidget);
expect(find.text('Name'), findsOneWidget);
expect(find.text('Email'), findsOneWidget);
});
testWidgets('displays user name from auth state', (tester) async {
harness.givenAuthenticatedUser(name: 'John Doe');
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
expect(find.text('John Doe'), findsOneWidget);
});
testWidgets('displays user email from auth state', (tester) async {
harness.givenAuthenticatedUser(email: 'john@example.com');
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
expect(find.text('john@example.com'), findsOneWidget);
});
testWidgets('displays admin role for admin user', (tester) async {
harness.givenAdminUser();
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
expect(find.text('Roles'), findsOneWidget);
expect(find.textContaining('admin.general:admin'), findsOneWidget);
});
testWidgets('displays theme icon for system theme', (tester) async {
harness.givenAuthenticatedUser();
harness.givenThemeMode(ThemeMode.system);
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.brightness_auto), findsOneWidget);
});
testWidgets('displays theme icon for light theme', (tester) async {
harness.givenAuthenticatedUser();
harness.givenThemeMode(ThemeMode.light);
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.light_mode), findsOneWidget);
});
testWidgets('displays theme icon for dark theme', (tester) async {
harness.givenAuthenticatedUser();
harness.givenThemeMode(ThemeMode.dark);
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.dark_mode), findsOneWidget);
});
testWidgets('displays account icons', (tester) async {
harness.givenAuthenticatedUser();
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.person_outline), findsOneWidget);
expect(find.byIcon(Icons.email_outlined), findsOneWidget);
expect(find.byIcon(Icons.home_outlined), findsOneWidget);
});
testWidgets('displays all three section cards', (tester) async {
harness.givenAuthenticatedUser();
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
// Should have 3 cards: Appearance, Navigation, Account
expect(find.byType(Card), findsNWidgets(3));
});
});
group('SettingsPage Responsive', () {
testWidgets('renders at desktop size (1920x1080)', (tester) async {
configureScreenSize(tester, ScreenSizes.desktopLarge);
harness.givenAuthenticatedUser();
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
expect(find.text('Settings'), findsOneWidget);
expect(find.byType(Card), findsNWidgets(3));
});
testWidgets('renders at tablet landscape (1024x768)', (tester) async {
configureScreenSize(tester, ScreenSizes.tabletLandscape);
harness.givenAuthenticatedUser();
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
expect(find.text('Settings'), findsOneWidget);
expect(find.text('APPEARANCE'), findsOneWidget);
});
testWidgets('renders at tablet portrait (768x1024)', (tester) async {
configureScreenSize(tester, ScreenSizes.tabletPortrait);
harness.givenAuthenticatedUser();
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
expect(find.text('Settings'), findsOneWidget);
});
testWidgets('renders at mobile size (375x812)', (tester) async {
configureScreenSize(tester, ScreenSizes.mobile);
harness.givenAuthenticatedUser();
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
expect(find.byType(SettingsPage), findsOneWidget);
});
testWidgets('renders at small mobile size (320x568)', (tester) async {
configureScreenSize(tester, ScreenSizes.mobileSmall);
harness.givenAuthenticatedUser();
await tester.pumpWidget(harness.wrap(const SettingsPage()));
await tester.pumpAndSettle();
expect(find.byType(SettingsPage), findsOneWidget);
});
});
}
+410
View File
@@ -0,0 +1,410 @@
/// Test fixtures for mock API responses.
///
/// Provides realistic test data for various API endpoints.
class Fixtures {
Fixtures._();
// ============================================================
// Containers
// ============================================================
static const containers = [
{
'Id': 'abc123def456',
'Names': ['/nginx-proxy'],
'Image': 'nginx:latest',
'State': 'running',
'Status': 'Up 3 days (healthy)',
'Created': 1704067200,
'Ports': [
{'PrivatePort': 80, 'PublicPort': 80, 'Type': 'tcp'},
{'PrivatePort': 443, 'PublicPort': 443, 'Type': 'tcp'},
],
},
{
'Id': 'def456ghi789',
'Names': ['/postgres-db'],
'Image': 'postgres:15',
'State': 'running',
'Status': 'Up 5 days',
'Created': 1703894400,
'Ports': [
{'PrivatePort': 5432, 'PublicPort': 5432, 'Type': 'tcp'},
],
},
{
'Id': 'ghi789jkl012',
'Names': ['/redis-cache'],
'Image': 'redis:7-alpine',
'State': 'running',
'Status': 'Up 2 days',
'Created': 1704153600,
'Ports': [
{'PrivatePort': 6379, 'Type': 'tcp'},
],
},
{
'Id': 'jkl012mno345',
'Names': ['/stopped-service'],
'Image': 'myapp:latest',
'State': 'exited',
'Status': 'Exited (0) 12 hours ago',
'Created': 1704067200,
'Ports': [],
},
];
static Map<String, dynamic> container(int index) => containers[index];
// ============================================================
// Domains (for ProxyHostsPage DataGrid)
// ============================================================
static const domains = [
{
'domain': 'home.schweitz.net',
'service': '192.168.86.149:8092',
'proxy_host_id': 1,
'ssl_enabled': true,
'certificate_id': 1,
},
{
'domain': 'api.schweitz.net',
'service': '192.168.86.149:8083',
'proxy_host_id': 2,
'ssl_enabled': true,
'certificate_id': 2,
},
{
'domain': 'git.schweitz.net',
'service': '192.168.86.149:3000',
'proxy_host_id': 3,
'ssl_enabled': true,
'certificate_id': 3,
},
{
'domain': 'internal.schweitz.net',
'service': '192.168.86.100:80',
'proxy_host_id': 4,
'ssl_enabled': false,
'certificate_id': null,
},
];
static Map<String, dynamic> domain(int index) =>
Map<String, dynamic>.from(domains[index]);
// ============================================================
// Proxy Hosts (raw NPM format)
// ============================================================
static const proxyHosts = [
{
'id': 1,
'domain_names': ['home.schweitz.net'],
'forward_host': '192.168.86.149',
'forward_port': 8092,
'forward_scheme': 'http',
'ssl_forced': true,
'enabled': 1,
'meta': {'nginx_online': true},
},
{
'id': 2,
'domain_names': ['api.schweitz.net'],
'forward_host': '192.168.86.149',
'forward_port': 8083,
'forward_scheme': 'http',
'ssl_forced': true,
'enabled': 1,
'meta': {'nginx_online': true},
},
{
'id': 3,
'domain_names': ['git.schweitz.net'],
'forward_host': '192.168.86.149',
'forward_port': 3000,
'forward_scheme': 'http',
'ssl_forced': true,
'enabled': 1,
'meta': {'nginx_online': true},
},
];
static Map<String, dynamic> proxyHost(int index) =>
Map<String, dynamic>.from(proxyHosts[index]);
// ============================================================
// Users (for UsersListPage DataGrid)
// ============================================================
static const users = [
{
'id': 'user-001',
'email': 'admin@example.com',
'name': 'Admin User',
'avatar_url': null,
'created_at': '2024-01-01T00:00:00Z',
'last_login': '2024-01-15T10:30:00Z',
'roles': ['admin.general:admin'],
},
{
'id': 'user-002',
'email': 'john@example.com',
'name': 'John Doe',
'avatar_url': null,
'created_at': '2024-01-05T00:00:00Z',
'last_login': '2024-01-14T08:00:00Z',
'roles': ['security.users:viewer'],
},
{
'id': 'user-003',
'email': 'jane@example.com',
'name': 'Jane Smith',
'avatar_url': null,
'created_at': '2024-01-10T00:00:00Z',
'last_login': null,
'roles': [],
},
];
static Map<String, dynamic> user(int index) =>
Map<String, dynamic>.from(users[index]);
// ============================================================
// Groups
// ============================================================
static const groups = [
{
'id': 'group-001',
'name': 'admins',
'is_superuser': true,
'member_count': 1,
'parent_name': null,
},
{
'id': 'group-002',
'name': 'users',
'is_superuser': false,
'member_count': 3,
'parent_name': null,
},
{
'id': 'group-003',
'name': 'developers',
'is_superuser': false,
'member_count': 1,
'parent_name': 'users',
},
];
static Map<String, dynamic> group(int index) =>
Map<String, dynamic>.from(groups[index]);
// ============================================================
// Auth Sync Response
// ============================================================
static Map<String, dynamic> authSyncResponse({
String userId = 'user-001',
String authentikId = 'ak-001',
String name = 'Test User',
String email = 'test@example.com',
bool isAdmin = false,
}) {
return {
'user_id': userId,
'authentik_id': authentikId,
'name': name,
'email': email,
'avatar_url': null,
'roles': isAdmin
? [
{
'id': 'admin-role',
'name': 'admin.general:admin',
'domain': 'admin',
'category': 'general',
'action': 'admin',
}
]
: [],
'preferences': {
'theme': 'system',
'default_room': 'front-hall',
'preferences_json': {},
},
};
}
// ============================================================
// System Stats (for DashboardContent)
// ============================================================
static const systemStats = {
'cpu': {
'usage_percent': 23.5,
'cores': 8,
'load_1m': 1.2,
'load_5m': 1.5,
'load_15m': 1.3,
},
'memory': {
'usage_percent': 45.2,
'total_bytes': 17179869184,
'used_bytes': 7771729306,
'available_bytes': 9408139878,
},
'disks': [
{
'mount_point': '/',
'device': '/dev/sda1',
'fstype': 'ext4',
'usage_percent': 67.8,
'total_bytes': 500000000000,
'used_bytes': 339000000000,
'free_bytes': 161000000000,
},
],
'network': {
'bytes_sent': 1234567890,
'bytes_recv': 9876543210,
'bytes_total': 11111111100,
},
'gpu': {
'available': false,
},
'hostname': 'test-server',
'queried_at': '2024-01-15T10:30:00Z',
};
// ============================================================
// Quick Links
// ============================================================
static const quickLinks = [
{
'id': 1,
'title': 'Portainer',
'url': 'https://portainer.example.com',
'icon': 'dns',
'category': 'Infrastructure',
'position': 0,
'is_visible': true,
'link_type': 'iframe',
},
{
'id': 2,
'title': 'Gitea',
'url': 'https://git.example.com',
'icon': 'code',
'category': 'Development',
'position': 1,
'is_visible': true,
'link_type': 'new_tab',
},
{
'id': 3,
'title': 'Jellyfin',
'url': 'https://media.example.com',
'icon': 'movie',
'category': 'Home',
'position': 2,
'is_visible': true,
'link_type': 'iframe',
},
];
static Map<String, dynamic> quickLink(int index) =>
Map<String, dynamic>.from(quickLinks[index]);
// ============================================================
// Environment Data
// ============================================================
static const environment = {
'weather': {
'temperature': 8.5,
'feels_like': 5.0,
'conditions': 'Partly Cloudy',
'humidity': 72,
'wind_speed': 18.5,
'wind_direction': 'SW',
'location': 'Rotterdam, NL',
'icon': '03d',
},
'forecast': [
{
'date': '2026-01-06',
'high': 10.0,
'low': 4.0,
'conditions': 'Cloudy',
'precipitation_chance': 20,
},
{
'date': '2026-01-07',
'high': 12.0,
'low': 5.0,
'conditions': 'Partly Cloudy',
'precipitation_chance': 10,
},
{
'date': '2026-01-08',
'high': 9.0,
'low': 3.0,
'conditions': 'Rain',
'precipitation_chance': 80,
},
{
'date': '2026-01-09',
'high': 11.0,
'low': 6.0,
'conditions': 'Sunny',
'precipitation_chance': 0,
},
],
'sun_times': {
'sunrise': '2026-01-06T08:45:00Z',
'sunset': '2026-01-06T16:50:00Z',
'daylight_minutes': 485,
'solar_noon': '2026-01-06T12:47:30Z',
},
'air_quality': {
'aqi': 42,
'quality': 'Good',
'pm25': 8.5,
'pm10': 15.0,
'o3': 32.0,
},
'updated_at': '2026-01-06T10:30:00Z',
'user': 'default',
};
/// Environment data without air quality (for conditional rendering test).
static const environmentNoAirQuality = {
'weather': {
'temperature': 8.5,
'conditions': 'Partly Cloudy',
'humidity': 72,
'wind_speed': 18.5,
'location': 'Rotterdam, NL',
},
'forecast': [
{
'date': '2026-01-06',
'high': 10.0,
'low': 4.0,
'conditions': 'Cloudy',
},
],
'sun_times': {
'sunrise': '2026-01-06T08:45:00Z',
'sunset': '2026-01-06T16:50:00Z',
'daylight_minutes': 485,
},
'air_quality': null,
'updated_at': '2026-01-06T10:30:00Z',
'user': 'default',
};
}
+185
View File
@@ -0,0 +1,185 @@
import 'package:dio/dio.dart';
/// Mock Dio client for testing API interactions.
///
/// Allows registering canned responses for specific endpoints.
class MockApiClient {
MockApiClient() {
_dio = Dio(BaseOptions(baseUrl: 'https://test.api'))
..interceptors.add(_MockInterceptor(this));
}
late final Dio _dio;
final Map<String, _MockResponse> _responses = {};
final List<RequestOptions> _requests = [];
/// Get the mock Dio instance.
Dio get dio => _dio;
/// Get all recorded requests.
List<RequestOptions> get requests => List.unmodifiable(_requests);
/// Get the last request made.
RequestOptions? get lastRequest => _requests.isEmpty ? null : _requests.last;
/// Clear all recorded requests.
void clearRequests() => _requests.clear();
/// Register a mock response for an endpoint.
///
/// [method] is the HTTP method (GET, POST, PUT, DELETE).
/// [path] is the endpoint path (can include wildcards like /users/*).
/// [response] is the response data.
/// [statusCode] is the HTTP status code (default 200).
void when({
required String method,
required String path,
required dynamic response,
int statusCode = 200,
Map<String, dynamic>? headers,
}) {
final key = '${method.toUpperCase()}:$path';
_responses[key] = _MockResponse(
data: response,
statusCode: statusCode,
headers: headers ?? {},
);
}
/// Register a GET response.
void whenGet(String path, dynamic response, {int statusCode = 200}) {
when(method: 'GET', path: path, response: response, statusCode: statusCode);
}
/// Register a POST response.
void whenPost(String path, dynamic response, {int statusCode = 200}) {
when(method: 'POST', path: path, response: response, statusCode: statusCode);
}
/// Register a PUT response.
void whenPut(String path, dynamic response, {int statusCode = 200}) {
when(method: 'PUT', path: path, response: response, statusCode: statusCode);
}
/// Register a DELETE response.
void whenDelete(String path, dynamic response, {int statusCode = 200}) {
when(method: 'DELETE', path: path, response: response, statusCode: statusCode);
}
/// Register an error response.
void whenError({
required String method,
required String path,
required int statusCode,
String? message,
}) {
when(
method: method,
path: path,
response: {'error': message ?? 'Mock error'},
statusCode: statusCode,
);
}
/// Find a mock response for the given request.
_MockResponse? _findResponse(String method, String path) {
// Try exact match first
final exactKey = '${method.toUpperCase()}:$path';
if (_responses.containsKey(exactKey)) {
return _responses[exactKey];
}
// Try wildcard matches
for (final entry in _responses.entries) {
final pattern = entry.key;
if (_matchesPattern(pattern, exactKey)) {
return entry.value;
}
}
return null;
}
bool _matchesPattern(String pattern, String key) {
// Simple wildcard matching: /users/* matches /users/123
if (!pattern.contains('*')) return false;
final patternParts = pattern.split('/');
final keyParts = key.split('/');
if (patternParts.length != keyParts.length) return false;
for (var i = 0; i < patternParts.length; i++) {
if (patternParts[i] == '*') continue;
if (patternParts[i] != keyParts[i]) return false;
}
return true;
}
/// Reset all mock responses and requests.
void reset() {
_responses.clear();
_requests.clear();
}
}
class _MockResponse {
_MockResponse({
required this.data,
required this.statusCode,
required this.headers,
});
final dynamic data;
final int statusCode;
final Map<String, dynamic> headers;
}
class _MockInterceptor extends Interceptor {
_MockInterceptor(this._client);
final MockApiClient _client;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
_client._requests.add(options);
final response = _client._findResponse(options.method, options.path);
if (response != null) {
if (response.statusCode >= 200 && response.statusCode < 300) {
handler.resolve(Response(
requestOptions: options,
data: response.data,
statusCode: response.statusCode,
headers: Headers.fromMap(
response.headers.map((k, v) => MapEntry(k, [v.toString()])),
),
));
} else {
handler.reject(DioException(
requestOptions: options,
response: Response(
requestOptions: options,
data: response.data,
statusCode: response.statusCode,
),
type: DioExceptionType.badResponse,
));
}
} else {
// No mock registered - return 404
handler.reject(DioException(
requestOptions: options,
response: Response(
requestOptions: options,
data: {'error': 'No mock registered for ${options.method} ${options.path}'},
statusCode: 404,
),
type: DioExceptionType.badResponse,
message: 'No mock registered for ${options.method} ${options.path}',
));
}
}
}
+118
View File
@@ -0,0 +1,118 @@
import 'package:tatlock_ui/core/auth/auth_state.dart';
import 'package:tatlock_ui/core/auth/permissions.dart';
import 'package:tatlock_ui/core/auth/user_preferences.dart';
/// Mock authentication states for testing.
class MockAuth {
MockAuth._();
/// Unauthenticated state.
static const guest = AuthState();
/// Basic authenticated user with no special permissions.
static AuthState user({
String id = 'test-user-id',
String name = 'Test User',
String email = 'test@example.com',
String defaultRoom = 'front-hall',
String theme = 'system',
}) {
return AuthState(
isAuthenticated: true,
accessToken: 'mock-access-token-${DateTime.now().millisecondsSinceEpoch}',
refreshToken: 'mock-refresh-token',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
userId: id,
authentikId: 'authentik-$id',
userName: name,
userEmail: email,
roles: [],
preferences: UserPreferences(
defaultRoom: defaultRoom,
theme: theme,
),
);
}
/// Admin user with full permissions.
static AuthState admin({
String id = 'admin-user-id',
String name = 'Admin User',
String email = 'admin@example.com',
}) {
return AuthState(
isAuthenticated: true,
accessToken: 'mock-admin-token-${DateTime.now().millisecondsSinceEpoch}',
refreshToken: 'mock-admin-refresh-token',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
userId: id,
authentikId: 'authentik-$id',
userName: name,
userEmail: email,
roles: [
Role(
id: 'admin-role',
name: 'admin.general:admin',
domain: Domain.admin,
category: 'general',
action: Action.admin,
),
],
preferences: const UserPreferences(),
);
}
/// User with specific permissions.
static AuthState withPermissions({
String id = 'perm-user-id',
String name = 'Permission User',
String email = 'perm@example.com',
required List<Role> roles,
}) {
return AuthState(
isAuthenticated: true,
accessToken: 'mock-perm-token-${DateTime.now().millisecondsSinceEpoch}',
refreshToken: 'mock-perm-refresh-token',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
userId: id,
authentikId: 'authentik-$id',
userName: name,
userEmail: email,
roles: roles,
preferences: const UserPreferences(),
);
}
/// User with expired token (for refresh testing).
static AuthState expired({
String id = 'expired-user-id',
String name = 'Expired User',
}) {
return AuthState(
isAuthenticated: true,
accessToken: 'mock-expired-token',
refreshToken: 'mock-refresh-token',
expiresAt: DateTime.now().subtract(const Duration(hours: 1)),
userId: id,
userName: name,
roles: [],
preferences: const UserPreferences(),
);
}
/// Create a role for testing.
static Role role({
String id = 'test-role',
required Domain domain,
String category = 'general',
required Action action,
}) {
return Role(
id: id,
name: '${domain.value}.$category:${action.name}',
domain: domain,
category: category,
action: action,
);
}
}
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// Standard screen sizes for responsive testing.
abstract class ScreenSizes {
// Desktop sizes
static const desktopLarge = Size(1920, 1080);
static const desktop = Size(1440, 900);
static const desktopSmall = Size(1280, 800);
// Tablet sizes
static const tabletLandscape = Size(1024, 768);
static const tabletPortrait = Size(768, 1024);
// Mobile sizes
static const mobileLarge = Size(428, 926); // iPhone 14 Pro Max
static const mobile = Size(375, 812); // iPhone X/11/12/13
static const mobileSmall = Size(320, 568); // iPhone SE
/// All sizes for comprehensive testing.
static const all = [
desktopLarge,
desktop,
tabletLandscape,
tabletPortrait,
mobileLarge,
mobile,
];
/// Desktop-only sizes.
static const desktopSizes = [desktopLarge, desktop, desktopSmall];
/// Tablet sizes.
static const tabletSizes = [tabletLandscape, tabletPortrait];
/// Mobile sizes.
static const mobileSizes = [mobileLarge, mobile, mobileSmall];
}
/// Extension to easily set screen size in widget tests.
extension ScreenSizeExtension on WidgetTester {
/// Sets the screen size for the test.
///
/// Remember to call `addTearDown(() => view.resetPhysicalSize())` after.
void setScreenSize(Size size) {
view.physicalSize = size;
view.devicePixelRatio = 1.0;
}
/// Resets the screen size to default.
void resetScreenSize() {
view.resetPhysicalSize();
}
}
/// Configures a widget test for a specific screen size with overflow suppression.
///
/// Usage:
/// ```dart
/// testWidgets('renders on mobile', (tester) async {
/// configureScreenSize(tester, ScreenSizes.mobile);
/// // ... test code
/// });
/// ```
void configureScreenSize(WidgetTester tester, Size size) {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
// Suppress overflow errors for responsive tests
final originalOnError = FlutterError.onError;
FlutterError.onError = (details) {
if (details.exceptionAsString().contains('overflowed')) {
return; // Ignore overflow errors
}
originalOnError?.call(details);
};
addTearDown(() => FlutterError.onError = originalOnError);
}
/// Returns a descriptive name for a screen size.
String screenSizeName(Size size) {
if (size == ScreenSizes.desktopLarge) return 'desktop-large';
if (size == ScreenSizes.desktop) return 'desktop';
if (size == ScreenSizes.desktopSmall) return 'desktop-small';
if (size == ScreenSizes.tabletLandscape) return 'tablet-landscape';
if (size == ScreenSizes.tabletPortrait) return 'tablet-portrait';
if (size == ScreenSizes.mobileLarge) return 'mobile-large';
if (size == ScreenSizes.mobile) return 'mobile';
if (size == ScreenSizes.mobileSmall) return 'mobile-small';
return '${size.width.toInt()}x${size.height.toInt()}';
}
+298
View File
@@ -0,0 +1,298 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tatlock_ui/core/api/api_client.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/auth/auth_state.dart';
import 'package:tatlock_ui/core/theme/theme_provider.dart';
import 'package:tatlock_ui/routing/room_registry.dart';
// Import room routers for registry initialization
import 'package:tatlock_ui/features/front_hall/router.dart';
import 'package:tatlock_ui/features/control_room/router.dart';
import 'package:tatlock_ui/features/security/router.dart';
import 'package:tatlock_ui/features/parlor/router.dart';
import 'package:tatlock_ui/features/media_room/router.dart';
import 'fixtures.dart';
import 'mock_api_client.dart';
import 'mock_auth.dart';
export 'fixtures.dart';
export 'mock_api_client.dart';
export 'mock_auth.dart';
/// Test harness for widget and integration tests.
///
/// Provides a consistent testing environment with:
/// - Mock authentication state
/// - Mock API client with fixtures
/// - Room registry initialization
/// - Provider overrides
///
/// Usage:
/// ```dart
/// void main() {
/// final harness = TestHarness();
///
/// setUp(() => harness.setUp());
/// tearDown(() => harness.tearDown());
///
/// testWidgets('example test', (tester) async {
/// harness.givenAuthenticatedUser();
/// harness.givenContainers(Fixtures.containers);
///
/// await tester.pumpWidget(harness.wrap(MyWidget()));
/// // assertions...
/// });
/// }
/// ```
class TestHarness {
TestHarness();
/// Mock API client for registering responses.
final api = MockApiClient();
/// Current auth state for the test.
AuthState _authState = MockAuth.guest;
/// Current theme mode for the test.
ThemeMode _themeMode = ThemeMode.light;
/// Set up the test environment.
///
/// Call this in setUp() for each test.
Future<void> setUp() async {
// Initialize SharedPreferences with empty values
SharedPreferences.setMockInitialValues({});
// Initialize room registry if needed
_initializeRoomRegistry();
// Reset state
_authState = MockAuth.guest;
_themeMode = ThemeMode.light;
api.reset();
}
/// Tear down the test environment.
///
/// Call this in tearDown() for each test.
Future<void> tearDown() async {
api.reset();
}
void _initializeRoomRegistry() {
if (roomRegistry.all.isEmpty) {
registerFrontHall();
registerControlRoom();
registerSecurity();
registerParlor();
registerMediaRoom();
}
}
// ============================================================
// Given - Set up preconditions
// ============================================================
/// Set up an unauthenticated (guest) user.
void givenGuestUser() {
_authState = MockAuth.guest;
}
/// Set up an authenticated user.
void givenAuthenticatedUser({
String name = 'Test User',
String email = 'test@example.com',
String defaultRoom = 'front-hall',
}) {
_authState = MockAuth.user(
name: name,
email: email,
defaultRoom: defaultRoom,
);
}
/// Set up an admin user.
void givenAdminUser({
String name = 'Admin User',
String email = 'admin@example.com',
}) {
_authState = MockAuth.admin(name: name, email: email);
}
/// Set up a custom auth state.
void givenAuthState(AuthState state) {
_authState = state;
}
/// Set up mock containers response.
void givenContainers([List<Map<String, dynamic>>? containers]) {
api.whenGet(
'/infrastructure/containers',
containers ?? Fixtures.containers,
);
}
/// Set up mock domains response (for ProxyHostsPage).
void givenDomains([List<Map<String, dynamic>>? domains]) {
api.whenGet(
'/infrastructure/domains',
domains ?? Fixtures.domains,
);
}
/// Set up mock proxy hosts response (raw NPM format).
void givenProxyHosts([List<Map<String, dynamic>>? hosts]) {
api.whenGet(
'/infrastructure/npm/proxy-hosts',
hosts ?? Fixtures.proxyHosts,
);
}
/// Set up mock users response.
void givenUsers([List<Map<String, dynamic>>? users]) {
api.whenGet('/auth/users', users ?? Fixtures.users);
}
/// Set up mock groups response.
void givenGroups([List<Map<String, dynamic>>? groups]) {
api.whenGet('/auth/groups', groups ?? Fixtures.groups);
}
/// Set up mock quick links response.
void givenQuickLinks([List<Map<String, dynamic>>? links]) {
api.whenGet('/dashboard/quick-links', links ?? Fixtures.quickLinks);
}
/// Set up mock system stats response.
void givenSystemStats([Map<String, dynamic>? stats]) {
api.whenGet('/tools/system/stats', stats ?? Fixtures.systemStats);
}
/// Set up mock environment response.
void givenEnvironment([Map<String, dynamic>? environment]) {
api.whenGet('/tools/environment', environment ?? Fixtures.environment);
}
/// Set up mock environment response without air quality.
void givenEnvironmentNoAirQuality() {
api.whenGet('/tools/environment', Fixtures.environmentNoAirQuality);
}
/// Set up theme mode.
void givenThemeMode(ThemeMode mode) {
_themeMode = mode;
}
/// Set up an API error response.
void givenApiError({
required String method,
required String path,
int statusCode = 500,
String? message,
}) {
api.whenError(
method: method,
path: path,
statusCode: statusCode,
message: message,
);
}
// ============================================================
// Wrap - Create test widget with providers
// ============================================================
/// Wrap a widget with the test harness providers.
///
/// This sets up:
/// - ProviderScope with auth, theme, and API overrides
/// - MaterialApp with theme
/// - Scaffold for proper widget context
Widget wrap(Widget child, {bool useScaffold = true}) {
final content = useScaffold ? Scaffold(body: child) : child;
return ProviderScope(
overrides: [
// Override auth provider to return our mock state
authProvider.overrideWith(() => _MockAuthNotifier(_authState)),
// Override theme provider
themeProvider.overrideWith(() => _MockThemeNotifier(_themeMode)),
// Override API client to use mock
coreApiClientProvider.overrideWithValue(api.dio),
],
child: MaterialApp(
theme: ThemeData.light(useMaterial3: true),
darkTheme: ThemeData.dark(useMaterial3: true),
themeMode: _themeMode,
home: content,
),
);
}
/// Wrap with a custom route for navigation testing.
Widget wrapWithRouter(Widget child) {
return ProviderScope(
overrides: [
authProvider.overrideWith(() => _MockAuthNotifier(_authState)),
themeProvider.overrideWith(() => _MockThemeNotifier(_themeMode)),
coreApiClientProvider.overrideWithValue(api.dio),
],
child: MaterialApp(
theme: ThemeData.light(useMaterial3: true),
darkTheme: ThemeData.dark(useMaterial3: true),
themeMode: _themeMode,
home: child,
),
);
}
// ============================================================
// Verify - Check assertions
// ============================================================
/// Verify that a specific API endpoint was called.
void verifyApiCalled(String method, String path) {
final called = api.requests.any(
(r) => r.method.toUpperCase() == method.toUpperCase() && r.path == path,
);
expect(called, isTrue, reason: 'Expected $method $path to be called');
}
/// Verify that no API calls were made.
void verifyNoApiCalls() {
expect(api.requests, isEmpty, reason: 'Expected no API calls');
}
/// Get the request body of the last API call.
dynamic getLastRequestBody() {
return api.lastRequest?.data;
}
}
/// Mock auth notifier that returns a fixed state.
class _MockAuthNotifier extends AuthNotifier {
_MockAuthNotifier(this._state);
final AuthState _state;
@override
Future<AuthState> build() async => _state;
}
/// Mock theme notifier that returns a fixed theme.
class _MockThemeNotifier extends ThemeNotifier {
_MockThemeNotifier(this._mode);
final ThemeMode _mode;
@override
ThemeSetting build() {
return switch (_mode) {
ThemeMode.light => ThemeSetting.light,
ThemeMode.dark => ThemeSetting.dark,
ThemeMode.system => ThemeSetting.system,
};
}
}
+204
View File
@@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/routing/room_registry.dart';
import '../harness/test_harness.dart';
void main() {
final harness = TestHarness();
setUp(() => harness.setUp());
tearDown(() => harness.tearDown());
group('RoomDefinition', () {
test('creates room with all required fields', () {
final room = RoomDefinition(
id: 'test-room',
label: 'Test Room',
icon: Icons.home_outlined,
selectedIcon: Icons.home,
defaultRoute: '/test-room',
routes: () => [],
);
expect(room.id, 'test-room');
expect(room.label, 'Test Room');
expect(room.icon, Icons.home_outlined);
expect(room.selectedIcon, Icons.home);
expect(room.defaultRoute, '/test-room');
expect(room.requiredPermissions, isEmpty);
});
test('creates room with permissions', () {
final room = RoomDefinition(
id: 'admin-room',
label: 'Admin Room',
icon: Icons.admin_panel_settings_outlined,
selectedIcon: Icons.admin_panel_settings,
defaultRoute: '/admin',
routes: () => [],
requiredPermissions: ['admin:access', 'admin:read'],
);
expect(room.requiredPermissions.length, 2);
expect(room.requiredPermissions, contains('admin:access'));
});
test('routes function returns RouteBase list', () {
final room = RoomDefinition(
id: 'test-room',
label: 'Test Room',
icon: Icons.home_outlined,
selectedIcon: Icons.home,
defaultRoute: '/test-room',
routes: () => [
GoRoute(
path: '/test-room',
builder: (context, state) => const SizedBox(),
),
],
);
final routes = room.routes();
expect(routes.length, 1);
expect(routes.first, isA<GoRoute>());
});
});
group('RoomRegistry', () {
test('all returns registered rooms', () {
// Rooms are registered by harness.setUp()
final rooms = roomRegistry.all;
expect(rooms, isNotEmpty);
});
test('byId returns room for valid ID', () {
final room = roomRegistry.byId('front-hall');
expect(room, isNotNull);
expect(room!.label, 'Front Hall');
});
test('byId returns null for invalid ID', () {
final room = roomRegistry.byId('non-existent-room');
expect(room, isNull);
});
test('indexOfId returns correct index', () {
final index = roomRegistry.indexOfId('front-hall');
expect(index, 0);
final controlRoomIndex = roomRegistry.indexOfId('control-room');
expect(controlRoomIndex, 1);
});
test('indexOfId returns -1 for invalid ID', () {
final index = roomRegistry.indexOfId('non-existent');
expect(index, -1);
});
test('indexOfRoute matches exact route', () {
final index = roomRegistry.indexOfRoute('/front-hall');
expect(index, 0);
});
test('indexOfRoute matches sub-routes', () {
final index = roomRegistry.indexOfRoute('/control-room/containers');
expect(index, 1);
final securityIndex = roomRegistry.indexOfRoute('/security/users');
expect(securityIndex, 2);
});
test('indexOfRoute returns 0 for unknown route', () {
final index = roomRegistry.indexOfRoute('/unknown-route');
expect(index, 0); // Defaults to first room
});
test('allRoutes returns routes from all rooms', () {
final routes = roomRegistry.allRoutes();
expect(routes, isNotEmpty);
// Should have routes from all registered rooms
});
test('accessibleTo filters by permissions', () {
// All current rooms have no required permissions
final accessible = roomRegistry.accessibleTo({});
expect(accessible.length, roomRegistry.all.length);
});
test('accessibleTo returns all rooms for empty permissions when no rooms require permissions', () {
final accessible = roomRegistry.accessibleTo({'some:permission'});
// All rooms should be accessible since none require permissions
expect(accessible.length, roomRegistry.all.length);
});
});
group('Room Registration Order', () {
test('front-hall is first', () {
expect(roomRegistry.all[0].id, 'front-hall');
});
test('control-room is second', () {
expect(roomRegistry.all[1].id, 'control-room');
});
test('security is third', () {
expect(roomRegistry.all[2].id, 'security');
});
test('parlor is fourth', () {
expect(roomRegistry.all[3].id, 'parlor');
});
test('media-room is fifth', () {
expect(roomRegistry.all[4].id, 'media-room');
});
});
group('Room Default Routes', () {
test('front-hall default route is /front-hall', () {
final room = roomRegistry.byId('front-hall');
expect(room?.defaultRoute, '/front-hall');
});
test('control-room default route is /control-room/containers', () {
final room = roomRegistry.byId('control-room');
expect(room?.defaultRoute, '/control-room/containers');
});
test('security default route is /security/users', () {
final room = roomRegistry.byId('security');
expect(room?.defaultRoute, '/security/users');
});
test('parlor default route is /parlor', () {
final room = roomRegistry.byId('parlor');
expect(room?.defaultRoute, '/parlor');
});
test('media-room default route is /media-room', () {
final room = roomRegistry.byId('media-room');
expect(room?.defaultRoute, '/media-room');
});
});
group('Room Icons', () {
test('each room has distinct icons', () {
final rooms = roomRegistry.all;
final icons = rooms.map((r) => r.icon).toSet();
final selectedIcons = rooms.map((r) => r.selectedIcon).toSet();
// All icons should be unique
expect(icons.length, rooms.length);
expect(selectedIcons.length, rooms.length);
});
test('icon and selectedIcon are different for each room', () {
for (final room in roomRegistry.all) {
expect(room.icon, isNot(equals(room.selectedIcon)),
reason: '${room.label} should have different icon and selectedIcon');
}
});
});
}
@@ -0,0 +1,410 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tatlock_ui/shared/components/data_grid/data_grid_column.dart';
import 'package:tatlock_ui/shared/components/data_grid/data_grid_config.dart';
import 'package:tatlock_ui/shared/components/data_grid/data_grid_state.dart';
import '../../../harness/test_harness.dart';
/// Test data class for DataGrid tests.
class TestItem {
const TestItem({
required this.id,
required this.name,
required this.value,
this.isActive = true,
});
final String id;
final String name;
final int value;
final bool isActive;
}
void main() {
final harness = TestHarness();
setUp(() => harness.setUp());
tearDown(() => harness.tearDown());
group('DataGridColumn', () {
test('creates column with required fields', () {
final column = DataGridColumn<TestItem>(
id: 'name',
header: 'Name',
valueBuilder: (item) => item.name,
);
expect(column.id, 'name');
expect(column.header, 'Name');
expect(column.sortable, isFalse);
expect(column.searchable, isFalse);
});
test('creates sortable column', () {
final column = DataGridColumn<TestItem>(
id: 'name',
header: 'Name',
valueBuilder: (item) => item.name,
sortable: true,
);
expect(column.sortable, isTrue);
});
test('creates searchable column', () {
final column = DataGridColumn<TestItem>(
id: 'name',
header: 'Name',
valueBuilder: (item) => item.name,
searchable: true,
);
expect(column.searchable, isTrue);
});
test('valueBuilder extracts correct value', () {
final column = DataGridColumn<TestItem>(
id: 'name',
header: 'Name',
valueBuilder: (item) => item.name,
);
final item = TestItem(id: '1', name: 'Test Item', value: 42);
expect(column.valueBuilder(item), 'Test Item');
});
test('supports fixed width', () {
final column = DataGridColumn<TestItem>(
id: 'id',
header: 'ID',
valueBuilder: (item) => item.id,
width: const DataGridColumnWidth.fixed(100),
);
expect(column.width, isA<GridFixedWidth>());
expect((column.width as GridFixedWidth).width, 100);
});
test('supports flex width', () {
final column = DataGridColumn<TestItem>(
id: 'name',
header: 'Name',
valueBuilder: (item) => item.name,
width: const DataGridColumnWidth.flex(2),
);
expect(column.width, isA<GridFlexWidth>());
expect((column.width as GridFlexWidth).flex, 2);
});
test('default width is flex(1)', () {
final column = DataGridColumn<TestItem>(
id: 'name',
header: 'Name',
valueBuilder: (item) => item.name,
);
expect(column.width, isA<GridFlexWidth>());
expect((column.width as GridFlexWidth).flex, 1);
});
test('supports custom alignment', () {
final column = DataGridColumn<TestItem>(
id: 'value',
header: 'Value',
valueBuilder: (item) => item.value.toString(),
alignment: DataGridColumnAlignment.end,
);
expect(column.alignment, DataGridColumnAlignment.end);
expect(column.textAlign, TextAlign.end);
expect(column.crossAxisAlignment, CrossAxisAlignment.end);
});
test('effectiveSortField defaults to lowercase header', () {
final column = DataGridColumn<TestItem>(
id: 'name',
header: 'Full Name',
valueBuilder: (item) => item.name,
);
expect(column.effectiveSortField, 'full name');
});
test('effectiveSortField uses sortField when provided', () {
final column = DataGridColumn<TestItem>(
id: 'name',
header: 'Full Name',
valueBuilder: (item) => item.name,
sortField: 'name',
);
expect(column.effectiveSortField, 'name');
});
});
group('DataGridConfig', () {
test('creates config with columns', () {
final config = DataGridConfig<TestItem>(
columns: [
DataGridColumn<TestItem>(
id: 'id',
header: 'ID',
valueBuilder: (item) => item.id,
),
DataGridColumn<TestItem>(
id: 'name',
header: 'Name',
valueBuilder: (item) => item.name,
),
],
);
expect(config.columns.length, 2);
});
test('enableSearch defaults to false', () {
final config = DataGridConfig<TestItem>(
columns: [],
);
expect(config.enableSearch, isFalse);
});
test('enables search when specified', () {
final config = DataGridConfig<TestItem>(
columns: [
DataGridColumn<TestItem>(
id: 'name',
header: 'Name',
valueBuilder: (item) => item.name,
searchable: true,
),
],
enableSearch: true,
);
expect(config.enableSearch, isTrue);
});
test('supports custom search hint', () {
final config = DataGridConfig<TestItem>(
columns: [],
enableSearch: true,
searchHint: 'Search items...',
);
expect(config.searchHint, 'Search items...');
});
test('supports row selection mode', () {
final config = DataGridConfig<TestItem>(
columns: [],
rowsSelectable: true,
);
expect(config.rowsSelectable, isTrue);
});
test('visibleColumns filters hidden columns', () {
final config = DataGridConfig<TestItem>(
columns: [
DataGridColumn<TestItem>(
id: 'id',
header: 'ID',
valueBuilder: (item) => item.id,
visible: false,
),
DataGridColumn<TestItem>(
id: 'name',
header: 'Name',
valueBuilder: (item) => item.name,
),
],
);
expect(config.visibleColumns.length, 1);
expect(config.visibleColumns.first.id, 'name');
});
test('searchableColumnIndices returns correct indices', () {
final config = DataGridConfig<TestItem>(
columns: [
DataGridColumn<TestItem>(
id: 'id',
header: 'ID',
valueBuilder: (item) => item.id,
),
DataGridColumn<TestItem>(
id: 'name',
header: 'Name',
valueBuilder: (item) => item.name,
searchable: true,
),
DataGridColumn<TestItem>(
id: 'value',
header: 'Value',
valueBuilder: (item) => item.value.toString(),
searchable: true,
),
],
);
expect(config.searchableColumnIndices, [1, 2]);
});
});
group('DataGridState', () {
test('creates initial state with defaults', () {
const state = DataGridState<TestItem>();
expect(state.items, isEmpty);
expect(state.isLoading, isFalse);
expect(state.selectedIds, isEmpty);
expect(state.searchQuery, isEmpty);
expect(state.isInitialLoad, isTrue);
});
test('creates loading state', () {
const state = DataGridState<TestItem>(
isLoading: true,
);
expect(state.isLoading, isTrue);
});
test('stores items', () {
final items = [
const TestItem(id: '1', name: 'Item 1', value: 10),
const TestItem(id: '2', name: 'Item 2', value: 20),
];
final state = DataGridState<TestItem>(
items: items,
isLoading: false,
);
expect(state.items.length, 2);
expect(state.items[0].name, 'Item 1');
});
test('stores selected IDs', () {
const state = DataGridState<TestItem>(
selectedIds: {'1', '2', '3'},
);
expect(state.selectedIds.length, 3);
expect(state.selectedIds.contains('1'), isTrue);
});
test('stores search query', () {
const state = DataGridState<TestItem>(
searchQuery: 'test query',
);
expect(state.searchQuery, 'test query');
});
test('stores sort column index', () {
const state = DataGridState<TestItem>(
sortColumnIndex: 1,
sortDescending: true,
);
expect(state.sortColumnIndex, 1);
expect(state.sortDescending, isTrue);
});
test('stores error', () {
const state = DataGridState<TestItem>(
error: 'Something went wrong',
);
expect(state.error, 'Something went wrong');
});
test('stores pagination info', () {
const state = DataGridState<TestItem>(
currentPage: 2,
totalCount: 100,
);
expect(state.currentPage, 2);
expect(state.totalCount, 100);
});
});
group('DataGridColumnWidth', () {
test('fixed width creates GridFixedWidth', () {
const width = DataGridColumnWidth.fixed(150);
expect(width, isA<GridFixedWidth>());
expect((width as GridFixedWidth).width, 150);
});
test('flex width creates GridFlexWidth', () {
const width = DataGridColumnWidth.flex(2);
expect(width, isA<GridFlexWidth>());
expect((width as GridFlexWidth).flex, 2);
});
test('default flex is 1', () {
const width = DataGridColumnWidth.flex();
expect((width as GridFlexWidth).flex, 1);
});
test('fraction width creates GridFractionWidth', () {
const width = DataGridColumnWidth.fraction(0.5);
expect(width, isA<GridFractionWidth>());
expect((width as GridFractionWidth).fraction, 0.5);
});
});
group('DataGridColumnAlignment', () {
test('has start alignment', () {
expect(DataGridColumnAlignment.start, isNotNull);
});
test('has center alignment', () {
expect(DataGridColumnAlignment.center, isNotNull);
});
test('has end alignment', () {
expect(DataGridColumnAlignment.end, isNotNull);
});
});
group('DataGridDataMode', () {
test('all mode', () {
const mode = DataGridDataMode.all();
expect(mode, isA<AllDataMode>());
});
test('paginated mode with default page size', () {
const mode = DataGridDataMode.paginated();
expect(mode, isA<PaginatedDataMode>());
expect((mode as PaginatedDataMode).pageSize, 25);
});
test('paginated mode with custom page size', () {
const mode = DataGridDataMode.paginated(pageSize: 50);
expect((mode as PaginatedDataMode).pageSize, 50);
});
test('infinite mode with defaults', () {
const mode = DataGridDataMode.infinite();
expect(mode, isA<InfiniteDataMode>());
expect((mode as InfiniteDataMode).initialLoad, 50);
expect((mode as InfiniteDataMode).loadMoreThreshold, 10);
});
test('infinite mode with custom values', () {
const mode = DataGridDataMode.infinite(
initialLoad: 100,
loadMoreThreshold: 20,
);
expect((mode as InfiniteDataMode).initialLoad, 100);
expect((mode as InfiniteDataMode).loadMoreThreshold, 20);
});
});
}
+172 -11
View File
@@ -1,19 +1,180 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tatlock_ui/routing/room_registry.dart';
import 'package:tatlock_ui/app.dart';
import 'harness/test_harness.dart';
void main() {
testWidgets('App renders dashboard', (WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
child: TatlockApp(),
),
);
final harness = TestHarness();
// Wait for async operations
await tester.pumpAndSettle();
setUp(() => harness.setUp());
tearDown(() => harness.tearDown());
expect(find.text('Welcome to Tatlock'), findsOneWidget);
group('Room Registry', () {
test('is initialized with all rooms', () {
expect(roomRegistry.all.isNotEmpty, isTrue);
expect(roomRegistry.all.length, greaterThanOrEqualTo(5));
});
test('contains expected rooms', () {
final roomIds = roomRegistry.all.map((r) => r.id).toList();
expect(roomIds, contains('front-hall'));
expect(roomIds, contains('control-room'));
expect(roomIds, contains('security'));
expect(roomIds, contains('parlor'));
expect(roomIds, contains('media-room'));
});
test('returns correct index for route', () {
expect(roomRegistry.indexOfRoute('/front-hall'), 0);
expect(roomRegistry.indexOfRoute('/control-room/containers'), 1);
expect(roomRegistry.indexOfRoute('/security/users'), 2);
expect(roomRegistry.indexOfRoute('/parlor'), 3);
expect(roomRegistry.indexOfRoute('/media-room'), 4);
});
test('finds room by ID', () {
final frontHall = roomRegistry.byId('front-hall');
expect(frontHall, isNotNull);
expect(frontHall!.label, 'Front Hall');
expect(frontHall.defaultRoute, '/front-hall');
final controlRoom = roomRegistry.byId('control-room');
expect(controlRoom, isNotNull);
expect(controlRoom!.label, 'Control Room');
final nonExistent = roomRegistry.byId('non-existent');
expect(nonExistent, isNull);
});
test('returns default route for room', () {
final frontHall = roomRegistry.byId('front-hall');
expect(frontHall?.defaultRoute, '/front-hall');
final controlRoom = roomRegistry.byId('control-room');
expect(controlRoom?.defaultRoute, '/control-room/containers');
final security = roomRegistry.byId('security');
expect(security?.defaultRoute, '/security/users');
});
});
group('Test Harness', () {
test('provides guest auth state by default', () async {
harness.givenGuestUser();
expect(MockAuth.guest.isAuthenticated, isFalse);
});
test('provides authenticated user state', () {
harness.givenAuthenticatedUser(name: 'John Doe');
// The auth state is set internally, verified via wrap()
});
test('provides admin user state', () {
harness.givenAdminUser();
final adminState = MockAuth.admin();
expect(adminState.isGlobalAdmin, isTrue);
});
test('mock API client registers responses', () {
harness.givenContainers();
// Verify the mock is registered (would be called during widget tests)
});
});
group('Mock Auth', () {
test('guest state is not authenticated', () {
expect(MockAuth.guest.isAuthenticated, isFalse);
expect(MockAuth.guest.accessToken, isNull);
});
test('user state is authenticated', () {
final user = MockAuth.user();
expect(user.isAuthenticated, isTrue);
expect(user.accessToken, isNotNull);
expect(user.userName, 'Test User');
});
test('admin state has admin role', () {
final admin = MockAuth.admin();
expect(admin.isAuthenticated, isTrue);
expect(admin.isGlobalAdmin, isTrue);
expect(admin.roles.length, 1);
});
test('expired state has past expiry', () {
final expired = MockAuth.expired();
expect(expired.isTokenExpired, isTrue);
});
});
group('Mock API Client', () {
test('returns registered response', () async {
final client = MockApiClient();
client.whenGet('/test', {'message': 'hello'});
final response = await client.dio.get<Map<String, dynamic>>('/test');
expect(response.data?['message'], 'hello');
});
test('returns 404 for unregistered endpoint', () async {
final client = MockApiClient();
expect(
() => client.dio.get('/unknown'),
throwsA(isA<Exception>()),
);
});
test('records requests', () async {
final client = MockApiClient();
client.whenGet('/test1', {});
client.whenGet('/test2', {});
await client.dio.get('/test1');
await client.dio.get('/test2');
expect(client.requests.length, 2);
expect(client.requests[0].path, '/test1');
expect(client.requests[1].path, '/test2');
});
test('supports error responses', () async {
final client = MockApiClient();
client.whenError(method: 'GET', path: '/error', statusCode: 500);
expect(
() => client.dio.get('/error'),
throwsA(isA<Exception>()),
);
});
});
group('Fixtures', () {
test('provides container data', () {
expect(Fixtures.containers.length, 4);
expect(Fixtures.containers[0]['Names'], contains('/nginx-proxy'));
});
test('provides proxy host data', () {
expect(Fixtures.proxyHosts.length, 3);
expect(Fixtures.proxyHosts[0]['domain_names'], contains('home.schweitz.net'));
});
test('provides user data', () {
expect(Fixtures.users.length, 3);
expect(Fixtures.users[0]['name'], 'Admin User');
});
test('provides group data', () {
expect(Fixtures.groups.length, 3);
expect(Fixtures.groups[0]['name'], 'admins');
});
test('provides auth sync response', () {
final response = Fixtures.authSyncResponse(name: 'Test');
expect(response['name'], 'Test');
expect(response['preferences'], isNotNull);
});
});
}
+8 -6
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env dart
// Generates web/health.json from pubspec.yaml
// Generates web/health/health.json from pubspec.yaml
// Run: dart run tool/generate_health_json.dart
// ignore_for_file: avoid_print
import 'dart:convert';
import 'dart:io';
@@ -35,15 +37,15 @@ void main() {
'fullVersion': '$version+$buildNumber',
};
final webDir = Directory('web');
if (!webDir.existsSync()) {
webDir.createSync(recursive: true);
final healthDir = Directory('web/health');
if (!healthDir.existsSync()) {
healthDir.createSync(recursive: true);
}
final healthFile = File('web/health.json');
final healthFile = File('web/health/health.json');
healthFile.writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(health),
);
print('Generated web/health.json with version $version+$buildNumber');
print('Generated web/health/health.json with version $version+$buildNumber');
}
+8
View File
@@ -0,0 +1,8 @@
{
"status": "healthy",
"name": "tatlock_ui",
"title": "Tatlock - a Home Lab AI",
"version": "1.0.4",
"buildNumber": 1,
"fullVersion": "1.0.4+1"
}
+1 -1
View File
@@ -13,7 +13,7 @@
<h1 id="status">Loading...</h1>
<pre id="data"></pre>
<script>
fetch('/health.json')
fetch('./health.json')
.then(r => r.json())
.then(data => {
document.getElementById('status').textContent = data.status?.toUpperCase() || 'OK';
+15 -3
View File
@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
@@ -24,16 +25,27 @@
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Tatlock">
<meta name="theme-color" content="#040607">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="favicon.ico"/>
<link rel="icon" type="image/png" href="favicon.png"/>
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="icon" type="image/png" href="favicon.png" />
<title>Tatlock</title>
<link rel="manifest" href="manifest.json">
<style>
body {
margin: 0;
padding: 0;
/* Match Flutter's dark theme scaffold background (FlexScheme.aquaBlue) */
background-color: #111111;
}
</style>
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
</html>
+3 -3
View File
@@ -3,8 +3,8 @@
"short_name": "Tatlock",
"start_url": ".",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#1a1a2e",
"background_color": "#040607",
"theme_color": "#040607",
"description": "Tatlock Home Dashboard",
"orientation": "portrait-primary",
"prefer_related_applications": false,
@@ -32,4 +32,4 @@
"purpose": "maskable"
}
]
}
}