Compare commits

...
58 Commits
Author SHA1 Message Date
Jeroen SchweitzerandClaude Opus 4.5 5f3ff7f31a chore: release v1.1.6
Build and Push / build (push) Successful in 3m3s
Build and Push / release (push) Successful in 3s
Auth moved to standalone controller outside Riverpod:
- New AuthController runs in main() before runApp()
- Handles callback, token exchange, and /auth/sync before app starts
- If auth not ready (redirecting), app doesn't start at all
- AuthProvider now just loads stored tokens (no async OIDC logic)
- Fixes "Cannot use Ref after disposed" errors

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

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

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

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

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

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

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

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

Mobile still uses Flutter's OIDC flow as before.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also added service port reference table to AGENTS.md.

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

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

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

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

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

All 60 tests passing.

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

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

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

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

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

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

Docs:
- Added Freezed 3.x sealed class documentation

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

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

This simplifies deployment: just push a version tag.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:24:04 +01:00
Jeroen SchweitzerandClaude Opus 4.5 bdced8739c chore: release v0.3.3
Build and Push / build (release) Successful in 3m6s
Features:
- Health check endpoint for Portainer monitoring
- Local search filtering in DataGrid
- Container status badges reflect health (green/orange/blue)

Improvements:
- Standardized 56px header heights across panels
- Container grid parses Docker API format correctly
- Search bar styling improvements
- Status badges have consistent width

Fixes:
- Quick links persistence (link type, form refresh)
- Iframe switching closes existing content first
- ContainerState type conflict resolved

Branding:
- Updated favicon and icons with Tatlock bucket logo

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:12:18 +01:00
Jeroen SchweitzerandClaude Opus 4.5 0d15d3dbb5 feat(front-hall): wire quick links to Core API dashboard endpoint
Update quick links integration to use new /dashboard/quick-links API:
- Change API path from /front-hall/quick-links to /dashboard/quick-links
- Update field mappings: name→title, icon_name→icon, sort_order→position, is_active→is_visible
- Change ID type from String to int with conversion in provider
- Parse new response format {"links": [...], "total": N}
- Update reorder endpoint from PUT to POST with link_ids

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 13:17:16 +01:00
Jeroen SchweitzerandClaude Opus 4.5 1d68402067 feat(front-hall): add drag-to-reorder for quick links in settings mode
- Add ReorderableListView in settings mode for drag-to-reorder support
- Create _ReorderableLinkTile with drag handle indicator
- Show flat list with categories as subtitles when reordering
- Call reorder API when items are dragged to new position
- Keep grouped view with category headers in normal mode

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 10:18:01 +01:00
Jeroen SchweitzerandClaude Opus 4.5 327b4a1233 feat(front-hall): add link management with form and icon picker
- Create IconPicker widget with searchable grid of Material icons
- Create QuickLinkForm with full CRUD support (create, edit, delete)
- Update QuickLinkSettingsContent to use real form instead of placeholder
- Update QuickLinksPanel to use shared getIconData function
- Form includes name, URL, category, icon, type, and active toggle
- Validation for required fields and URL format

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 23:19:03 +01:00
Jeroen SchweitzerandClaude Opus 4.5 9c9ec472ef feat(dashboard): add gauge, weather, and air quality widgets
- Create shared GaugeWidget with circular progress and customizable colors
- Add GaugeRow for displaying multiple gauges in a responsive layout
- Create WeatherWidget with temperature, condition, and details
- Create AirQualityWidget with AQI levels and pollutant readings
- Update DashboardContent with System Stats gauges and Environment section
- Both widgets use mock data, ready for API integration

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 23:09:15 +01:00
Jeroen SchweitzerandClaude Opus 4.5 40db92d9d5 feat(front-hall): implement three-mode layout with quick links panel
Phase 2 of Organizr Migration - Front Hall restructure:

- Add FrontHallState provider with three modes (dashboard, iframe, settings)
- Create QuickLinksPanel widget with categorized links and overflow menus
- Add IframeView with platform-aware implementation (web iframe, mobile fallback)
- Extract DashboardContent from FrontHallPage
- Add QuickLinkSettingsContent placeholder for Phase 4 link editor
- Implement QuickLink entity and data layer with Core API datasource
- Add default quick links fallback when API unavailable
- Update UI_LAYOUT.md with Front Hall panel configurations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 22:24:20 +01:00
Jeroen Schweitzer 4f8d0f521f chore: bump version to 0.3.2
Build and Push / build (release) Successful in 4m15s
2026-01-02 14:00:15 +01:00
Jeroen Schweitzer 2b9f9273d6 fix: remove pubspec.lock from Dockerfile (not in repo) 2026-01-02 13:57:05 +01:00
Jeroen Schweitzer fb78b6224f chore: bump version to 0.3.1
Build and Push / build (release) Failing after 11s
2026-01-02 12:33:04 +01:00
Jeroen SchweitzerandClaude Opus 4.5 aa3fe916b7 build: add Docker and CI/CD pipeline for web deployment
- Add multi-stage Dockerfile (Flutter build → nginx:alpine)
- Add nginx.conf with SPA routing, gzip, and caching
- Add Gitea workflow for release-triggered builds
- Document release procedure in AGENTS.md

Deploys to port 8092, auto-updates via Watchtower.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 12:25:11 +01:00
Jeroen SchweitzerandClaude Opus 4.5 6989241ae7 chore: upgrade to riverpod 3.x and freezed 3.x
- Upgrade flutter_riverpod to 3.1.0, riverpod_annotation to 4.0.0
- Upgrade freezed to 3.2.3, freezed_annotation to 3.1.0
- Migrate freezed classes to use sealed keyword (freezed 3.x)
- Update provider naming (*NotifierProvider → *Provider)
- Add legacy.dart import for StateNotifierProvider compatibility
- Fix valueOrNull → value for AsyncValue
- Remove unused imports and fields
- Add sync from Authentik button to users/groups pages
- Suppress invalid_annotation_target warning in analysis_options

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 00:07:01 +01:00
Jeroen SchweitzerandClaude 0de79fba26 docs: add UI patterns guidance and update gitignore
- Add UI Patterns section to AGENTS.md (read docs/UI_LAYOUT.md before changes)
- Ignore .vscode/launch.json in gitignore

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-01 22:56:47 +01:00
Jeroen SchweitzerandClaude 88e8848953 refactor: convert Control Room views to DataGrid pattern
- Refactor containers_list_page to use shared DataGrid component
- Refactor proxy_hosts_page to use shared DataGrid component
- Add ContainerStatusBadge.fromString() factory for string state values
- Extend CoreApiDataSource to handle raw array API responses
- Simplify data classes (no freezed, just fromJson factories)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-01 22:56:37 +01:00
Jeroen SchweitzerandClaude a5a8e7ba88 feat: add Security room with Users and Groups sections
- Add Security room accessible from main navigation
- Implement Users list page with DataGrid (fetches from /auth/users)
- Implement Groups list page with DataGrid (fetches from /auth/groups)
- Move user/group management from Control Room to dedicated Security room
- Remove Authentik section from Control Room navigation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-01 22:55:37 +01:00
Jeroen SchweitzerandClaude Opus 4.5 2adb50b352 feat: implement NPM Proxy Hosts section with reusable entity patterns
Add full Proxy Hosts feature for Control Room:
- Data layer: datasource, model with custom JSON converters for NPM API quirks
- Domain layer: ProxyHost entity with SSL, caching, websocket support
- Presentation: list page, detail/edit page, form widget

Add reusable entity page patterns for Control Room sections:
- EntityPageScaffold, EntitySection, EntitySettingRow components
- EntityForm with create/view/edit mode support
- EntityPageModeMixin for consistent mode management
- CreateOnlyField for immutable-after-creation fields

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 15:02:06 +01:00
Jeroen SchweitzerandClaude Opus 4.5 67d2f777c8 test: add unit tests for Control Room data models
Add comprehensive test coverage for:
- ProxyHostModel: 37 tests for JSON converters and serialization
- ContainerModel: 44 tests for deserialization, entity conversion, state parsing
- StackModel: 55 tests for type/status parsing, timestamps, entity properties

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 15:00:04 +01:00
Jeroen Schweitzer 50f643dd90 add mcp tooling 2025-12-31 17:02:34 +01:00
Jeroen SchweitzerandClaude Opus 4.5 ae2bbcd58d feat: add NPM and Authentik sections to Control Room navigation
- Add ControlRoomNav enum with section grouping (Portainer, NPM, Authentik)
- Update NavPanel to support conditional section headers
- Rename NavSection to NavItem with optional section field
- Add routes for NPM: Proxy Hosts, Redirections, Streams, SSL Certificates
- Add routes for Authentik: Users, Groups, Applications
- Section headers auto-show when multiple sections exist

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 16:52:10 +01:00
Jeroen SchweitzerandClaude Opus 4.5 cbf6ca5338 docs: add Nav Panel section headers mockup
Document conditional section headers for grouping nav items:
- Single section: headers hidden
- Multiple sections: headers visible with left accent bar
- Route configuration driven data model
- Updated Control Room wireframes with current/future state

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 16:38:29 +01:00
Jeroen SchweitzerandClaude Opus 4.5 b189c3518b feat: add Nav Panel with URL-routed section navigation
- Add NavPanel widget for room-level section navigation
- Add Control Room feature router with URL routes:
  - /control-room/containers
  - /control-room/networks
  - /control-room/volumes
  - /control-room/images
- Sections: Containers, Networks, Volumes, Images
- Navigation updates URL and vice versa (deep-linkable)
- Remove redundant Stacks section (handled by filter panel)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 16:28:01 +01:00
Jeroen Schweitzer c2b487610c remove old instruction file 2025-12-31 16:20:07 +01:00
Jeroen SchweitzerandClaude Opus 4.5 865b69f960 fix: enable scrolling in CodeEditor widget
Wrap CodeField in SingleChildScrollView with minLines: 1 to prevent
overflow when YAML content exceeds available height.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 15:03:48 +01:00
Jeroen SchweitzerandClaude Opus 4.5 add960d60b chore: add project config files and assets
- Add Claude plans directory
- Add original logo art assets
- Add DevTools options
- Add Core API spec documentation
- Add iOS/macOS Podfiles
- Add logs/ to gitignore

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 14:57:45 +01:00
Jeroen SchweitzerandClaude Opus 4.5 285ebd9258 chore: add flutter_svg and highlight dependencies
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 14:55:11 +01:00
Jeroen SchweitzerandClaude Opus 4.5 282e81634a refactor: convert relative imports to package imports
Replace all relative imports (../../) with package imports
(package:tatlock_ui/) across 29 files for cleaner, more
maintainable import paths.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 14:54:59 +01:00
Jeroen SchweitzerandClaude Opus 4.5 948e104ce5 feat: add reusable panel system with FilterPanel widget
- Update UI_LAYOUT.md with comprehensive panel taxonomy
- Document Nav Panel, Filter Panel, Detail Panel, Chat Dock specs
- Add panel header behavior (bottom-docked for left panels)
- Add responsive breakpoints with panel folding summary
- Create PanelHeader widget with dockToBottom option
- Create FilterPanel widget for data filtering sidebars
- Refactor Control Room to use FilterPanel
- Remove external links from sidebar (cleanup)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 14:54:14 +01:00
Jeroen SchweitzerandClaude Opus 4.5 de739e8f7a feat: redesign header with logo bulge and room navigation
- Move room navigation from sidebar to top header bar as icons
- Add circular "bulge" extending below header for larger logo (120px)
- Logo centered in bulge with 20% circle below header line
- Profile dropdown with Settings and Logout options
- Header overlays content (Stack layout) instead of pushing it down
- Remove AppBar from FrontHallPage (provided by AppScaffold)
- Add transparent logo asset

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 14:52:40 +01:00
Jeroen SchweitzerandClaude Opus 4.5 5c09bac3d2 feat: add stack detail view with YAML and env vars editor
When a stack is selected, the right pane now shows a split view:
- Top: Stack editor with tabs for Compose YAML and Environment Variables
- Bottom: Docked compact container list for the selected stack

Includes Deploy button to redeploy stack with updated configuration.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 02:18:07 +01:00
Jeroen SchweitzerandClaude Opus 4.5 19f81c8749 chore: add VS Code launch configuration
Update .gitignore to track .vscode/launch.json while ignoring other
VS Code settings.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 02:03:30 +01:00
Jeroen SchweitzerandClaude Opus 4.5 3b89ed8c18 feat: add Control Room with containers and stacks management
- Add Control Room page with stacks sidebar and containers list
- Implement container actions (start/stop/restart) with snackbars
- Add container logs viewer dialog
- Add search/filter for both stacks and containers lists
- Add external links for Portainer and Netdata (url_launcher)
- Add VS Code launch configuration for Flutter web debugging

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 02:01:01 +01:00
jpmschweitzerandClaude Opus 4.5 dd6bcbdbda feat: LAN-first development with optional auth
- Default API URLs now use LAN IPs (192.168.86.149)
- Auth interceptor skips auth when using LAN endpoints
- Production builds override with --dart-define

Development: flutter run -d chrome (no auth needed on LAN)
Production:  flutter build web --dart-define=CORE_API_URL=https://api.schweitz.net

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 00:04:04 +01:00
148 changed files with 17889 additions and 397 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"mcp__chrome-devtools__take_screenshot",
"mcp__puppeteer__puppeteer_evaluate",
"mcp__chrome-devtools__navigate_page",
"mcp__chrome-devtools__take_snapshot"
]
}
}
+46
View File
@@ -0,0 +1,46 @@
name: Build and Push
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Create Gitea Release
run: |
curl -sf -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"tag_name": "${{ github.ref_name }}", "name": "Release ${{ github.ref_name }}", "body": "Automated release for ${{ github.ref_name }}"}' \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
build:
runs-on: ubuntu-latest
needs: release
steps:
- uses: actions/checkout@v4
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.schweitz.internal
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
git.schweitz.internal/jpmschweitzer/tatlock-ui:latest
git.schweitz.internal/jpmschweitzer/tatlock-ui:${{ github.ref_name }}
- name: Trigger Watchtower update
if: success()
run: |
curl -sf -H "Authorization: Bearer ${{ secrets.WATCHTOWER_TOKEN }}" \
http://watchtower:8080/v1/update
+9 -6
View File
@@ -12,20 +12,21 @@ pubspec.lock
*.freezed.dart
*.gr.dart
*.mocks.dart
lib/generated_plugin_registrant.dart
# Keep version.g.dart - it's generated but should be committed
# so CI/CD builds have version info without running the generator
# Other *.g.dart files (from json_serializable, etc.) are ignored
# All generated *.g.dart files (from json_serializable, riverpod, version_builder)
# These are regenerated by build_runner during CI/CD builds
lib/**/*.g.dart
!lib/version.g.dart
# Generated health.json (regenerated by tool/generate_health_json.dart during build)
web/health.json
# IDE
.idea/
*.iml
*.ipr
*.iws
.vscode/
.vscode/*
!.vscode/launch.json
*.swp
*.swo
*~
@@ -118,3 +119,5 @@ secrets/
# Uploads (reference images, not tracked)
uploads/
logs/
.vscode/launch.json
+40
View File
@@ -0,0 +1,40 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Flutter Web (Chrome)",
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"args": [
"-d",
"chrome",
"--web-port=8080"
]
},
{
"name": "Flutter Web (Chrome) - Profile",
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"flutterMode": "profile",
"args": [
"-d",
"chrome",
"--web-port=8080"
]
},
{
"name": "Flutter Web (Chrome) - Release",
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"flutterMode": "release",
"args": [
"-d",
"chrome",
"--web-port=8080"
]
}
]
}
+51 -7
View File
@@ -22,14 +22,20 @@ This document contains instructions and documentation references for AI assistan
* Related repos: , `core-api`, `tatlock`, `library-desk`, `scheduler`, `portainer-core`
### 🐳 Deployment & Infrastructure
**⚠️ IMPORTANT: Service Port Reference**
| Service | LAN Port | External URL | Notes |
|---------|----------|--------------|-------|
| **Core API** | 8083 | `api.schweitz.net` | FastAPI backend for this UI |
| **Tatlock API** | 8000 | `tatlock.schweitz.net` | Legacy Python API (Ollama proxy) |
| **Tatlock UI** | 9999 | `home.schweitz.net` | This Flutter app |
* **Full stack documentation**: Available in the `portainer-core` repo
* Access: `curl http://localhost:3002/jpmschweitzer/portainer-core/raw/branch/main/CONTAINERS.md`
* Access: `curl http://192.168.86.149:3002/jpmschweitzer/portainer-core/raw/branch/main/CONTAINERS.md`
* Contains: All service ports, URLs, Redis DB allocations, external domains
* **Tatlock deployment**:
* LAN: `http://192.168.86.149:8000`
* External: `tatlock.schweitz.net` (behind Authentik SSO)
* Redis DBs: 1 (memory), 6 (benchmarks)
* **Health check**: `curl http://192.168.86.149:8000/health`
* **Health checks**:
* Core API: `curl http://192.168.86.149:8083/health`
* Tatlock API: `curl http://192.168.86.149:8000/health`
### 🛡️ Git Discipline
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
@@ -42,7 +48,36 @@ This document contains instructions and documentation references for AI assistan
* Tag after updating `pubspec.yaml` version and CHANGELOG
* Push tags with `git push --tags`
### 🚀 Release Procedure
This project uses version-tag-based CI/CD. Releases trigger automated Docker builds and deployments.
**Release Steps:**
1. Update version in `pubspec.yaml` (bump major.minor.patch, not build number)
2. Update `CHANGELOG.md` with changes under `## [x.x.x] - YYYY-MM-DD`
3. Commit changes: `git commit -m "chore: release vX.X.X"`
4. Create git tag: `git tag vX.X.X`
5. Push with tags: `git push origin master --tags`
6. Create release in Gitea UI (git.schweitz.net → Releases → New Release)
* Select the tag
* Add release notes (can copy from CHANGELOG)
* **Publish** the release (this triggers CI/CD)
**What happens on release:**
* Gitea CI builds Flutter web app in Docker
* Image pushed to `git.schweitz.internal/jpmschweitzer/tatlock-ui:latest` and `:vX.X.X`
* Watchtower detects new image and auto-updates running container
* App available at `http://tower:8092` (and eventually `home.schweitz.net`)
**Rollback:**
* In Portainer, update image tag to previous version (e.g., `:v0.2.0`)
* Or: `docker pull git.schweitz.internal/jpmschweitzer/tatlock-ui:v0.2.0`
### 🧪 Testing Requirements
* **Always add tests for new code before committing.** No exceptions.
* Tests should cover the happy path and key edge cases.
* Run `flutter test` before committing to ensure all tests pass.
@@ -50,5 +85,14 @@ This document contains instructions and documentation references for AI assistan
* Code coverage should not decrease with new commits.
### 📝 Changelog Maintenance
* **Update `CHANGELOG.md`** with every user-facing change.
* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`.
* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`.
### 🎨 UI Patterns (MUST READ BEFORE CHANGES)
* **Before modifying the widget tree**, read `docs/UI_LAYOUT.md` to understand established patterns.
* Investigate existing implementations in the codebase before creating new components.
* **DO NOT** reinvent wheels - check if shared components already exist in `lib/shared/components/`.
* Look at similar features for reference patterns (e.g., how other list views, forms, or CRUD screens are built).
* Deviating from established patterns creates inconsistency and technical debt.
+203
View File
@@ -7,6 +7,209 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.1.6] - 2026-01-04
### Changed
- **Auth moved to standalone controller** - Handles OIDC completely outside Riverpod
- New `AuthController` runs in `main()` before `runApp()` - avoids provider lifecycle issues
- Handles callback, token exchange, and /auth/sync before app starts
- If auth not ready (redirecting), app doesn't start at all
- `AuthProvider` now just loads stored tokens (no async OIDC logic)
- Fixes "Cannot use Ref after disposed" errors from autoDispose providers
## [1.1.5] - 2026-01-04
### Fixed
- Race condition in OIDC callback: AuthProvider.build() was initiating silent OIDC while the callback page was processing, causing PKCE state to be cleared. Now skips silent OIDC when on `/callback` route.
## [1.1.4] - 2026-01-04
### Fixed
- Silent OIDC fallback: when `prompt=none` fails with `login_required` (no Authentik session), automatically fall back to regular OIDC flow to show login UI
## [1.1.3] - 2026-01-04
### Changed
- **Web auth uses silent OIDC with JWT Bearer tokens**
- Uses `prompt=none` to silently obtain JWT when Authentik session exists (via NPM forward auth)
- Flutter sends Bearer token to core-api instead of relying on forward auth cookies
- Fixes cross-subdomain cookie issues between home.schweitz.net and api.schweitz.net
- Callback now syncs with `/auth/sync` to get user profile and roles from core-api
- API interceptor now adds Bearer token on web (previously skipped)
## [1.1.2] - 2026-01-04
### Changed
- **Web auth simplified**: Skip Flutter OIDC on web - NPM forward auth handles it
- NPM authenticates at proxy level before app loads
- No more redundant OIDC redirect after NPM auth completes
- Fixes "Cannot use Ref after disposed" error from conflicting auth flows
- Mobile still uses Flutter OIDC flow
### Added
- Logout now redirects to Authentik to end SSO session
- Clears local tokens AND invalidates Authentik session
- Uses OIDC end_session_endpoint from discovery document
- Redirects back to app after Authentik logout completes
## [1.1.0] - 2026-01-04
### Changed
- **Dockerfile rebuild fix**: Added `flutter clean` before build to prevent stale cached artifacts
- VERSION build arg added for explicit cache busting
- Reordered build steps: clean → pub get → build_runner → health.json → flutter build
- Ensures deployed app always matches the version in health.json
### Fixed
- Replaced deprecated `dart:html` with `package:web` in iframe_view_web.dart
- Uses `web.HTMLIFrameElement` instead of `html.IFrameElement`
- Fixes deprecation warnings for Flutter 3.x web builds
## [1.0.12] - 2026-01-04
### Changed
- Removed login page - auth now auto-initiates from AppScaffold
- No more redirect to /login, just auto-start OIDC if not authenticated
- Shows loading screen during auth, error screen on failure with retry
- Seamless experience when Authentik session exists
### Removed
- Removed /login route and _LoginPage widget
## [1.0.11] - 2026-01-04
### Changed
- Web auth now extracts user info directly from JWT instead of syncing with core-api
- Eliminates CORS preflight issues with /auth/sync endpoint
- Decodes JWT claims (name, email, groups) client-side
- Bearer token will be used for API authentication
## [1.0.10] - 2026-01-04
### Fixed
- Fixed OIDC callback route being redirected to login before processing
- Moved callback route exception check BEFORE the auth redirect check in router
- This was preventing token exchange from ever happening
- Added favicon.ico to web root for proper browser tab icon display
## [1.0.9] - 2026-01-04
### Fixed
- Fixed OIDC callback Riverpod state modification error
- Deferred callback processing to `addPostFrameCallback` to avoid modifying state during widget build
## [1.0.8] - 2026-01-04
### Changed
- Switched from hash-based URLs (`/#/login`) to path-based URLs (`/login`)
- Required for OIDC callback to work correctly
- Uses conditional import to avoid breaking mobile/desktop builds
## [1.0.7] - 2026-01-04
### Fixed
- Fixed OIDC PKCE state loss across browser redirect
- Code verifier and state now persist in sessionStorage instead of memory
- Prevents "No code verifier" error after Authentik redirect
## [1.0.6] - 2026-01-04
### Fixed
- Fixed version generation in CI/CD builds
- Removed generated files (version.g.dart, health.json) from git tracking
- These files are now regenerated from pubspec.yaml during Docker build
## [1.0.5] - 2026-01-04
### Changed
- **Web authentication now uses OIDC** instead of NPM forward auth
- Added `OidcServiceWeb` for browser redirect-based Authorization Code flow with PKCE
- Added `/callback` route to handle Authentik redirect after login
- Login page now shows "Sign in with Authentik" button for both web and mobile
- Tokens stored in SharedPreferences and synced with core-api via `/auth/sync`
- Added web utility functions (`web_utils.dart`) with conditional imports for non-web platforms
- Added `crypto` and `web` packages for PKCE SHA-256 and browser API access
### Fixed
- Removed cross-origin cookie dependency that caused authentication failures on web
## [1.0.4] - 2026-01-03
### Added
- `health.json` generated at build time with app version info
- `health.html` now displays version, title, and status from health.json
## [1.0.3] - 2026-01-03
### Fixed
- Fixed auth endpoint path: `/auth/me``/auth/users/me`
## [1.0.2] - 2026-01-03
### Fixed
- Production Docker build now uses correct API URLs
- Added `--dart-define` flags for `CORE_API_URL` and `TATLOCK_API_URL`
- This enables `requiresAuth=true` so authentication is actually triggered
- Updated AGENTS.md with clear service port reference table
## [1.0.1] - 2026-01-03
### Fixed
- Web authentication now works correctly with NPM forward auth
- Dio client sends cookies with requests via `withCredentials: true`
- Added platform-specific adapters (native vs web) for proper cookie handling
## [1.0.0] - 2026-01-03
### Added
- **Authentication System** - Dual-flow auth supporting web (NPM forward auth) and mobile (OIDC)
- `AuthState` model with roles, permissions, and user preferences
- `AuthProvider` with automatic web session detection via `/auth/me`
- Permission system with Domain/Action enums and hierarchical access levels
- `PermissionGate` and `AdminGate` widgets for UI permission checks
- `Role` model with `{domain}.{category}:{action}` format parsing
- Route guards redirect unauthenticated users to login page
- Login page with Authentik OAuth redirect
- Mobile auth platform configuration (iOS URL schemes, Android AppAuth)
- Comprehensive auth test suite (60 unit tests)
### Changed
- API interceptor skips Bearer tokens on web (uses cookies via NPM forward auth)
- Router integrates auth state for protected route access
- **First stable release** - Core functionality complete for home lab dashboard
## [0.3.3] - 2026-01-03
### Added
- Health check endpoint (`/health.html`) for Portainer container monitoring
- Local search filtering in DataGrid (filters cached data client-side)
- Container status badges now reflect health status (green=healthy, orange=unhealthy)
- `ContainerHealth` enum for parsing Docker health status from status string
### Changed
- Standardized header bar heights to 56px across all panels
- Container grid now correctly parses Docker API JSON format (capitalized keys)
- Status column displays clean uptime (stripped health indicators)
- Status badges have consistent minimum width (90px)
- Search bar styling improved (36px height, visible border, proper background)
- Quick links now properly persist link type (iframe vs new tab)
- Iframe switching now closes existing content before loading new link
### Fixed
- Quick links form properly saves changes and refreshes panel
- `ContainerState` type conflict resolved (removed duplicate enum)
- Container data parsing handles null values safely
### Branding
- Updated favicon and icons with Tatlock bucket logo
- Updated manifest.json with Tatlock branding
## [0.3.2] - 2025-01-02
### Changed
- API defaults now use LAN IPs for local development (no auth required)
- Auth interceptor skips authentication when using LAN endpoints
## [0.3.0] - 2025-12-30
### Added
+55
View File
@@ -0,0 +1,55 @@
# Stage 1: Build Flutter web application
FROM ghcr.io/cirruslabs/flutter:stable AS builder
WORKDIR /app
# 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"
# 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 . .
# 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
RUN dart run tool/generate_health_json.dart
# Build for web release with production configuration
# These URLs enable authentication (requiresAuth = true when URL contains schweitz.net)
RUN flutter build web --release \
--dart-define=CORE_API_URL=https://api.schweitz.net \
--dart-define=TATLOCK_API_URL=https://tatlock.schweitz.net
# Stage 2: Serve with nginx
FROM nginx:alpine
# Install curl for healthcheck
RUN apk add --no-cache curl
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Copy built web app to nginx html directory
COPY --from=builder /app/build/web /usr/share/nginx/html
# Expose port 80
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:80/ || exit 1
# Run nginx in foreground
CMD ["nginx", "-g", "daemon off;"]
+6
View File
@@ -9,6 +9,12 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
errors:
# Freezed uses @JsonKey on constructor parameters which triggers this warning
# but is the correct pattern for freezed classes
invalid_annotation_target: ignore
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
+4 -1
View File
@@ -24,10 +24,13 @@ android {
applicationId = "net.schweitz.tatlock_ui"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
minSdk = 23 // Required for AppAuth
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
// flutter_appauth redirect scheme for OIDC callbacks
manifestPlaceholders["appAuthRedirectScheme"] = "net.schweitz.tatlock"
}
buildTypes {
Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 977 KiB

+3
View File
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
+162 -1
View File
@@ -142,7 +142,7 @@ part 'container_model.freezed.dart';
part 'container_model.g.dart';
@freezed
class ContainerModel with _$ContainerModel {
sealed class ContainerModel with _$ContainerModel {
const factory ContainerModel({
required String id,
required String name,
@@ -197,6 +197,139 @@ class ContainerRepositoryImpl implements ContainerRepository {
}
```
## Data Model Patterns
Models handle conversion between API JSON and domain entities. The pattern depends on whether the feature is **read-only** or **CRUD**.
### Read-Only Models
For data fetched from external systems (Docker, NPM, Portainer) where Flutter doesn't create/update records:
```dart
@freezed
sealed class ContainerModel with _$ContainerModel {
const factory ContainerModel({
required String id,
required String name,
required String status,
}) = _ContainerModel;
const ContainerModel._();
factory ContainerModel.fromJson(Map<String, dynamic> json) =>
_$ContainerModelFromJson(json);
/// Converts API response to domain entity.
Container toEntity() => Container(
id: id,
name: name,
status: ContainerStatus.values.byName(status),
);
}
```
**Only `toEntity()` is needed** - no `fromEntity()` or `toJson()` required.
### CRUD Models (Bidirectional)
For data that Flutter creates, updates, and deletes:
```dart
@freezed
sealed class QuickLinkModel with _$QuickLinkModel {
const factory QuickLinkModel({
@Default(0) int id,
required String title,
required String url,
String? icon,
String? category,
@Default(0) int position,
@JsonKey(name: 'is_visible') @Default(true) bool isVisible,
}) = _QuickLinkModel;
const QuickLinkModel._();
factory QuickLinkModel.fromJson(Map<String, dynamic> json) =>
_$QuickLinkModelFromJson(json);
/// Converts API response to domain entity.
QuickLink toEntity() => QuickLink(
id: id.toString(),
name: title,
url: url,
iconName: icon ?? 'link',
category: category,
sortOrder: position,
isActive: isVisible,
);
/// Creates model from domain entity for API requests.
factory QuickLinkModel.fromEntity(QuickLink entity) => QuickLinkModel(
id: int.tryParse(entity.id) ?? 0,
title: entity.name,
url: entity.url,
icon: entity.iconName,
category: entity.category,
position: entity.sortOrder,
isVisible: entity.isActive,
);
}
```
**Critical rules for CRUD models:**
1. **Always use generated `toJson()`** - Never write custom JSON methods that selectively include fields. The generated `toJson()` from freezed/json_serializable always includes ALL fields, which is the correct behavior.
2. **Never create custom `toCreateJson()` or similar** - This leads to bugs where fields are silently dropped.
3. **Use `fromEntity()` factory** - Maps domain entity fields to API field names.
### Form Update Pattern
When updating existing entities in forms, **always use `copyWith()`** to preserve existing data:
```dart
// ✅ Correct - preserves all existing fields
final updatedLink = existingLink.copyWith(
name: _nameController.text.trim(),
url: _urlController.text.trim(),
category: category.isEmpty ? null : category,
);
await actions.update(updatedLink);
// ❌ Wrong - loses existing data not in form
final newLink = QuickLink(
id: existingLink.id,
name: _nameController.text.trim(),
url: _urlController.text.trim(),
// Missing: sortOrder, other fields...
);
```
### Datasource Usage
```dart
// Create - convert entity to model, use toJson()
Future<QuickLink> createQuickLink(QuickLink link) async {
final model = QuickLinkModel.fromEntity(link);
final response = await _dio.post<Map<String, dynamic>>(
_basePath,
data: model.toJson(), // Always use generated toJson()
);
return QuickLinkModel.fromJson(response.data!).toEntity();
}
// Update - same pattern
Future<QuickLink> updateQuickLink(QuickLink link) async {
final model = QuickLinkModel.fromEntity(link);
final response = await _dio.put<Map<String, dynamic>>(
'$_basePath/${link.id}',
data: model.toJson(), // Always use generated toJson()
);
return QuickLinkModel.fromJson(response.data!).toEntity();
}
```
### Presentation Layer (Flutter + Riverpod)
The presentation layer contains UI code and state management.
@@ -331,6 +464,34 @@ Generated files:
- `*.freezed.dart` - Immutable classes
- `*.g.dart` - JSON serialization, Riverpod providers
### Freezed 3.x: Required `sealed class`
**Freezed 3.x requires the `sealed` keyword** on all classes with generated mixins. Without it, the generated code will fail to compile with errors about missing concrete implementations.
```dart
// ✅ Correct - Freezed 3.x
@freezed
sealed class UserModel with _$UserModel {
const factory UserModel({
required String id,
required String name,
}) = _UserModel;
factory UserModel.fromJson(Map<String, dynamic> json) =>
_$UserModelFromJson(json);
}
// ❌ Wrong - will fail to compile
@freezed
class UserModel with _$UserModel { // Missing `sealed`
const factory UserModel({...}) = _UserModel;
}
```
The `sealed` keyword was introduced in Dart 3.0 and allows the generated mixin `_$UserModel` to have abstract members that are implemented by the private `_UserModel` class.
**Always use `sealed class` with `@freezed`** - this applies to all models, entities, and state classes using Freezed.
## Import Rules
1. Never import from `data/` in `domain/`
+394 -52
View File
@@ -22,36 +22,286 @@ The UI uses a **tabbed room navigation** in the header rather than a traditional
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ HEADER (intrinsic height)
│ [Logo] [═══════ Room Tabs (scrollable) ═══════] [Notifications] [Profile]
│ HEADER BAR (56px, with logo bulge overlay)
│ [Logo] [Room Icons] ─────────────────────────────────── [Profile Menu]
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────┐ ┌───────────────────┐ │
│ │ │ │ │ │
│ │ MAIN CONTENT │ │ CHAT DOCK │ │
│ │ flex: 1 │ │ flex: 0 │ │
│ │ │ │ │ │
│ │ ┌─────────────┐ ┌────────────────────────────┐ │ │ Tatlock chat │ │
│ │ │ CONTEXT │ │ PRIMARY │ │ │ assistant, │ │
│ │ │ SIDEBAR │ │ CONTENT │ │ │ persistent │ │
│ │ │ │ │ │ │ across rooms │ │
│ │ flex: 0 │ │ flex: 1 │ │ │ │ │
│ │ intrinsic │ │ │ │
└─────────────┘ └────────────────────────────┘ │ │ │ │
│ │ │ │
│ └─────────────────────────────────────────────────┘ └───────────────────┘ │
│ ┌─────────────┐ ┌─────────────────────────────────┐ ┌───────────────────┐ │
│ │ NAV PANEL │ │ PRIMARY CONTENT │ │ CHAT DOCK │ │
│ │ │ │ │ │ │ │
│ │ Room-level │ │ ┌──────────┐ ┌──────────────┐ │ │ Tatlock AI │ │
│ │ navigation │ │ │ FILTER │ │ DATA GRID │ │ assistant │ │
│ │ (sections) │ PANEL │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ Persistent │ │
│ │ 280px fixed │ │ │ Optional │ │ flex: 1 │ │ across rooms │ │
│ │ │ │ │ 280px │ │ │ │ │ │ │
└─────────────┘ │ └──────────┘ └──────────────┘ │ │ 280px-33vw │ │
│ │ │ │
│ flex: 1 │ │ flex: 0 │ │
└─────────────────────────────────┘ └───────────────────┘
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Component Roles
---
## Panel Taxonomy
All panels share common styling patterns but serve different purposes.
### Panel Types
| Panel | Position | Purpose | Width | Content Alignment |
|-------|----------|---------|-------|-------------------|
| **Header Bar** | Top | Room nav, profile | 56px height | Logo in bulge, rest at bottom |
| **Nav Panel** | Left | Room-level section nav | 280px fixed | Header docked to bottom |
| **Filter Panel** | Left (inside content) | Data filtering/search | 280px fixed | Header docked to bottom |
| **Detail Panel** | Right (inside content) | Selected item details | 320-400px | Standard header |
| **Chat Dock** | Right | AI assistant | 280px-33vw | Standard header |
### Panel Header Behavior
All left-side panels (Nav Panel, Filter Panel) dock their header content to the **bottom** of the header area. This accommodates the logo bulge that overlays into their space.
```
┌─────────────────────────┐
│ │ ← Logo bulge overlays this space
│ (empty) │
│ │
│ [Icon] Title [Action]│ ← Content docked to bottom (8px margin)
├─────────────────────────┤
│ Panel content... │
```
Right-side panels (Detail Panel, Chat Dock) use standard vertically-centered headers since the logo bulge doesn't reach them.
### Nav Panel Sections
Nav items can be organized into **sections**. Section headers are **conditionally visible** - they only appear when multiple sections exist.
#### Single Section (headers hidden)
When all items belong to one section, no headers are shown:
```
┌─────────────────────────┐
│ [≡] Sections [···] │ ← Panel header
├─────────────────────────┤
│ ▸ Containers │
│ Networks │
│ Volumes │
│ Images │
│ │
└─────────────────────────┘
```
#### Multiple Sections (headers visible)
When items span multiple sections, section headers appear:
```
┌─────────────────────────┐
│ [≡] Sections [···] │ ← Panel header
├─────────────────────────┤
│ ▌Portainer │ ← Section header (subtle bg, left accent)
│ ▸ Containers │
│ Networks │
│ Volumes │
│ Images │
│ │
│ ▌NPM │ ← Section header
│ Proxy Hosts │
│ Redirections │
│ Streams │
│ │
│ ▌Authentik │ ← Section header
│ Users │
│ Groups │
│ Applications │
│ │
└─────────────────────────┘
```
#### Section Header Styling
```
┌─────────────────────────┐
│▌SECTION NAME │ ← Left accent bar (2px, primary color)
└─────────────────────────┘ ← Background: surfaceContainerHigh
← Text: labelSmall, onSurfaceVariant
← Padding: 8px horizontal, 6px vertical
← All caps, letter-spacing: 0.5
```
#### Data Model
```dart
/// NavItem model (in nav_panel.dart)
class NavItem {
final String id;
final String label;
final IconData icon;
final String? section; // null = ungrouped
}
// Section headers auto-generate from unique section values
// Visibility: items.map((i) => i.section).toSet().length > 1
```
#### Route Configuration Driven
Sections are defined in the feature's route configuration, not the widget:
```dart
/// In lib/features/control_room/router.dart
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 (future)
proxyHosts('proxy-hosts', 'Proxy Hosts', Icons.public, 'NPM'),
redirections('redirections', 'Redirections', Icons.alt_route, 'NPM'),
// Authentik section (future)
users('users', 'Users', Icons.people, 'Authentik'),
groups('groups', 'Groups', Icons.group_work, 'Authentik');
const ControlRoomNav(this.id, this.label, this.icon, this.section);
final String id;
final String label;
final IconData icon;
final String section;
NavItem toNavItem() => NavItem(
id: id,
label: label,
icon: icon,
section: section,
);
}
```
The NavPanel widget receives items and auto-generates section headers based on unique section values in the list. No section logic lives in the widget - it just renders what the route config provides.
---
## Panel Configurations by Room
### Front Hall
Front Hall uses a **three-mode content system** with a persistent QuickLinks panel:
| Mode | QuickLinks Panel | Content Area |
|------|------------------|--------------|
| **Dashboard** | Normal navigation | Dashboard widgets (stats, weather) |
| **Iframe** | Normal navigation | Embedded iframe content |
| **Settings** | Edit mode (back button, add new, overflow menus) | QuickLinkPage (EntityPageScaffold) |
#### Dashboard Mode (default)
```
┌──────────────┬─────────────────────────────────────────────────┬────────────┐
│ QUICK LINKS │ PRIMARY CONTENT │ CHAT DOCK │
│ │ │ (expanded) │
│ ▌HOME │ SYSTEM STATS │ │
│ Jellyfin │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │
│ WebUI │ │ CPU │ │ MEM │ │ DISK │ │ NET │ │ │
│ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │
│ ▌AMP │ │ │
│ AMP Home │ WEATHER AIR QUALITY │ │
│ │ ┌────────────────┐ ┌────────────────┐ │ │
│ ▌INFRA │ │ 72°F Sunny │ │ AQI: 42 Good │ │ │
│ Netdata │ └────────────────┘ └────────────────┘ │ │
│ │ │ │
│ [⚙ Settings] │ │ │
└──────────────┴─────────────────────────────────────────────────┴────────────┘
```
#### Iframe Mode
```
┌──────────────┬─────────────────────────────────────────────────┬────────────┐
│ QUICK LINKS │ ┌─────────────────────────────────────────────┐ │ CHAT DOCK │
│ │ │ Jellyfin [↗] [⟳] [✕] │ │ (collapsed)│
│ ▌HOME │ ├─────────────────────────────────────────────┤ │ │
│ ▸Jellyfin │ │ │ │ │
│ WebUI │ │ <iframe src="jellyfin.url"> │ │ │
│ │ │ │ │ │
│ ▌AMP │ │ │ │ │
│ AMP Home │ │ │ │ │
│ │ │ │ │ │
│ ▌INFRA │ │ │ │ │
│ Netdata │ │ │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ [⚙ Settings] │ │ │
└──────────────┴─────────────────────────────────────────────────┴────────────┘
```
#### Settings Mode
```
┌──────────────┬─────────────────────────────────────────────────┬────────────┐
│ [←] Links │ ┌─────────────────────────────────────────────┐ │ CHAT DOCK │
│ │ │ ← Back Edit Quick Link [Save] │ │ (collapsed)│
│ [+ Add New] │ ├─────────────────────────────────────────────┤ │ │
├──────────────┤ │ │ │ │
│ ▌HOME │ │ Name: [Jellyfin_________________] │ │ │
│ ▸Jellyfin ⋮ │ │ │ │ │
│ WebUI ⋮ │ │ URL: [https://jellyfin.schweitz...] │ │ │
│ │ │ │ │ │
│ ▌AMP │ │ Category: [Home ▼] │ │ │
│ AMP Home ⋮ │ │ │ │ │
│ │ │ Type: ◉ Iframe ○ New Tab │ │ │
│ ▌INFRA │ │ │ │ │
│ Netdata ⋮ │ │ Icon: [🎬 Pick...] │ │ │
│ Portainer⋮ │ │ │ │ │
│ │ │ Active: [✓] │ │ │
└──────────────┴─────────────────────────────────────────────────┴────────────┘
```
**Settings mode panel behavior:**
- Back button in header (exits settings mode → dashboard)
- "+ Add New Link" button at top
- Each item has overflow menu (Edit, Delete, Move Up/Down)
- Clicking item selects it for editing in content area
- Uses EntityPageScaffold pattern (no modals)
### Control Room
```
┌───────────┬──────────────────────────────────────────────────────┬──────────┐
│ NAV PANEL │ PRIMARY CONTENT │ CHAT │
│ │ ┌────────────┬────────────────────────────────────┐ │ DOCK │
│ Sections: │ │ FILTER │ DATA GRID │ │ │
│ • Contai. │ │ PANEL │ Container/Stack list │ │ (collap- │
│ • Stacks │ │ │ │ │ sed) │
│ • Network │ │ Stack list │ │ │ │
│ • Volumes │ │ + search │ │ │ │
└───────────┴──┴────────────┴────────────────────────────────────┴─┴──────────┘
```
### Parlor
```
┌───────────┬────────────────────────────────────────────────────┬────────────┐
│ NAV PANEL │ PRIMARY CONTENT │ CHAT DOCK │
│ │ Device controls, scenes │ (collapsed)│
│ Areas: │ │ │
│ • Living │ │ │
│ • Bedroom │ │ │
│ • Kitchen │ │ │
└───────────┴────────────────────────────────────────────────────┴────────────┘
```
---
## Component Roles
| Component | Flex | Description |
|-----------|------|-------------|
| **Header** | intrinsic | Logo, room tabs, action icons |
| **Main Content** | `flex: 1` | Fills remaining horizontal space |
| **Context Sidebar** | `flex: 0`, intrinsic | Room-specific navigation (Control Room, Parlor) |
| **Primary Content** | `flex: 1` | Room's main working area |
| **Header Bar** | intrinsic (56px) | Logo bulge, room icons, profile menu |
| **Nav Panel** | `flex: 0`, 280px | Room-level section navigation |
| **Filter Panel** | `flex: 0`, 280px | Data filtering within a section |
| **Primary Content** | `flex: 1` | Main working area |
| **Detail Panel** | `flex: 0`, 320-400px | Selected item details (optional) |
| **Chat Dock** | `flex: 0`, intrinsic | Tatlock assistant, collapsible |
---
@@ -61,55 +311,76 @@ The UI uses a **tabbed room navigation** in the header rather than a traditional
### Wide (>= 1200px)
```
┌────────────────────────────────────────────────────────┬────────────────────┐
[Sidebar] [══════════ Content ══════════] │ Chat (expanded)
└────────────────────────────────────────────────────────┴────────────────────┘
┌────────────────────────────────────────────────────────┬────────────────────┐
NAV PANEL │ [Filter Panel] [═══ Data Grid ═══] CHAT DOCK
│ (280px) │ (280px) (flex: 1) │ (expanded, 320px) │
└───────────┴─────────────────────────────────────────────┴────────────────────┘
```
- All panels visible
- Chat dock expanded by default on Front Hall
- Context sidebar visible with labels
- Nav panel visible with full labels
- Filter panel visible (where applicable)
- Full data grid columns
### Medium (>= 800px, < 1200px)
```
┌────────────────────────────────────────────────────────────────────────┬────┐
[Sidebar] [══════════════════ Content ══════════════════] │ 💬 │
└────────────────────────────────────────────────────────────────────────┴────┘
┌──────────────────────────────────────────────────────────────────────┬────┐
NAV PANEL │ [Filter Panel] [═══════════ Data Grid ═══════════] │ 💬 │
│ (280px) │ (280px) (flex: 1) │48px│
└───────────┴───────────────────────────────────────────────────────────┴────┘
```
- Chat dock collapsed to icon rail
- Click to expand as overlay
- Context sidebar still visible
- Chat dock collapsed to icon rail (48px)
- Click chat icon to expand as overlay
- Nav panel still visible
- Filter panel still visible
- Data grid may hide some columns
### Compact (>= 600px, < 800px)
```
┌───────────────────────────────────────────────────────────────────────┬────┐
[≡] [════════════════════ Content ════════════════════] │ 💬 │
└────────────────────────────────────────────────────────────────────────┴────┘
┌───────────────────────────────────────────────────────────────────────┬────┐
≡ │ [═══════════════════════ Content ═══════════════════════] │ 💬 │
│48px│ Filter panel becomes top bar or collapsible │48px│
└────┴───────────────────────────────────────────────────────────────────┴────┘
```
- Context sidebar becomes drawer (hamburger menu)
- Chat dock collapsed
- Reduced data grid columns
- Nav panel collapses to icon rail (48px), expands as drawer on tap
- Filter panel moves to top of content or becomes collapsible
- Chat dock remains collapsed (48px)
- Data grid shows essential columns only
### Mobile (< 600px)
```
┌────────────────────────────────────────────────────────────────────────┐
│ [≡] [Tabs scroll horizontally] [💬]
HEADER: [≡] [Room Icons scroll] ─────────────────────────────── [💬]
├────────────────────────────────────────────────────────────────────────┤
│ │
│ [══════════════════════ Content ══════════════════════]
│ [══════════════════════ Content ══════════════════════] │
│ Filter as expandable section at top │
│ │
└────────────────────────────────────────────────────────────────────────┘
```
- Single column layout
- Room tabs scroll horizontally
- Chat opens as bottom sheet
- Context sidebar as full-screen drawer
- Nav panel opens as full-screen drawer (hamburger menu)
- Filter panel becomes expandable section at top of content
- Chat opens as bottom sheet (60vh max)
- Data grid becomes card list or single-column table
---
## Panel Folding Summary
| Panel | Wide (≥1200) | Medium (≥800) | Compact (≥600) | Mobile (<600) |
|-------|--------------|---------------|----------------|---------------|
| **Nav Panel** | 280px visible | 280px visible | 48px rail → drawer | Hidden → drawer |
| **Filter Panel** | 280px visible | 280px visible | Collapsed/top bar | Expandable section |
| **Chat Dock** | 320px expanded | 48px rail → overlay | 48px rail → overlay | Icon → bottom sheet |
| **Detail Panel** | 320-400px slide-in | 320px slide-in | Full-width overlay | Full-screen |
---
@@ -168,29 +439,51 @@ Primary landing page. Chat dock expanded by default.
### Control Room (Infrastructure)
Context sidebar with section navigation. Chat collapsed.
Nav panel with grouped section navigation. Chat collapsed.
**Current state** (single grouper - headers hidden):
```
┌───────────────┬───────────────────────────────────────────────────────┬──────┐
│ │ │ │
│ SECTIONS │ CONTAINERS [Search] [+ New] │ 💬 │
│ │ ─────────────────────────────────────────────────── │ │
│ ▸ Containers │ ☑ NAME STATUS CPU MEM IMAGE ⋮ │ │
Stacks │ ☐ jellyfin ● Run 2.3% 1.2GB latest ⋮ │ │
Networks │ ☐ jellyfin ● Run 2.3% 1.2GB latest ⋮ │ │
│ Volumes │ ☐ ollama ● Run 45% 8.0GB 0.1.32 ⋮ │ │
│ Images │ ☐ postgres ● Run 1.1% 512MB 16-alp ⋮ │ │
│ │ ☐ redis ● Run 0.2% 128MB 7-alp ⋮ │ │
│ │ ─────────────────────────────────────────────────── │ │
│ │ Showing 5 of 40 < 1 2 3 4 5 > │ │
│ │ │ │
└───────────────┴───────────────────────────────────────────────────────┴──────┘
```
**Future state** (multiple groupers - headers visible):
```
┌───────────────┬───────────────────────────────────────────────────────┬──────┐
│ │ │ │
│ SECTIONS │ CONTAINERS [Search] [+ New] │ 💬 │
│ │ ─────────────────────────────────────────────────── │ │
│ ▌PORTAINER │ ☑ NAME STATUS CPU MEM IMAGE ⋮ │ │
│ ▸ Containers │ ☐ jellyfin ● Run 2.3% 1.2GB latest ⋮ │ │
│ Networks │ ☐ ollama ● Run 45% 8.0GB 0.1.32 ⋮ │ │
│ Volumes │ ☐ postgres ● Run 1.1% 512MB 16-alp ⋮ │ │
│ Images │ ☐ redis ● Run 0.2% 128MB 7-alp │ │
│ │ ☐ authentik ○ Stop - - 2024.2 │ │
──────────── │ ─────────────────────────────────────────────────── │ │
Netdata ↗ │ Showing 5 of 40 < 1 2 3 4 5 > │ │
Portainer↗ │ │ │
NPM │ │ │
│ Volumes │ ─────────────────────────────────────────────────── │ │
│ Images │ Showing 3 of 40 < 1 2 3 4 5 > │ │
│ │ │ │
▌NPM │ │ │
Proxy Hosts│ │ │
Redirects │ │ │
│ │ │
│ ▌AUTHENTIK │ │ │
│ Users │ │ │
│ Groups │ │ │
│ │ │ │
└───────────────┴───────────────────────────────────────────────────────┴──────┘
```
**Components:**
- Context Sidebar: Section nav + external links
- Nav Panel: Grouped section navigation (grouper headers conditional)
- Filter Panel: Stack/item filtering within section
- DataGrid: Container list with bulk actions
- Detail Panel: Opens on row selection (replaces grid or slides in)
@@ -275,6 +568,42 @@ The Tatlock chat assistant is **omnipresent** - accessible from any room.
## Flutter Implementation Notes
### Panel Widget Library
All panels are built from shared components in `lib/shared/layouts/widgets/`:
| Widget | File | Purpose |
|--------|------|---------|
| `PanelContainer` | `panel_container.dart` | Base container for all panels |
| `PanelHeader` | `panel_header.dart` | Configurable header (bottom-docked or centered) |
| `NavPanel` | `nav_panel.dart` | Left-side room navigation |
| `FilterPanel` | `filter_panel.dart` | Left-side data filtering |
| `DetailPanel` | `detail_panel.dart` | Right-side item details |
| `ChatDock` | `chat_dock.dart` | Right-side AI assistant |
### PanelHeader Configuration
```dart
/// Panel header with configurable content alignment.
class PanelHeader extends StatelessWidget {
const PanelHeader({
required this.title,
required this.icon,
this.actions,
this.dockToBottom = false, // true for left-side panels
});
}
```
**Left-side panels** (Nav, Filter) use `dockToBottom: true` to accommodate the logo bulge:
- Header height: 56px (matches app header)
- Content aligned to bottom with 8px margin
- Empty space at top allows logo bulge overlay
**Right-side panels** (Detail, Chat) use `dockToBottom: false`:
- Standard vertically-centered content
- No accommodation needed for logo bulge
### Recommended Widgets
| Concept | Flutter Widget |
@@ -296,6 +625,19 @@ abstract class Breakpoints {
}
```
### Panel Width Constants
```dart
abstract class PanelWidths {
static const double navPanel = 280;
static const double filterPanel = 280;
static const double detailPanel = 360;
static const double chatDockExpanded = 320;
static const double chatDockCollapsed = 48;
static const double railWidth = 48;
}
```
### Layout Builder Pattern
```dart
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+43
View File
@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
+13
View File
@@ -45,5 +45,18 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>net.schweitz.tatlock</string>
<key>CFBundleURLSchemes</key>
<array>
<string>net.schweitz.tatlock</string>
</array>
</dict>
</array>
</dict>
</plist>
+1 -1
View File
@@ -12,7 +12,7 @@ class TatlockApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(appRouterProvider);
final themeAsync = ref.watch(themeNotifierProvider);
final themeAsync = ref.watch(themeProvider);
// Get theme mode, defaulting to system while loading
final themeMode = switch (themeAsync) {
+26 -24
View File
@@ -1,26 +1,28 @@
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/core/api/api_interceptors.dart';
import 'package:tatlock_ui/core/config/app_config.dart';
import '../config/app_config.dart';
import 'api_interceptors.dart';
import 'api_client_native.dart' if (dart.library.html) 'api_client_web.dart'
as platform;
part 'api_client.g.dart';
/// Provides the Dio instance for Core API.
@riverpod
Dio coreApiClient(CoreApiClientRef ref) {
final dio = Dio(
BaseOptions(
baseUrl: AppConfig.coreApiUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
Dio coreApiClient(Ref ref) {
final options = BaseOptions(
baseUrl: AppConfig.coreApiUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
);
final dio = platform.createDio(options);
dio.interceptors.addAll([
AuthInterceptor(ref),
LoggingInterceptor(),
@@ -32,19 +34,19 @@ Dio coreApiClient(CoreApiClientRef ref) {
/// Provides the Dio instance for Tatlock API.
@riverpod
Dio tatlockApiClient(TatlockApiClientRef ref) {
final dio = Dio(
BaseOptions(
baseUrl: AppConfig.tatlockApiUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(minutes: 5), // Longer for LLM responses
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
Dio tatlockApiClient(Ref ref) {
final options = BaseOptions(
baseUrl: AppConfig.tatlockApiUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(minutes: 5), // Longer for LLM responses
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
);
final dio = platform.createDio(options);
dio.interceptors.addAll([
AuthInterceptor(ref),
LoggingInterceptor(),
+6
View File
@@ -0,0 +1,6 @@
import 'package:dio/dio.dart';
/// Create a Dio instance for native platforms (mobile, desktop).
Dio createDio(BaseOptions options) {
return Dio(options);
}
+12
View File
@@ -0,0 +1,12 @@
import 'package:dio/dio.dart';
import 'package:dio_web_adapter/dio_web_adapter.dart';
/// Create a Dio instance for web platform with credentials support.
///
/// Enables `withCredentials` to send cookies with requests, which is
/// required for NPM forward auth to work correctly.
Dio createDio(BaseOptions options) {
final dio = Dio(options);
dio.httpClientAdapter = BrowserHttpClientAdapter(withCredentials: true);
return dio;
}
+62 -19
View File
@@ -1,12 +1,16 @@
import 'dart:developer' as developer;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_provider.dart';
import '../error/app_exception.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/config/app_config.dart';
import 'package:tatlock_ui/core/error/app_exception.dart';
/// Adds authentication token to requests.
///
/// - **LAN mode**: Skipped entirely (no auth required)
/// - **Web + Mobile**: Adds Bearer token from OIDC authentication
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._ref);
@@ -14,7 +18,14 @@ class AuthInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final authState = _ref.read(authNotifierProvider);
// Skip auth for LAN development
if (!AppConfig.requiresAuth) {
handler.next(options);
return;
}
// Add Bearer token for all platforms (web + mobile)
final authState = _ref.read(authProvider);
authState.whenData((auth) {
if (auth.isAuthenticated && auth.accessToken != null) {
@@ -27,41 +38,73 @@ 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(authNotifierProvider.notifier).signOut();
_ref.read(authProvider.notifier).signOut();
}
handler.next(err);
}
}
/// Logs requests and responses in debug mode.
/// Logs API requests and responses to the console.
///
/// All requests are logged with method, URL, query params, and body.
/// Responses include status code. Errors include full details.
class LoggingInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
developer.log(
'${options.method} ${options.uri}',
name: 'api',
);
final buffer = StringBuffer()
..writeln('┌── API Request ──────────────────────────────────────')
..writeln('${options.method} ${options.path}');
if (options.queryParameters.isNotEmpty) {
buffer.writeln('│ Query: ${options.queryParameters}');
}
if (options.data != null) {
buffer.writeln('│ Body: ${options.data}');
}
buffer.writeln('└─────────────────────────────────────────────────────');
final message = buffer.toString();
developer.log(message, name: 'API');
debugPrint(message);
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
developer.log(
' ${response.statusCode} ${response.requestOptions.uri}',
name: 'api',
);
final message =
' ${response.statusCode} ${response.requestOptions.method} ${response.requestOptions.path}';
developer.log(message, name: 'API');
debugPrint(message);
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
developer.log(
'${err.response?.statusCode ?? 'NETWORK'} ${err.requestOptions.uri}: ${err.message}',
name: 'api',
error: err,
);
final buffer = StringBuffer()
..writeln('┌── API Error ────────────────────────────────────────')
..writeln('${err.requestOptions.method} ${err.requestOptions.path}')
..writeln('│ Status: ${err.response?.statusCode ?? 'NETWORK ERROR'}')
..writeln('│ Message: ${err.message}');
if (err.response?.data != null) {
buffer.writeln('│ Response: ${err.response?.data}');
}
buffer.writeln('└─────────────────────────────────────────────────────');
final message = buffer.toString();
developer.log(message, name: 'API', error: err);
debugPrint(message);
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);
}
}
+133
View File
@@ -0,0 +1,133 @@
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client.dart';
import 'permissions.dart';
import 'user_preferences.dart';
part 'auth_datasource.g.dart';
/// Response from POST /auth/sync endpoint.
class AuthSyncResponse {
const AuthSyncResponse({
required this.userId,
required this.authentikId,
required this.email,
required this.name,
this.avatarUrl,
required this.roles,
required this.preferences,
required this.isNewUser,
});
final String userId;
final String authentikId;
final String email;
final String name;
final String? avatarUrl;
final List<Role> roles;
final UserPreferences preferences;
final bool isNewUser;
factory AuthSyncResponse.fromJson(Map<String, dynamic> json) {
final user = json['user'] as Map<String, dynamic>;
final rolesJson = json['roles'] as List<dynamic>;
final prefsJson = json['preferences'] as Map<String, dynamic>;
return AuthSyncResponse(
userId: user['id'] as String,
authentikId: user['authentik_id'] as String,
email: user['email'] as String,
name: user['name'] as String,
avatarUrl: user['avatar_url'] as String?,
roles: rolesJson.map((r) => _parseRole(r as Map<String, dynamic>)).toList(),
preferences: UserPreferences.fromJson(prefsJson),
isNewUser: json['is_new_user'] as bool,
);
}
}
/// Parse a role from API JSON.
Role _parseRole(Map<String, dynamic> json) {
final name = json['name'] as String;
final domainStr = json['domain'] as String;
final category = json['category'] as String? ?? 'general';
final actionStr = json['action'] as String;
final domain = Domain.fromString(domainStr);
final action = Action.fromString(actionStr);
if (domain == null || action == null) {
// Return a placeholder role for unknown domains/actions
return Role(
id: json['id'] as String,
name: name,
domain: Domain.admin, // Fallback
category: category,
action: Action.viewer, // Fallback - least privilege
);
}
return Role(
id: json['id'] as String,
name: name,
domain: domain,
category: category,
action: action,
);
}
/// Datasource for auth API endpoints.
class AuthDatasource {
AuthDatasource(this._dio);
final Dio _dio;
/// Sync user with core-api after OIDC authentication.
///
/// Sends the OIDC access token to core-api, which validates it with Authentik
/// and returns the user profile, roles, and preferences.
Future<AuthSyncResponse> syncUser(String accessToken) async {
final response = await _dio.post<Map<String, dynamic>>(
'/auth/sync',
data: {'access_token': accessToken},
);
return AuthSyncResponse.fromJson(response.data!);
}
/// Get current user profile via NPM forward auth.
///
/// This endpoint reads X-authentik-* headers set by NPM forward auth.
/// Returns user profile if authenticated via the proxy.
/// Throws 401 if not authenticated or accessing directly.
Future<AuthSyncResponse> getCurrentUser() async {
final response = await _dio.get<Map<String, dynamic>>('/auth/users/me');
return AuthSyncResponse.fromJson(response.data!);
}
/// Update user preferences.
Future<UserPreferences> updatePreferences({
String? theme,
String? defaultRoom,
Map<String, dynamic>? preferencesJson,
}) async {
final data = <String, dynamic>{};
if (theme != null) data['theme'] = theme;
if (defaultRoom != null) data['default_room'] = defaultRoom;
if (preferencesJson != null) data['preferences_json'] = preferencesJson;
final response = await _dio.patch<Map<String, dynamic>>(
'/auth/users/me/preferences',
data: data,
);
return UserPreferences.fromJson(response.data!);
}
}
/// Provider for the auth datasource.
@riverpod
AuthDatasource authDatasource(Ref ref) {
return AuthDatasource(ref.watch(coreApiClientProvider));
}
+328 -22
View File
@@ -1,28 +1,48 @@
import 'dart:convert' show jsonDecode, jsonEncode;
import 'dart:developer' as developer;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
import 'auth_datasource.dart';
import 'auth_state.dart';
import 'oidc_service.dart';
import 'oidc_service_web.dart';
import 'permissions.dart';
import 'user_preferences.dart';
import 'web_utils.dart' as web_utils;
part 'auth_provider.g.dart';
/// Provides authentication state and operations.
///
/// Note: Full OIDC implementation with flutter_appauth requires
/// native platform configuration. For now, this provides the
/// state management infrastructure.
/// Supports OIDC Authorization Code flow with PKCE on all platforms:
/// - **Web**: Browser redirect to Authentik, callback via /callback route
/// - **Mobile**: flutter_appauth with custom URL scheme
///
/// After OIDC authentication, syncs with core-api via POST /auth/sync
/// to get user profile, roles, and preferences.
@riverpod
class AuthNotifier extends _$AuthNotifier {
// Storage keys
static const _accessTokenKey = 'auth_access_token';
static const _refreshTokenKey = 'auth_refresh_token';
static const _expiresAtKey = 'auth_expires_at';
static const _userIdKey = 'auth_user_id';
static const _authentikIdKey = 'auth_authentik_id';
static const _userNameKey = 'auth_user_name';
static const _userEmailKey = 'auth_user_email';
static const _avatarUrlKey = 'auth_avatar_url';
static const _rolesKey = 'auth_roles';
static const _preferencesKey = 'auth_preferences';
@override
Future<AuthState> build() async {
// AuthController.initialize() in main() handles OIDC flow before app starts.
// By the time we get here, tokens are already stored (or we're in LAN mode).
// Just load the stored auth state.
return _loadStoredAuth();
}
@@ -40,17 +60,36 @@ class AuthNotifier extends _$AuthNotifier {
? DateTime.fromMillisecondsSinceEpoch(expiresAtMs)
: null;
// Load roles from JSON
final rolesJson = prefs.getString(_rolesKey);
final roles = rolesJson != null ? _parseRoles(rolesJson) : <Role>[];
// Load preferences from JSON
final prefsJson = prefs.getString(_preferencesKey);
final preferences = prefsJson != null
? UserPreferences.fromJson(jsonDecode(prefsJson) as Map<String, dynamic>)
: null;
final authState = AuthState(
isAuthenticated: true,
accessToken: accessToken,
refreshToken: prefs.getString(_refreshTokenKey),
expiresAt: expiresAt,
userId: prefs.getString(_userIdKey),
authentikId: prefs.getString(_authentikIdKey),
userName: prefs.getString(_userNameKey),
userEmail: prefs.getString(_userEmailKey),
avatarUrl: prefs.getString(_avatarUrlKey),
roles: roles,
preferences: preferences,
);
// Check if token is expired
// Check if token is expired - try to refresh
if (authState.isTokenExpired && authState.refreshToken != null) {
developer.log('Token expired, attempting refresh', name: 'auth');
return _tryRefreshToken(authState);
}
if (authState.isTokenExpired) {
developer.log('Stored token expired, clearing auth', name: 'auth');
await _clearStoredAuth();
@@ -65,29 +104,285 @@ class AuthNotifier extends _$AuthNotifier {
}
}
/// Sign in with OIDC (placeholder for flutter_appauth integration).
/// Parse roles from stored JSON.
List<Role> _parseRoles(String json) {
try {
final list = jsonDecode(json) as List<dynamic>;
return list.map((item) {
final map = item as Map<String, dynamic>;
final domain = Domain.fromString(map['domain'] as String);
final action = Action.fromString(map['action'] as String);
if (domain == null || action == null) {
return null;
}
return Role(
id: map['id'] as String,
name: map['name'] as String,
domain: domain,
category: map['category'] as String? ?? 'general',
action: action,
);
}).whereType<Role>().toList();
} catch (e) {
developer.log('Failed to parse roles: $e', name: 'auth');
return [];
}
}
/// Try to refresh the access token.
Future<AuthState> _tryRefreshToken(AuthState currentState) async {
if (currentState.refreshToken == null) {
await _clearStoredAuth();
return const AuthState();
}
try {
final oidcService = ref.read(oidcServiceProvider);
final tokens = await oidcService.refreshToken(currentState.refreshToken!);
// Update stored tokens
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_accessTokenKey, tokens.accessToken);
if (tokens.refreshToken != null) {
await prefs.setString(_refreshTokenKey, tokens.refreshToken!);
}
await prefs.setInt(_expiresAtKey, tokens.expiresAt.millisecondsSinceEpoch);
developer.log('Token refreshed successfully', name: 'auth');
return currentState.copyWith(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken ?? currentState.refreshToken,
expiresAt: tokens.expiresAt,
);
} catch (e) {
developer.log('Token refresh failed: $e', name: 'auth');
await _clearStoredAuth();
return const AuthState();
}
}
/// Sign in with the appropriate method for the platform.
///
/// - **Web**: Redirects to Authentik for OIDC authentication
/// - **Mobile**: Opens Authentik login via OIDC, then syncs with core-api
Future<void> signIn() async {
// TODO: Implement OIDC flow with flutter_appauth
// For now, this is a placeholder that will be implemented
// when native platform configuration is complete.
developer.log('Sign in requested - OIDC not yet configured', name: 'auth');
if (!AppConfig.requiresAuth) {
developer.log('Auth not required in LAN mode', name: 'auth');
// In LAN mode, set a minimal authenticated state
state = const AsyncData(AuthState(isAuthenticated: true));
return;
}
// Web: Use OIDC flow with browser redirect
if (kIsWeb) {
developer.log('Web sign-in: starting OIDC flow', name: 'auth');
state = const AsyncLoading();
try {
final oidcService = OidcServiceWeb();
final authUrl = await oidcService.getAuthorizationUrl();
developer.log('Redirecting to: $authUrl', name: 'auth');
web_utils.redirectTo(authUrl);
// Browser will redirect, so we don't update state here
} catch (e, stack) {
developer.log('Failed to start OIDC flow: $e', name: 'auth');
state = AsyncError(e, stack);
}
return;
}
// Mobile: Use OIDC flow with flutter_appauth
state = const AsyncLoading();
try {
// Step 1: OIDC authentication with Authentik
developer.log('Starting OIDC authentication', name: 'auth');
final oidcService = ref.read(oidcServiceProvider);
final tokens = await oidcService.signIn();
// Step 2: Sync with core-api to get user profile and roles
developer.log('Syncing with core-api', name: 'auth');
final authDatasource = ref.read(authDatasourceProvider);
final syncResponse = await authDatasource.syncUser(tokens.accessToken);
// Step 3: Store credentials and user data
await _storeAuth(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
);
state = AsyncData(AuthState(
isAuthenticated: true,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
));
developer.log(
'Authenticated as ${syncResponse.name} with ${syncResponse.roles.length} roles',
name: 'auth',
);
} on OidcException catch (e) {
developer.log('OIDC authentication failed: $e', name: 'auth');
state = AsyncError(e, StackTrace.current);
} catch (e, stack) {
developer.log('Authentication failed: $e', name: 'auth');
state = AsyncError(e, stack);
}
}
/// Handle OIDC callback after Authentik redirects back (web only).
///
/// [code] is the authorization code from the callback URL.
/// [state] is the state parameter for CSRF verification.
Future<void> handleOidcCallback(String code, String callbackState) async {
if (!kIsWeb) {
developer.log('handleOidcCallback called on non-web platform', name: 'auth');
return;
}
developer.log('Handling OIDC callback', name: 'auth');
state = const AsyncLoading();
try {
// Step 1: Exchange code for tokens
final oidcService = OidcServiceWeb();
final tokens = await oidcService.exchangeCode(code, callbackState);
// Step 2: Sync with core-api to get user profile and roles
developer.log('Syncing with core-api', name: 'auth');
final authDatasource = ref.read(authDatasourceProvider);
final syncResponse = await authDatasource.syncUser(tokens.accessToken);
developer.log(
'Synced user: ${syncResponse.name} with ${syncResponse.roles.length} roles',
name: 'auth',
);
// Step 3: Store credentials and user data from sync response
await _storeAuth(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
);
state = AsyncData(AuthState(
isAuthenticated: true,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
userId: syncResponse.userId,
authentikId: syncResponse.authentikId,
userName: syncResponse.name,
userEmail: syncResponse.email,
avatarUrl: syncResponse.avatarUrl,
roles: syncResponse.roles,
preferences: syncResponse.preferences,
));
developer.log('Authenticated as ${syncResponse.name}', name: 'auth');
// Clean up the URL by removing the query parameters
web_utils.replaceUrl('/');
} on OidcException catch (e) {
developer.log('OIDC callback failed: $e', name: 'auth');
state = AsyncError(e, StackTrace.current);
} catch (e, stack) {
developer.log('Callback handling failed: $e', name: 'auth');
state = AsyncError(e, stack);
}
}
/// Sign out and clear stored credentials.
///
/// On web, also redirects to Authentik's logout endpoint to end the SSO session.
Future<void> signOut() async {
// Clear local storage first
await _clearStoredAuth();
state = const AsyncData(AuthState());
developer.log('Signed out', name: 'auth');
developer.log('Signed out locally', name: 'auth');
// On web, redirect to Authentik logout to end SSO session
if (kIsWeb && AppConfig.requiresAuth) {
try {
final oidcService = OidcServiceWeb();
final logoutUrl = await oidcService.getLogoutUrl();
developer.log('Redirecting to Authentik logout', name: 'auth');
web_utils.redirectTo(logoutUrl);
} catch (e) {
developer.log('Failed to get logout URL: $e', name: 'auth');
// Local logout already done, just reload to trigger re-auth
web_utils.redirectTo('/');
}
}
}
/// Update auth state (called after successful OIDC flow).
Future<void> setAuthenticated({
/// Update user preferences.
Future<void> updatePreferences({
String? theme,
String? defaultRoom,
Map<String, dynamic>? preferencesJson,
}) async {
final currentState = state.value;
if (currentState == null || !currentState.isAuthenticated) return;
try {
final authDatasource = ref.read(authDatasourceProvider);
final newPrefs = await authDatasource.updatePreferences(
theme: theme,
defaultRoom: defaultRoom,
preferencesJson: preferencesJson,
);
// Update stored preferences
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_preferencesKey, jsonEncode(newPrefs.toJson()));
state = AsyncData(currentState.copyWith(preferences: newPrefs));
developer.log('Preferences updated', name: 'auth');
} catch (e) {
developer.log('Failed to update preferences: $e', name: 'auth');
rethrow;
}
}
/// Store authentication data to SharedPreferences.
Future<void> _storeAuth({
required String accessToken,
String? refreshToken,
DateTime? expiresAt,
String? userId,
String? authentikId,
String? userName,
String? userEmail,
String? avatarUrl,
List<Role>? roles,
UserPreferences? preferences,
}) async {
final prefs = await SharedPreferences.getInstance();
@@ -99,20 +394,27 @@ class AuthNotifier extends _$AuthNotifier {
await prefs.setInt(_expiresAtKey, expiresAt.millisecondsSinceEpoch);
}
if (userId != null) await prefs.setString(_userIdKey, userId);
if (authentikId != null) await prefs.setString(_authentikIdKey, authentikId);
if (userName != null) await prefs.setString(_userNameKey, userName);
if (userEmail != null) await prefs.setString(_userEmailKey, userEmail);
if (avatarUrl != null) await prefs.setString(_avatarUrlKey, avatarUrl);
state = AsyncData(AuthState(
isAuthenticated: true,
accessToken: accessToken,
refreshToken: refreshToken,
expiresAt: expiresAt,
userId: userId,
userName: userName,
userEmail: userEmail,
));
// Store roles as JSON
if (roles != null) {
final rolesJson = jsonEncode(roles.map((r) => {
'id': r.id,
'name': r.name,
'domain': r.domain.value,
'category': r.category,
'action': r.action.name,
}).toList());
await prefs.setString(_rolesKey, rolesJson);
}
developer.log('Authenticated as $userName', name: 'auth');
// Store preferences as JSON
if (preferences != null) {
await prefs.setString(_preferencesKey, jsonEncode(preferences.toJson()));
}
}
Future<void> _clearStoredAuth() async {
@@ -121,7 +423,11 @@ class AuthNotifier extends _$AuthNotifier {
await prefs.remove(_refreshTokenKey);
await prefs.remove(_expiresAtKey);
await prefs.remove(_userIdKey);
await prefs.remove(_authentikIdKey);
await prefs.remove(_userNameKey);
await prefs.remove(_userEmailKey);
await prefs.remove(_avatarUrlKey);
await prefs.remove(_rolesKey);
await prefs.remove(_preferencesKey);
}
}
+38 -2
View File
@@ -1,18 +1,46 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'permissions.dart';
import 'user_preferences.dart';
part 'auth_state.freezed.dart';
/// Authentication state.
/// Authentication state including user profile, roles, and preferences.
@freezed
class AuthState with _$AuthState {
sealed class AuthState with _$AuthState {
const factory AuthState({
/// Whether user is authenticated.
@Default(false) bool isAuthenticated,
/// OIDC access token.
String? accessToken,
/// OIDC refresh token.
String? refreshToken,
/// Token expiration time.
DateTime? expiresAt,
/// Internal user ID (from core-api).
String? userId,
/// Authentik user ID.
String? authentikId,
/// User display name.
String? userName,
/// User email address.
String? userEmail,
/// User avatar URL.
String? avatarUrl,
/// User's permission roles.
@Default([]) List<Role> roles,
/// User preferences.
UserPreferences? preferences,
}) = _AuthState;
const AuthState._();
@@ -23,4 +51,12 @@ class AuthState with _$AuthState {
// Consider expired if less than 1 minute remaining
return DateTime.now().isAfter(expiresAt!.subtract(const Duration(minutes: 1)));
}
/// Check if user has the specified permission.
bool hasPermission(Domain domain, Action action, {String category = 'general'}) {
return roles.hasPermission(domain, action, category: category);
}
/// Check if user is a global admin.
bool get isGlobalAdmin => roles.isGlobalAdmin;
}
+115
View File
@@ -0,0 +1,115 @@
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../config/app_config.dart';
part 'oidc_service.g.dart';
/// OIDC token response containing access and refresh tokens.
class OidcTokens {
const OidcTokens({
required this.accessToken,
required this.refreshToken,
required this.expiresAt,
this.idToken,
});
final String accessToken;
final String? refreshToken;
final DateTime expiresAt;
final String? idToken;
}
/// Service for OIDC authentication using flutter_appauth.
///
/// Handles the Authorization Code flow with PKCE for secure authentication
/// against Authentik.
class OidcService {
OidcService({FlutterAppAuth? appAuth}) : _appAuth = appAuth ?? const FlutterAppAuth();
final FlutterAppAuth _appAuth;
/// OIDC scopes to request.
static const _scopes = ['openid', 'profile', 'email', 'offline_access'];
/// Redirect URI for the app.
static String get _redirectUri => '${AppConfig.authRedirectScheme}://callback';
/// Start the authorization code flow.
///
/// Opens a browser/webview for user to authenticate with Authentik,
/// then exchanges the authorization code for tokens.
///
/// Throws [OidcException] if authentication fails.
Future<OidcTokens> signIn() async {
try {
final result = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
AppConfig.authClientId,
_redirectUri,
discoveryUrl: AppConfig.authDiscoveryUrl,
scopes: _scopes,
),
);
if (result.accessToken == null) {
throw OidcException('Authorization failed: no access token');
}
return OidcTokens(
accessToken: result.accessToken!,
refreshToken: result.refreshToken,
expiresAt: result.accessTokenExpirationDateTime ?? DateTime.now().add(const Duration(hours: 1)),
idToken: result.idToken,
);
} on Exception catch (e) {
throw OidcException('Authorization failed: $e');
}
}
/// Refresh the access token using a refresh token.
///
/// Throws [OidcException] if refresh fails.
Future<OidcTokens> refreshToken(String refreshToken) async {
try {
final result = await _appAuth.token(
TokenRequest(
AppConfig.authClientId,
_redirectUri,
discoveryUrl: AppConfig.authDiscoveryUrl,
refreshToken: refreshToken,
scopes: _scopes,
),
);
if (result.accessToken == null) {
throw OidcException('Token refresh failed: no access token');
}
return OidcTokens(
accessToken: result.accessToken!,
refreshToken: result.refreshToken ?? refreshToken,
expiresAt: result.accessTokenExpirationDateTime ?? DateTime.now().add(const Duration(hours: 1)),
idToken: result.idToken,
);
} on Exception catch (e) {
throw OidcException('Token refresh failed: $e');
}
}
}
/// Exception thrown when OIDC operations fail.
class OidcException implements Exception {
const OidcException(this.message);
final String message;
@override
String toString() => 'OidcException: $message';
}
/// Provider for the OIDC service.
@riverpod
OidcService oidcService(Ref ref) {
return OidcService();
}
+245
View File
@@ -0,0 +1,245 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import '../config/app_config.dart';
import 'oidc_service.dart';
import 'web_utils.dart' as web_utils;
/// Web implementation of OIDC service using browser redirect flow.
///
/// Uses Authorization Code flow with PKCE for secure authentication.
/// On web, we can't use flutter_appauth, so we implement the flow manually
/// using browser redirects and URL parsing.
class OidcServiceWeb implements OidcService {
OidcServiceWeb({Dio? dio}) : _dio = dio ?? Dio();
final Dio _dio;
/// OIDC scopes to request.
static const _scopes = ['openid', 'profile', 'email', 'offline_access'];
/// Redirect URI for web.
static String get _redirectUri => '${AppConfig.webBaseUrl}/callback';
// SessionStorage keys for PKCE state (persists across redirect)
static const _codeVerifierKey = 'oidc_code_verifier';
static const _stateKey = 'oidc_state';
/// Get the authorization URL to redirect the browser to.
///
/// Returns a URL that the browser should navigate to for authentication.
/// The [codeVerifier] and [state] are stored for later verification.
///
/// If [silent] is true, adds `prompt=none` to skip login UI.
/// This is used when the user already has an Authentik session (via NPM).
/// Authentik will instantly redirect back with a code, or return an error
/// if there's no valid session.
Future<String> getAuthorizationUrl({bool silent = false}) async {
// Fetch OIDC discovery document
final discovery = await _fetchDiscovery();
final authEndpoint = discovery['authorization_endpoint'] as String;
// Generate PKCE code verifier and challenge
final codeVerifier = _generateCodeVerifier();
final codeChallenge = _generateCodeChallenge(codeVerifier);
// Generate state for CSRF protection
final state = _generateRandomString(32);
// Store PKCE state in sessionStorage (persists across redirect)
web_utils.setSessionStorage(_codeVerifierKey, codeVerifier);
web_utils.setSessionStorage(_stateKey, state);
// Build authorization URL
final params = {
'client_id': AppConfig.authClientId,
'redirect_uri': _redirectUri,
'response_type': 'code',
'scope': _scopes.join(' '),
'code_challenge': codeChallenge,
'code_challenge_method': 'S256',
'state': state,
if (silent) 'prompt': 'none', // Silent auth - no UI, instant redirect
};
final uri = Uri.parse(authEndpoint).replace(queryParameters: params);
developer.log('Authorization URL (silent=$silent): $uri', name: 'oidc_web');
return uri.toString();
}
/// Exchange authorization code for tokens.
///
/// Call this after the browser redirects back with the authorization code.
/// [code] is the authorization code from the callback URL.
/// [state] is the state parameter from the callback URL (verified for CSRF).
Future<OidcTokens> exchangeCode(String code, String state) async {
// Retrieve PKCE state from sessionStorage
final storedState = web_utils.getSessionStorage(_stateKey);
final codeVerifier = web_utils.getSessionStorage(_codeVerifierKey);
developer.log('Stored state: $storedState, received state: $state', name: 'oidc_web');
developer.log('Code verifier present: ${codeVerifier != null}', name: 'oidc_web');
// Verify state matches
if (storedState == null || state != storedState) {
_clearPkceState();
throw OidcException('State mismatch - possible CSRF attack');
}
if (codeVerifier == null) {
_clearPkceState();
throw OidcException('No code verifier - flow not started properly');
}
try {
// Fetch token endpoint from discovery
final discovery = await _fetchDiscovery();
final tokenEndpoint = discovery['token_endpoint'] as String;
developer.log('Exchanging code at: $tokenEndpoint', name: 'oidc_web');
// Exchange code for tokens
final response = await _dio.post<Map<String, dynamic>>(
tokenEndpoint,
data: {
'grant_type': 'authorization_code',
'client_id': AppConfig.authClientId,
'redirect_uri': _redirectUri,
'code': code,
'code_verifier': codeVerifier,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
);
final data = response.data!;
developer.log('Token exchange successful', name: 'oidc_web');
// Clear stored PKCE state
_clearPkceState();
return OidcTokens(
accessToken: data['access_token'] as String,
refreshToken: data['refresh_token'] as String?,
expiresAt: DateTime.now().add(
Duration(seconds: data['expires_in'] as int? ?? 3600),
),
idToken: data['id_token'] as String?,
);
} on DioException catch (e) {
developer.log('Token exchange failed: $e', name: 'oidc_web');
_clearPkceState();
throw OidcException('Token exchange failed: ${e.message}');
}
}
/// Clear PKCE state from sessionStorage.
void _clearPkceState() {
web_utils.removeSessionStorage(_codeVerifierKey);
web_utils.removeSessionStorage(_stateKey);
}
/// Not used on web - use [getAuthorizationUrl] and [exchangeCode] instead.
@override
Future<OidcTokens> signIn() async {
throw OidcException(
'signIn() not supported on web. Use getAuthorizationUrl() and exchangeCode() instead.',
);
}
/// Refresh the access token using a refresh token.
@override
Future<OidcTokens> refreshToken(String refreshToken) async {
try {
final discovery = await _fetchDiscovery();
final tokenEndpoint = discovery['token_endpoint'] as String;
final response = await _dio.post<Map<String, dynamic>>(
tokenEndpoint,
data: {
'grant_type': 'refresh_token',
'client_id': AppConfig.authClientId,
'refresh_token': refreshToken,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
);
final data = response.data!;
return OidcTokens(
accessToken: data['access_token'] as String,
refreshToken: data['refresh_token'] as String? ?? refreshToken,
expiresAt: DateTime.now().add(
Duration(seconds: data['expires_in'] as int? ?? 3600),
),
idToken: data['id_token'] as String?,
);
} on DioException catch (e) {
throw OidcException('Token refresh failed: ${e.message}');
}
}
/// Fetch OIDC discovery document.
Future<Map<String, dynamic>> _fetchDiscovery() async {
final response = await _dio.get<Map<String, dynamic>>(
AppConfig.authDiscoveryUrl,
);
return response.data!;
}
/// Generate a random code verifier for PKCE.
String _generateCodeVerifier() {
return _generateRandomString(64);
}
/// Generate code challenge from verifier using S256.
String _generateCodeChallenge(String verifier) {
final bytes = utf8.encode(verifier);
final digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
/// Generate a random string of given length.
String _generateRandomString(int length) {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
final random = Random.secure();
return List.generate(length, (_) => chars[random.nextInt(chars.length)])
.join();
}
/// Get the logout URL to redirect the browser to for SSO logout.
///
/// [idToken] is optional but recommended for logout verification.
/// After logout, Authentik redirects back to [postLogoutRedirectUri].
Future<String> getLogoutUrl({String? idToken}) async {
final discovery = await _fetchDiscovery();
final endSessionEndpoint = discovery['end_session_endpoint'] as String?;
if (endSessionEndpoint == null) {
// Fallback: just redirect to home, local state already cleared
developer.log('No end_session_endpoint in discovery', name: 'oidc_web');
return AppConfig.webBaseUrl;
}
final params = <String, String>{
'post_logout_redirect_uri': AppConfig.webBaseUrl,
};
if (idToken != null) {
params['id_token_hint'] = idToken;
}
final uri = Uri.parse(endSessionEndpoint).replace(queryParameters: params);
developer.log('Logout URL: $uri', name: 'oidc_web');
return uri.toString();
}
}
+110
View File
@@ -0,0 +1,110 @@
import 'package:flutter/widgets.dart' hide Action;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart';
import 'permissions.dart';
/// A widget that conditionally renders its child based on user permissions.
///
/// Example:
/// ```dart
/// PermissionGate(
/// domain: Domain.controlRoom,
/// action: Action.admin,
/// child: DeleteButton(),
/// fallback: Text('No permission'),
/// )
/// ```
class PermissionGate extends ConsumerWidget {
const PermissionGate({
super.key,
required this.domain,
required this.action,
this.category = 'general',
required this.child,
this.fallback,
});
/// The domain required for this permission.
final Domain domain;
/// The action level required (viewer, user, editor, admin).
final Action action;
/// Optional category within the domain (defaults to 'general').
final String category;
/// Widget to show when user has permission.
final Widget child;
/// Widget to show when user lacks permission (defaults to empty).
final Widget? fallback;
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final hasPermission = authState.maybeWhen(
data: (state) => state.hasPermission(domain, action, category: category),
orElse: () => false,
);
if (hasPermission) {
return child;
}
return fallback ?? const SizedBox.shrink();
}
}
/// A widget that shows its child only if the user is a global admin.
class AdminGate extends ConsumerWidget {
const AdminGate({
super.key,
required this.child,
this.fallback,
});
/// Widget to show when user is admin.
final Widget child;
/// Widget to show when user is not admin (defaults to empty).
final Widget? fallback;
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final isAdmin = authState.maybeWhen(
data: (state) => state.isGlobalAdmin,
orElse: () => false,
);
if (isAdmin) {
return child;
}
return fallback ?? const SizedBox.shrink();
}
}
/// Extension for checking permissions in code.
extension PermissionCheck on WidgetRef {
/// Check if the current user has a specific permission.
bool hasPermission(Domain domain, Action action, {String category = 'general'}) {
final authState = read(authProvider);
return authState.maybeWhen(
data: (state) => state.hasPermission(domain, action, category: category),
orElse: () => false,
);
}
/// Check if the current user is a global admin.
bool get isGlobalAdmin {
final authState = read(authProvider);
return authState.maybeWhen(
data: (state) => state.isGlobalAdmin,
orElse: () => false,
);
}
}
+137
View File
@@ -0,0 +1,137 @@
// Permission system for role-based access control.
//
// Roles follow the format: `domain.category:action`
// - Domain: Feature area (control-room, media, etc.)
// - Category: Sub-area within domain (default: general)
// - Action: Permission level (viewer < user < editor < admin)
/// Permission domains matching feature areas.
enum Domain {
controlRoom('control-room'),
library('library'),
media('media'),
ai('ai'),
housekeeper('housekeeper'),
developer('developer'),
documents('documents'),
gaming('gaming'),
admin('admin');
const Domain(this.value);
/// The API string value for this domain.
final String value;
/// Parse a domain string from API response.
static Domain? fromString(String value) {
for (final domain in Domain.values) {
if (domain.value == value) return domain;
}
return null;
}
}
/// Permission actions in hierarchical order.
///
/// Higher actions imply lower ones:
/// - admin implies editor, user, viewer
/// - editor implies user, viewer
/// - user implies viewer
enum Action {
viewer(1),
user(2),
editor(3),
admin(4);
const Action(this.level);
/// Numeric level for comparison (higher = more permissions).
final int level;
/// Check if this action grants at least the required action.
bool grants(Action required) => level >= required.level;
/// Parse an action string from API response.
static Action? fromString(String value) {
for (final action in Action.values) {
if (action.name == value) return action;
}
return null;
}
}
/// A permission role assigned to a user.
///
/// Roles are parsed from the API format: `domain.category:action`
class Role {
const Role({
required this.id,
required this.name,
required this.domain,
required this.category,
required this.action,
});
/// Unique role ID.
final String id;
/// Full role name (e.g., "control-room.general:admin").
final String name;
/// Permission domain.
final Domain domain;
/// Permission category (usually "general").
final String category;
/// Permission action level.
final Action action;
/// Check if this role grants access for the given domain and action.
///
/// Global admin (`admin.general:admin`) grants access to everything.
/// Otherwise, domain and category must match, and action level must be sufficient.
bool grants(Domain domain, Action action, {String category = 'general'}) {
// Global admin override
if (this.domain == Domain.admin &&
this.category == 'general' &&
this.action == Action.admin) {
return true;
}
// Check domain and category match
if (this.domain != domain || this.category != category) {
return false;
}
// Check action hierarchy
return this.action.grants(action);
}
@override
String toString() => 'Role($name)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Role && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}
/// Extension for checking permissions on a list of roles.
extension RoleListPermissions on List<Role> {
/// Check if any role grants the required permission.
bool hasPermission(Domain domain, Action action, {String category = 'general'}) {
return any((role) => role.grants(domain, action, category: category));
}
/// Check if any role grants any of the required permissions.
bool hasAnyPermission(List<(Domain, Action)> permissions) {
return permissions.any((p) => hasPermission(p.$1, p.$2));
}
/// Check if user is a global admin.
bool get isGlobalAdmin => hasPermission(Domain.admin, Action.admin);
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_preferences.freezed.dart';
part 'user_preferences.g.dart';
/// User preferences synced from core-api.
@freezed
sealed class UserPreferences with _$UserPreferences {
const factory UserPreferences({
/// Theme preference: system, light, dark
@Default('system') String theme,
/// Default room for housekeeping
@Default('front-hall') String defaultRoom,
/// Extended preferences as JSON
@Default({}) Map<String, dynamic> preferencesJson,
}) = _UserPreferences;
factory UserPreferences.fromJson(Map<String, dynamic> json) =>
_$UserPreferencesFromJson(json);
}
+7
View File
@@ -0,0 +1,7 @@
/// Web utilities with conditional imports.
///
/// Uses stub implementation on non-web platforms.
library;
export 'web_utils_stub.dart'
if (dart.library.js_interop) 'web_utils_web.dart';
+32
View File
@@ -0,0 +1,32 @@
/// Stub for non-web platforms.
library;
/// Redirect to a URL (no-op on non-web).
void redirectTo(String url) {
throw UnsupportedError('redirectTo is only supported on web');
}
/// Get current URL (no-op on non-web).
String getCurrentUrl() {
throw UnsupportedError('getCurrentUrl is only supported on web');
}
/// Replace current URL without navigation (no-op on non-web).
void replaceUrl(String url) {
throw UnsupportedError('replaceUrl is only supported on web');
}
/// Store a value in sessionStorage (no-op on non-web).
void setSessionStorage(String key, String value) {
throw UnsupportedError('setSessionStorage is only supported on web');
}
/// Get a value from sessionStorage (no-op on non-web).
String? getSessionStorage(String key) {
throw UnsupportedError('getSessionStorage is only supported on web');
}
/// Remove a value from sessionStorage (no-op on non-web).
void removeSessionStorage(String key) {
throw UnsupportedError('removeSessionStorage is only supported on web');
}
+34
View File
@@ -0,0 +1,34 @@
/// Web-specific utilities for browser operations.
library;
import 'package:web/web.dart' as web;
/// Redirect the browser to a URL.
void redirectTo(String url) {
web.window.location.href = url;
}
/// Get the current browser URL.
String getCurrentUrl() {
return web.window.location.href;
}
/// Replace the current URL in history without navigation.
void replaceUrl(String url) {
web.window.history.replaceState(null, '', url);
}
/// Store a value in sessionStorage.
void setSessionStorage(String key, String value) {
web.window.sessionStorage.setItem(key, value);
}
/// Get a value from sessionStorage.
String? getSessionStorage(String key) {
return web.window.sessionStorage.getItem(key);
}
/// Remove a value from sessionStorage.
void removeSessionStorage(String key) {
web.window.sessionStorage.removeItem(key);
}
+43 -4
View File
@@ -1,19 +1,35 @@
/// Application configuration from compile-time environment variables.
///
/// Set via: flutter build --dart-define=API_URL=https://...
/// ## Development (LAN - no auth required)
/// Default values use LAN IPs for local development:
/// ```bash
/// flutter run -d chrome
/// ```
///
/// ## Production (public URLs - auth required)
/// Override with public URLs for production builds:
/// ```bash
/// flutter build web \
/// --dart-define=CORE_API_URL=https://api.schweitz.net \
/// --dart-define=TATLOCK_API_URL=https://tatlock.schweitz.net
/// ```
class AppConfig {
AppConfig._();
/// Core API base URL
/// - LAN default: No auth required
/// - Production: https://api.schweitz.net (requires OIDC)
static const coreApiUrl = String.fromEnvironment(
'CORE_API_URL',
defaultValue: 'https://api.schweitz.net',
defaultValue: 'http://192.168.86.149:8083',
);
/// Tatlock API base URL
/// - LAN default: No auth required
/// - Production: https://tatlock.schweitz.net (requires OIDC)
static const tatlockApiUrl = String.fromEnvironment(
'TATLOCK_API_URL',
defaultValue: 'https://tatlock.schweitz.net',
defaultValue: 'http://192.168.86.149:8000',
);
/// Authentik OIDC discovery URL
@@ -29,12 +45,35 @@ class AppConfig {
defaultValue: 'tatlock-ui',
);
/// Authentik redirect URI scheme
/// Authentik redirect URI scheme (for mobile/native)
static const authRedirectScheme = String.fromEnvironment(
'AUTH_REDIRECT_SCHEME',
defaultValue: 'net.schweitz.tatlock',
);
/// Web app base URL (for OIDC redirect URI on web)
static const webBaseUrl = String.fromEnvironment(
'WEB_BASE_URL',
defaultValue: 'https://home.schweitz.net',
);
/// Whether running in debug mode
static const isDebug = bool.fromEnvironment('DEBUG', defaultValue: false);
/// Whether auth is required (false for LAN development)
static bool get requiresAuth =>
coreApiUrl.contains('schweitz.net') ||
tatlockApiUrl.contains('schweitz.net');
/// Portainer URL for container management
static const portainerUrl = String.fromEnvironment(
'PORTAINER_URL',
defaultValue: 'http://192.168.86.149:9000',
);
/// Netdata URL for system monitoring
static const netdataUrl = String.fromEnvironment(
'NETDATA_URL',
defaultValue: 'http://192.168.86.149:19999',
);
}
+5
View File
@@ -0,0 +1,5 @@
/// URL strategy with conditional imports for web/non-web platforms.
library;
export 'url_strategy_stub.dart'
if (dart.library.js_interop) 'url_strategy_web.dart';
+4
View File
@@ -0,0 +1,4 @@
/// Stub for non-web platforms - does nothing.
void configureUrlStrategy() {
// No-op on mobile/desktop
}
+10
View File
@@ -0,0 +1,10 @@
/// Web-specific URL strategy configuration.
library;
import 'package:flutter_web_plugins/url_strategy.dart';
void configureUrlStrategy() {
// Use path-based URLs instead of hash-based (e.g., /login instead of /#/login)
// Required for OIDC callback to work properly
usePathUrlStrategy();
}
@@ -0,0 +1,71 @@
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/control_room/containers/data/models/container_model.dart';
part 'containers_datasource.g.dart';
/// Remote data source for container operations.
class ContainersDatasource {
ContainersDatasource(this._dio);
final Dio _dio;
/// Gets all containers from the API.
Future<List<ContainerModel>> getContainers({bool all = true}) async {
final response = await _dio.get<List<dynamic>>(
'/infrastructure/containers',
queryParameters: {'all': all},
);
return response.data!
.map((json) => ContainerModel.fromJson(json as Map<String, dynamic>))
.toList();
}
/// Gets a single container by ID.
Future<ContainerModel> getContainer(String id) async {
final response = await _dio.get<Map<String, dynamic>>(
'/infrastructure/containers/$id',
);
return ContainerModel.fromJson(response.data!);
}
/// Performs an action on a container.
Future<void> containerAction(String id, String action) async {
await _dio.post<void>('/infrastructure/containers/$id/$action');
}
/// Gets container logs.
Future<String> getContainerLogs(
String id, {
int? tail,
bool timestamps = false,
}) async {
final response = await _dio.get<String>(
'/infrastructure/containers/$id/logs',
queryParameters: {
if (tail != null) 'tail': tail,
'timestamps': timestamps,
},
);
return response.data ?? '';
}
/// Removes a container.
Future<void> removeContainer(String id, {bool force = false}) async {
await _dio.delete<void>(
'/infrastructure/containers/$id',
queryParameters: {'force': force},
);
}
}
/// Provides the containers datasource.
@riverpod
ContainersDatasource containersDatasource(Ref ref) {
final dio = ref.watch(coreApiClientProvider);
return ContainersDatasource(dio);
}
@@ -0,0 +1,129 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart';
part 'container_model.freezed.dart';
part 'container_model.g.dart';
/// Container data model for API serialization.
@freezed
sealed class ContainerModel with _$ContainerModel {
const factory ContainerModel({
@JsonKey(name: 'Id') required String id,
@JsonKey(name: 'Names') required List<String> names,
@JsonKey(name: 'Image') required String image,
@JsonKey(name: 'State') required String state,
@JsonKey(name: 'Status') required String status,
@JsonKey(name: 'Labels') @Default({}) Map<String, String> labels,
@JsonKey(name: 'Ports') @Default([]) List<PortModel> ports,
@JsonKey(name: 'Mounts') @Default([]) List<MountModel> mounts,
@JsonKey(name: 'NetworkSettings') NetworkSettingsModel? networkSettings,
@JsonKey(name: 'Created') int? created,
@JsonKey(name: 'SizeRw') int? sizeRw,
@JsonKey(name: 'SizeRootFs') int? sizeRootFs,
}) = _ContainerModel;
const ContainerModel._();
factory ContainerModel.fromJson(Map<String, dynamic> json) =>
_$ContainerModelFromJson(json);
/// Converts to domain entity.
Container toEntity() {
// Extract stack name from labels (Docker Compose convention)
final stackName = labels['com.docker.compose.project'];
final stackId = stackName; // Use project name as ID for now
return Container(
id: id.substring(0, 12),
fullId: id,
name: names.isNotEmpty ? names.first.replaceFirst('/', '') : id,
image: image,
state: _parseState(state),
status: status,
stackName: stackName,
stackId: stackId,
ports: ports.map((p) => p.toEntity()).toList(),
labels: labels,
mounts: mounts.map((m) => m.toEntity()).toList(),
networks: networkSettings?.networks.keys.toList() ?? [],
createdAt: created != null
? DateTime.fromMillisecondsSinceEpoch(created! * 1000)
: null,
);
}
ContainerState _parseState(String state) {
return switch (state.toLowerCase()) {
'created' => ContainerState.created,
'running' => ContainerState.running,
'paused' => ContainerState.paused,
'restarting' => ContainerState.restarting,
'removing' => ContainerState.removing,
'exited' => ContainerState.exited,
'dead' => ContainerState.dead,
_ => ContainerState.exited,
};
}
}
/// Port mapping model.
@freezed
sealed class PortModel with _$PortModel {
const factory PortModel({
@JsonKey(name: 'IP') String? ip,
@JsonKey(name: 'PrivatePort') required int privatePort,
@JsonKey(name: 'PublicPort') int? publicPort,
@JsonKey(name: 'Type') @Default('tcp') String type,
}) = _PortModel;
const PortModel._();
factory PortModel.fromJson(Map<String, dynamic> json) =>
_$PortModelFromJson(json);
PortMapping toEntity() {
return PortMapping(
hostIp: ip,
hostPort: publicPort,
containerPort: privatePort,
protocol: type,
);
}
}
/// Mount model.
@freezed
sealed class MountModel with _$MountModel {
const factory MountModel({
@JsonKey(name: 'Type') required String type,
@JsonKey(name: 'Source') required String source,
@JsonKey(name: 'Destination') required String destination,
@JsonKey(name: 'Mode') @Default('rw') String mode,
@JsonKey(name: 'RW') @Default(true) bool rw,
}) = _MountModel;
const MountModel._();
factory MountModel.fromJson(Map<String, dynamic> json) =>
_$MountModelFromJson(json);
VolumeMount toEntity() {
return VolumeMount(
type: type,
source: source,
destination: destination,
mode: rw ? 'rw' : 'ro',
);
}
}
/// Network settings model.
@freezed
sealed class NetworkSettingsModel with _$NetworkSettingsModel {
const factory NetworkSettingsModel({
@JsonKey(name: 'Networks') @Default({}) Map<String, dynamic> networks,
}) = _NetworkSettingsModel;
factory NetworkSettingsModel.fromJson(Map<String, dynamic> json) =>
_$NetworkSettingsModelFromJson(json);
}
@@ -0,0 +1,83 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/features/control_room/containers/data/datasources/containers_datasource.dart';
import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart';
import 'package:tatlock_ui/features/control_room/containers/domain/repositories/container_repository.dart';
part 'container_repository_impl.g.dart';
/// Implementation of ContainerRepository using remote datasource.
class ContainerRepositoryImpl implements ContainerRepository {
ContainerRepositoryImpl(this._datasource);
final ContainersDatasource _datasource;
@override
Future<List<Container>> getContainers() async {
final models = await _datasource.getContainers();
return models.map((m) => m.toEntity()).toList();
}
@override
Future<List<Container>> getContainersByStack(String stackId) async {
final containers = await getContainers();
return containers.where((c) => c.stackId == stackId).toList();
}
@override
Future<Container> getContainer(String id) async {
final model = await _datasource.getContainer(id);
return model.toEntity();
}
@override
Future<void> startContainer(String id) async {
await _datasource.containerAction(id, 'start');
}
@override
Future<void> stopContainer(String id) async {
await _datasource.containerAction(id, 'stop');
}
@override
Future<void> restartContainer(String id) async {
await _datasource.containerAction(id, 'restart');
}
@override
Future<void> pauseContainer(String id) async {
await _datasource.containerAction(id, 'pause');
}
@override
Future<void> unpauseContainer(String id) async {
await _datasource.containerAction(id, 'unpause');
}
@override
Future<void> removeContainer(String id, {bool force = false}) async {
await _datasource.removeContainer(id, force: force);
}
@override
Future<String> getContainerLogs(
String id, {
int? tail,
bool timestamps = false,
}) async {
return _datasource.getContainerLogs(id, tail: tail, timestamps: timestamps);
}
@override
Stream<String> streamContainerLogs(String id, {bool timestamps = false}) {
// TODO: Implement WebSocket/SSE streaming
throw UnimplementedError('Log streaming not yet implemented');
}
}
/// Provides the container repository.
@riverpod
ContainerRepository containerRepository(Ref ref) {
final datasource = ref.watch(containersDatasourceProvider);
return ContainerRepositoryImpl(datasource);
}
@@ -0,0 +1,171 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'container.freezed.dart';
/// Docker container entity.
@freezed
sealed class Container with _$Container {
const factory Container({
/// Container ID (short form).
required String id,
/// Full container ID.
required String fullId,
/// Container name (without leading slash).
required String name,
/// Image name with tag.
required String image,
/// Current container state.
required ContainerState state,
/// Container status string (e.g., "Up 2 hours").
required String status,
/// Stack/project this container belongs to.
String? stackName,
/// Stack ID if part of a stack.
String? stackId,
/// Mapped ports.
@Default([]) List<PortMapping> ports,
/// Environment variables (key-value pairs).
@Default({}) Map<String, String> environment,
/// Container labels.
@Default({}) Map<String, String> labels,
/// Mounted volumes.
@Default([]) List<VolumeMount> mounts,
/// Networks the container is connected to.
@Default([]) List<String> networks,
/// When the container was created.
DateTime? createdAt,
/// When the container was started.
DateTime? startedAt,
/// CPU usage percentage (0-100).
double? cpuPercent,
/// Memory usage in bytes.
int? memoryUsage,
/// Memory limit in bytes.
int? memoryLimit,
}) = _Container;
const Container._();
/// Whether the container is running.
bool get isRunning => state == ContainerState.running;
/// Whether the container can be started.
bool get canStart =>
state == ContainerState.exited ||
state == ContainerState.created ||
state == ContainerState.paused;
/// Whether the container can be stopped.
bool get canStop => state == ContainerState.running;
/// Whether the container can be restarted.
bool get canRestart =>
state == ContainerState.running || state == ContainerState.exited;
/// Memory usage as a percentage of the limit.
double? get memoryPercent {
if (memoryUsage == null || memoryLimit == null || memoryLimit == 0) {
return null;
}
return (memoryUsage! / memoryLimit!) * 100;
}
/// Formatted memory usage string.
String get memoryFormatted {
if (memoryUsage == null) return '--';
return _formatBytes(memoryUsage!);
}
/// Formatted memory limit string.
String get memoryLimitFormatted {
if (memoryLimit == null) return '--';
return _formatBytes(memoryLimit!);
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
}
/// Container state.
enum ContainerState {
created,
running,
paused,
restarting,
removing,
exited,
dead,
}
/// Port mapping configuration.
@freezed
sealed class PortMapping with _$PortMapping {
const factory PortMapping({
/// Host IP (usually 0.0.0.0).
String? hostIp,
/// Port on the host.
int? hostPort,
/// Port inside the container.
required int containerPort,
/// Protocol (tcp/udp).
@Default('tcp') String protocol,
}) = _PortMapping;
const PortMapping._();
/// Formatted string representation.
String get formatted {
if (hostPort == null) return '$containerPort/$protocol';
final ip = hostIp == '0.0.0.0' ? '' : '$hostIp:';
return '$ip$hostPort->$containerPort/$protocol';
}
}
/// Volume mount configuration.
@freezed
sealed class VolumeMount with _$VolumeMount {
const factory VolumeMount({
/// Mount type (bind, volume, tmpfs).
required String type,
/// Source path or volume name.
required String source,
/// Destination path in container.
required String destination,
/// Mount mode (rw, ro).
@Default('rw') String mode,
}) = _VolumeMount;
const VolumeMount._();
/// Whether the mount is read-only.
bool get isReadOnly => mode == 'ro';
}
@@ -0,0 +1,44 @@
import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart';
/// Repository interface for container operations.
abstract class ContainerRepository {
/// Gets all containers.
Future<List<Container>> getContainers();
/// Gets containers filtered by stack.
Future<List<Container>> getContainersByStack(String stackId);
/// Gets a single container by ID.
Future<Container> getContainer(String id);
/// Starts a container.
Future<void> startContainer(String id);
/// Stops a container.
Future<void> stopContainer(String id);
/// Restarts a container.
Future<void> restartContainer(String id);
/// Pauses a container.
Future<void> pauseContainer(String id);
/// Unpauses a container.
Future<void> unpauseContainer(String id);
/// Removes a container.
Future<void> removeContainer(String id, {bool force = false});
/// Gets container logs.
Future<String> getContainerLogs(
String id, {
int? tail,
bool timestamps = false,
});
/// Streams container logs in real-time.
Stream<String> streamContainerLogs(
String id, {
bool timestamps = false,
});
}
@@ -0,0 +1,336 @@
import 'package:flutter/material.dart' hide Container;
import 'package:flutter_riverpod/flutter_riverpod.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/shared/components/data_grid/adapters/core_api_source.dart';
import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart';
/// Health status of a container.
enum ContainerHealth { healthy, unhealthy, starting, none }
/// Container data class for DataGrid display.
class ContainerData {
ContainerData({
required this.id,
required this.fullId,
required this.name,
required this.image,
required this.state,
required this.status,
required this.ports,
required this.health,
});
factory ContainerData.fromJson(Map<String, dynamic> json) {
// Docker API uses capitalized keys
final ports = (json['Ports'] as List<dynamic>?)
?.map((p) => ContainerPort.fromJson(p as Map<String, dynamic>))
.toList() ??
[];
final fullId = json['Id'] as String? ?? '';
final names = json['Names'] as List<dynamic>? ?? [];
final name = names.isNotEmpty
? (names.first as String).replaceFirst('/', '')
: 'Unknown';
final status = json['Status'] as String? ?? 'Unknown';
return ContainerData(
id: fullId.length > 12 ? fullId.substring(0, 12) : fullId,
fullId: fullId,
name: name,
image: json['Image'] as String? ?? 'Unknown',
state: json['State'] as String? ?? 'unknown',
status: status,
ports: ports,
health: _parseHealth(status),
);
}
/// Parse health status from Docker status string (e.g., "Up 2 hours (healthy)")
static ContainerHealth _parseHealth(String status) {
final lower = status.toLowerCase();
if (lower.contains('(healthy)')) return ContainerHealth.healthy;
if (lower.contains('(unhealthy)')) return ContainerHealth.unhealthy;
if (lower.contains('(health: starting)')) return ContainerHealth.starting;
return ContainerHealth.none;
}
final String id;
final String fullId;
final String name;
final String image;
final String state;
final String status;
final List<ContainerPort> ports;
final ContainerHealth health;
/// Status string with health info stripped (just shows uptime).
String get displayStatus {
return status
.replaceAll(RegExp(r'\s*\(healthy\)', caseSensitive: false), '')
.replaceAll(RegExp(r'\s*\(unhealthy\)', caseSensitive: false), '')
.replaceAll(RegExp(r'\s*\(health: starting\)', caseSensitive: false), '')
.trim();
}
bool get canStart => state == 'exited' || state == 'created';
bool get canStop => state == 'running';
bool get canRestart => state == 'running';
}
class ContainerPort {
ContainerPort({required this.privatePort, this.publicPort, this.type = 'tcp'});
factory ContainerPort.fromJson(Map<String, dynamic> json) => ContainerPort(
// Docker API uses PrivatePort/PublicPort
privatePort: json['PrivatePort'] as int? ?? 0,
publicPort: json['PublicPort'] as int?,
type: json['Type'] as String? ?? 'tcp',
);
final int privatePort;
final int? publicPort;
final String type;
String get formatted => publicPort != null ? '$publicPort:$privatePort' : '$privatePort';
}
/// Page displaying the list of containers using DataGrid.
class ContainersListPage extends ConsumerStatefulWidget {
const ContainersListPage({super.key});
@override
ConsumerState<ContainersListPage> createState() => _ContainersListPageState();
}
class _ContainersListPageState extends ConsumerState<ContainersListPage> {
late final StateNotifierProvider<DataGridController<ContainerData>,
DataGridState<ContainerData>> _gridProvider;
@override
void initState() {
super.initState();
final dio = ref.read(coreApiClientProvider);
final source = CoreApiDataSource<ContainerData>(
dio: dio,
endpoint: '/infrastructure/containers',
fromJson: ContainerData.fromJson,
);
_gridProvider = dataGridProvider<ContainerData>(
source: source,
config: _buildConfig(),
idSelector: (c) => c.id,
);
}
DataGridConfig<ContainerData> _buildConfig() {
return DataGridConfig<ContainerData>(
columns: [
DataGridColumn<ContainerData>(
header: 'Container',
valueBuilder: (c) => '${c.name} ${c.image}',
sortable: true,
searchable: true,
width: const DataGridColumnWidth.flex(2),
cellBuilder: (context, c) => _ContainerCell(container: c),
),
DataGridColumn<ContainerData>(
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>(
header: 'Status',
valueBuilder: (c) => c.displayStatus,
width: const DataGridColumnWidth.fixed(160),
alignment: DataGridColumnAlignment.end,
),
],
actions: [
DataGridAction<ContainerData>(
icon: Icons.play_arrow,
label: 'Start',
onTap: (c) async => _handleAction(c, 'start'),
showWhen: (c) => c.canStart,
),
DataGridAction<ContainerData>(
icon: Icons.stop,
label: 'Stop',
onTap: (c) async => _handleAction(c, 'stop'),
showWhen: (c) => c.canStop,
),
DataGridAction<ContainerData>(
icon: Icons.refresh,
label: 'Restart',
onTap: (c) async => _handleAction(c, 'restart'),
showWhen: (c) => c.canRestart,
),
DataGridAction<ContainerData>(
icon: Icons.article,
label: 'View Logs',
onTap: (c) async => _showLogs(c),
),
],
enableSearch: true,
searchHint: 'Search containers...',
showHeader: true,
showFooter: true,
);
}
Future<void> _handleAction(ContainerData container, String action) async {
final actions = ref.read(containerActionsProvider.notifier);
switch (action) {
case 'start':
await actions.start(container.fullId);
case 'stop':
await actions.stop(container.fullId);
case 'restart':
await actions.restart(container.fullId);
}
// Refresh the grid after action
ref.read(_gridProvider.notifier).refresh();
}
Future<void> _showLogs(ContainerData container) async {
if (!mounted) return;
showContainerLogs(
context,
containerId: container.fullId,
containerName: container.name,
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Listen for container action results to show snackbars
ref.listen<AsyncValue<void>>(containerActionsProvider, (previous, next) {
if (previous?.isLoading == true && !next.isLoading) {
next.whenOrNull(
data: (_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Container action completed'),
backgroundColor: colorScheme.primaryContainer,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
},
error: (error, _) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
'Container action failed: $error',
style: TextStyle(color: colorScheme.onErrorContainer),
),
),
],
),
backgroundColor: colorScheme.errorContainer,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 4),
),
);
},
);
}
});
return DataGrid<ContainerData>(
provider: _gridProvider,
config: _buildConfig(),
idSelector: (c) => c.id,
toolbarActions: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
onPressed: () => ref.read(_gridProvider.notifier).refresh(),
),
],
);
}
}
class _ContainerCell extends StatelessWidget {
const _ContainerCell({required this.container});
final ContainerData container;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
ContainerStatusBadge.fromString(container.state, health: container.health),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
container.name,
style: const TextStyle(fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
Text(
container.image,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
);
}
}
class _PortsCell extends StatelessWidget {
const _PortsCell({required this.ports});
final List<ContainerPort> ports;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
if (ports.isEmpty) {
return Text(
'-',
style: TextStyle(
fontSize: 12,
color: colorScheme.outline,
),
);
}
return Text(
ports.map((p) => p.formatted).join(', '),
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
),
overflow: TextOverflow.ellipsis,
);
}
}
@@ -0,0 +1,91 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/features/control_room/containers/data/repositories/container_repository_impl.dart';
import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart';
import 'package:tatlock_ui/features/control_room/stacks/presentation/providers/stacks_provider.dart';
part 'containers_provider.g.dart';
/// Provides all containers.
@riverpod
Future<List<Container>> allContainers(Ref ref) async {
final repository = ref.watch(containerRepositoryProvider);
return repository.getContainers();
}
/// Provides containers filtered by the selected stack.
@riverpod
Future<List<Container>> containers(Ref ref) async {
final repository = ref.watch(containerRepositoryProvider);
final selectedStack = ref.watch(selectedStackProvider);
if (selectedStack == null) {
return repository.getContainers();
}
return repository.getContainersByStack(selectedStack);
}
/// Provides a single container by ID.
@riverpod
Future<Container> container(Ref ref, String id) async {
final repository = ref.watch(containerRepositoryProvider);
return repository.getContainer(id);
}
/// Controller for container actions.
@riverpod
class ContainerActions extends _$ContainerActions {
@override
AsyncValue<void> build() => const AsyncValue.data(null);
Future<void> start(String id) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final repository = ref.read(containerRepositoryProvider);
await repository.startContainer(id);
ref.invalidate(allContainersProvider);
ref.invalidate(containersProvider);
});
}
Future<void> stop(String id) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final repository = ref.read(containerRepositoryProvider);
await repository.stopContainer(id);
ref.invalidate(allContainersProvider);
ref.invalidate(containersProvider);
});
}
Future<void> restart(String id) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final repository = ref.read(containerRepositoryProvider);
await repository.restartContainer(id);
ref.invalidate(allContainersProvider);
ref.invalidate(containersProvider);
});
}
Future<void> remove(String id, {bool force = false}) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final repository = ref.read(containerRepositoryProvider);
await repository.removeContainer(id, force: force);
ref.invalidate(allContainersProvider);
ref.invalidate(containersProvider);
});
}
}
/// Container logs provider.
@riverpod
Future<String> containerLogs(
Ref ref,
String id, {
int? tail = 100,
}) async {
final repository = ref.watch(containerRepositoryProvider);
return repository.getContainerLogs(id, tail: tail);
}
@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/features/control_room/containers/presentation/providers/containers_provider.dart';
/// Widget for displaying container logs.
class ContainerLogsViewer extends ConsumerStatefulWidget {
const ContainerLogsViewer({
super.key,
required this.containerId,
required this.containerName,
});
final String containerId;
final String containerName;
@override
ConsumerState<ContainerLogsViewer> createState() =>
_ContainerLogsViewerState();
}
class _ContainerLogsViewerState extends ConsumerState<ContainerLogsViewer> {
final _scrollController = ScrollController();
int _tailLines = 100;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final logsAsync = ref.watch(
containerLogsProvider(widget.containerId, tail: _tailLines),
);
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Toolbar
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
border: Border(
bottom: BorderSide(color: colorScheme.outlineVariant),
),
),
child: Row(
children: [
Text(
widget.containerName,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
// Tail lines selector
DropdownButton<int>(
value: _tailLines,
items: const [
DropdownMenuItem(value: 50, child: Text('50 lines')),
DropdownMenuItem(value: 100, child: Text('100 lines')),
DropdownMenuItem(value: 500, child: Text('500 lines')),
DropdownMenuItem(value: 1000, child: Text('1000 lines')),
],
onChanged: (value) {
if (value != null) {
setState(() => _tailLines = value);
}
},
underline: const SizedBox.shrink(),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.copy, size: 18),
tooltip: 'Copy logs',
onPressed: logsAsync.whenOrNull(
data: (logs) => () async {
await Clipboard.setData(ClipboardData(text: logs));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Logs copied to clipboard')),
);
}
},
),
),
IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: 'Refresh',
onPressed: () {
ref.invalidate(
containerLogsProvider(widget.containerId, tail: _tailLines),
);
},
),
],
),
),
// Logs content
Expanded(
child: logsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: colorScheme.error, size: 48),
const SizedBox(height: 16),
Text('Failed to load logs'),
const SizedBox(height: 8),
Text(
error.toString(),
style: TextStyle(color: colorScheme.outline),
),
],
),
),
data: (logs) => logs.isEmpty
? Center(
child: Text(
'No logs available',
style: TextStyle(color: colorScheme.outline),
),
)
: Container(
color: Colors.black87,
padding: const EdgeInsets.all(12),
child: SelectableText(
logs,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Colors.white70,
height: 1.4,
),
),
),
),
),
],
);
}
}
/// Shows container logs in a bottom sheet.
void showContainerLogs(
BuildContext context, {
required String containerId,
required String containerName,
}) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.3,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => ContainerLogsViewer(
containerId: containerId,
containerName: containerName,
),
),
);
}
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart' hide Container;
import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart';
import 'package:tatlock_ui/features/control_room/containers/presentation/pages/containers_list_page.dart';
/// Status badge for container state with optional health indication.
class ContainerStatusBadge extends StatelessWidget {
const ContainerStatusBadge({
super.key,
required this.state,
this.health,
this.showLabel = true,
});
/// Create badge from a string state value.
factory ContainerStatusBadge.fromString(
String state, {
ContainerHealth? health,
bool showLabel = true,
}) {
return ContainerStatusBadge(
state: _parseState(state),
health: health,
showLabel: showLabel,
);
}
/// Create badge from state enum and status string (parses health from status).
factory ContainerStatusBadge.withStatus({
required ContainerState state,
required String status,
bool showLabel = true,
}) {
return ContainerStatusBadge(
state: state,
health: _parseHealthFromStatus(status),
showLabel: showLabel,
);
}
/// Parse health status from Docker status string.
static ContainerHealth _parseHealthFromStatus(String status) {
final lower = status.toLowerCase();
if (lower.contains('(healthy)')) return ContainerHealth.healthy;
if (lower.contains('(unhealthy)')) return ContainerHealth.unhealthy;
if (lower.contains('(health: starting)')) return ContainerHealth.starting;
return ContainerHealth.none;
}
final ContainerState state;
final ContainerHealth? health;
final bool showLabel;
static ContainerState _parseState(String value) {
return ContainerState.values.firstWhere(
(s) => s.name == value.toLowerCase(),
orElse: () => ContainerState.exited,
);
}
@override
Widget build(BuildContext context) {
final (color, icon, label) = _getStateStyle(context);
Widget badge = DecoratedBox(
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
if (showLabel) ...[
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: color,
),
),
],
],
),
),
);
if (showLabel) {
badge = ConstrainedBox(
constraints: const BoxConstraints(minWidth: 90),
child: badge,
);
}
return badge;
}
(Color, IconData, String) _getStateStyle(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return switch (state) {
ContainerState.running => _getRunningStyle(colorScheme),
ContainerState.paused => (
Colors.orange,
Icons.pause_circle,
'Paused',
),
ContainerState.restarting => (
Colors.blue,
Icons.refresh,
'Restarting',
),
ContainerState.exited => (
colorScheme.outline,
Icons.stop_circle,
'Exited',
),
ContainerState.created => (
colorScheme.outline,
Icons.circle_outlined,
'Created',
),
ContainerState.removing => (
Colors.red,
Icons.delete,
'Removing',
),
ContainerState.dead => (
colorScheme.error,
Icons.error,
'Dead',
),
};
}
/// Get style for running state, factoring in health status.
(Color, IconData, String) _getRunningStyle(ColorScheme colorScheme) {
return switch (health) {
ContainerHealth.healthy => (
Colors.green,
Icons.play_circle,
'Running',
),
ContainerHealth.unhealthy => (
Colors.orange,
Icons.warning_amber_rounded,
'Unhealthy',
),
ContainerHealth.starting => (
Colors.blue,
Icons.hourglass_top,
'Starting',
),
ContainerHealth.none || null => (
Colors.green,
Icons.play_circle,
'Running',
),
};
}
}
@@ -0,0 +1,68 @@
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/control_room/npm/data/models/proxy_host_model.dart';
part 'proxy_hosts_datasource.g.dart';
/// Remote data source for NPM proxy host operations via Core API.
class ProxyHostsDatasource {
ProxyHostsDatasource(this._dio);
final Dio _dio;
/// Gets all configured domains from Core API.
Future<List<DomainInfoModel>> getDomains() async {
final response = await _dio.get<List<dynamic>>(
'/infrastructure/domains',
);
return response.data!
.map((json) => DomainInfoModel.fromJson(json as Map<String, dynamic>))
.toList();
}
/// Gets detailed proxy host configuration by ID.
Future<ProxyHostModel> getProxyHost(int proxyId) async {
final response = await _dio.get<Map<String, dynamic>>(
'/infrastructure/proxy/$proxyId',
);
return ProxyHostModel.fromJson(response.data!);
}
/// Creates a new proxy host.
Future<void> createProxyHost({
required List<String> domainNames,
required String forwardScheme,
required String forwardHost,
required int forwardPort,
bool sslEnabled = false,
}) async {
await _dio.post<void>(
'/infrastructure/proxy',
data: {
'domain_names': domainNames,
'forward_scheme': forwardScheme,
'forward_host': forwardHost,
'forward_port': forwardPort,
'ssl_enabled': sslEnabled,
},
);
}
/// Updates an existing proxy host.
Future<void> updateProxyHost(int proxyId, Map<String, dynamic> config) async {
await _dio.put<void>(
'/infrastructure/proxy/$proxyId',
data: config,
);
}
}
/// Provides the proxy hosts datasource.
@riverpod
ProxyHostsDatasource proxyHostsDatasource(Ref ref) {
final dio = ref.watch(coreApiClientProvider);
return ProxyHostsDatasource(dio);
}
@@ -0,0 +1,137 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:tatlock_ui/features/control_room/npm/domain/entities/proxy_host.dart';
part 'proxy_host_model.freezed.dart';
part 'proxy_host_model.g.dart';
/// Converts API values that can be either int or bool (false) to int?.
/// NPM returns false instead of null for missing IDs.
class NullableIntOrBoolConverter implements JsonConverter<int?, dynamic> {
const NullableIntOrBoolConverter();
@override
int? fromJson(dynamic json) {
if (json == null || json == false) return null;
if (json is int) return json;
if (json is num) return json.toInt();
return null;
}
@override
dynamic toJson(int? object) => object;
}
/// Converts API values that can be either int or bool to int.
/// NPM returns true/false for some 0/1 fields.
class IntOrBoolConverter implements JsonConverter<int, dynamic> {
const IntOrBoolConverter();
@override
int fromJson(dynamic json) {
if (json == null) return 0;
if (json is bool) return json ? 1 : 0;
if (json is int) return json;
if (json is num) return json.toInt();
return 0;
}
@override
dynamic toJson(int object) => object;
}
/// Proxy host data model for API serialization.
///
/// Maps to the NPM API response format via Core API.
@freezed
sealed class ProxyHostModel with _$ProxyHostModel {
const factory ProxyHostModel({
required int id,
@JsonKey(name: 'domain_names') required List<String> domainNames,
@JsonKey(name: 'forward_scheme') required String forwardScheme,
@JsonKey(name: 'forward_host') required String forwardHost,
@JsonKey(name: 'forward_port') required int forwardPort,
@JsonKey(name: 'ssl_forced') @Default(false) bool sslForced,
@NullableIntOrBoolConverter() @JsonKey(name: 'certificate_id') int? certificateId,
@IntOrBoolConverter() @Default(1) int enabled,
@IntOrBoolConverter() @JsonKey(name: 'http2_support') @Default(0) int http2Support,
@IntOrBoolConverter() @JsonKey(name: 'hsts_enabled') @Default(0) int hstsEnabled,
@NullableIntOrBoolConverter() @JsonKey(name: 'access_list_id') int? accessListId,
@IntOrBoolConverter() @JsonKey(name: 'caching_enabled') @Default(0) int cachingEnabled,
@IntOrBoolConverter() @JsonKey(name: 'block_exploits') @Default(0) int blockExploits,
@IntOrBoolConverter() @JsonKey(name: 'allow_websocket_upgrade') @Default(0) int allowWebsocketUpgrade,
@JsonKey(name: 'created_on') String? createdOn,
@JsonKey(name: 'modified_on') String? modifiedOn,
@Default([]) List<ProxyLocationModel> locations,
}) = _ProxyHostModel;
const ProxyHostModel._();
factory ProxyHostModel.fromJson(Map<String, dynamic> json) =>
_$ProxyHostModelFromJson(json);
/// Converts to domain entity.
ProxyHost toEntity() {
return ProxyHost(
id: id,
domainNames: domainNames,
forwardScheme: forwardScheme,
forwardHost: forwardHost,
forwardPort: forwardPort,
sslEnabled: certificateId != null && certificateId! > 0,
certificateId: certificateId,
enabled: enabled == 1,
http2Support: http2Support == 1,
hstsEnabled: hstsEnabled == 1,
forceSSL: sslForced,
accessListId: accessListId,
cacheAssets: cachingEnabled == 1,
blockExploits: blockExploits == 1,
websocketSupport: allowWebsocketUpgrade == 1,
locations: locations.map((l) => l.toEntity()).toList(),
createdAt: createdOn != null ? DateTime.tryParse(createdOn!) : null,
modifiedAt: modifiedOn != null ? DateTime.tryParse(modifiedOn!) : null,
);
}
}
/// Proxy location model.
@freezed
sealed class ProxyLocationModel with _$ProxyLocationModel {
const factory ProxyLocationModel({
required String path,
@JsonKey(name: 'forward_scheme') required String forwardScheme,
@JsonKey(name: 'forward_host') required String forwardHost,
@JsonKey(name: 'forward_port') required int forwardPort,
}) = _ProxyLocationModel;
const ProxyLocationModel._();
factory ProxyLocationModel.fromJson(Map<String, dynamic> json) =>
_$ProxyLocationModelFromJson(json);
ProxyLocation toEntity() {
return ProxyLocation(
path: path,
forwardScheme: forwardScheme,
forwardHost: forwardHost,
forwardPort: forwardPort,
);
}
}
/// Domain info model for the /infrastructure/domains endpoint.
///
/// This is a simpler model used for listing domains.
@freezed
sealed class DomainInfoModel with _$DomainInfoModel {
const factory DomainInfoModel({
required String domain,
required String service,
@JsonKey(name: 'proxy_host_id') required int proxyHostId,
@JsonKey(name: 'ssl_enabled') @Default(false) bool sslEnabled,
@JsonKey(name: 'certificate_id') int? certificateId,
}) = _DomainInfoModel;
factory DomainInfoModel.fromJson(Map<String, dynamic> json) =>
_$DomainInfoModelFromJson(json);
}
@@ -0,0 +1,93 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'proxy_host.freezed.dart';
/// NPM proxy host entity representing a domain configuration.
@freezed
sealed class ProxyHost with _$ProxyHost {
const factory ProxyHost({
/// Proxy host ID.
required int id,
/// Domain names (can have multiple).
required List<String> domainNames,
/// Forward scheme (http or https).
required String forwardScheme,
/// Forward host (IP or hostname).
required String forwardHost,
/// Forward port.
required int forwardPort,
/// Whether SSL is enabled.
required bool sslEnabled,
/// SSL certificate ID (if enabled).
int? certificateId,
/// Whether the host is enabled.
@Default(true) bool enabled,
/// Whether HTTP/2 is enabled.
@Default(false) bool http2Support,
/// Whether HSTS is enabled.
@Default(false) bool hstsEnabled,
/// Whether to force SSL.
@Default(false) bool forceSSL,
/// Custom locations (advanced nginx config).
@Default([]) List<ProxyLocation> locations,
/// Access list ID (for auth).
int? accessListId,
/// Cache assets enabled.
@Default(false) bool cacheAssets,
/// Block common exploits.
@Default(false) bool blockExploits,
/// Websocket support.
@Default(false) bool websocketSupport,
/// Created timestamp.
DateTime? createdAt,
/// Modified timestamp.
DateTime? modifiedAt,
}) = _ProxyHost;
const ProxyHost._();
/// Primary domain (first in list).
String get primaryDomain =>
domainNames.isNotEmpty ? domainNames.first : 'Unknown';
/// Forward URL (scheme://host:port).
String get forwardUrl => '$forwardScheme://$forwardHost:$forwardPort';
/// SSL status label.
String get sslStatus => sslEnabled ? 'SSL Enabled' : 'No SSL';
}
/// Proxy location for advanced routing.
@freezed
sealed class ProxyLocation with _$ProxyLocation {
const factory ProxyLocation({
/// Location path.
required String path,
/// Forward scheme.
required String forwardScheme,
/// Forward host.
required String forwardHost,
/// Forward port.
required int forwardPort,
}) = _ProxyLocation;
}
@@ -0,0 +1,297 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/features/control_room/npm/domain/entities/proxy_host.dart';
import 'package:tatlock_ui/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart';
import 'package:tatlock_ui/features/control_room/npm/presentation/widgets/proxy_host_form.dart';
import 'package:tatlock_ui/shared/widgets/entity_page.dart';
/// Unified page for proxy host create, view, and edit.
///
/// Usage:
/// - Create: `ProxyHostPage.create(onClose: ...)`
/// - View/Edit: `ProxyHostPage(proxyHostId: 123, onClose: ...)`
class ProxyHostPage extends ConsumerStatefulWidget {
const ProxyHostPage({
super.key,
required this.proxyHostId,
required this.onClose,
}) : _isCreate = false;
const ProxyHostPage.create({
super.key,
required this.onClose,
}) : proxyHostId = null,
_isCreate = true;
final int? proxyHostId;
final VoidCallback onClose;
final bool _isCreate;
@override
ConsumerState<ProxyHostPage> createState() => _ProxyHostPageState();
}
class _ProxyHostPageState extends ConsumerState<ProxyHostPage>
with EntityPageModeMixin {
@override
void initState() {
super.initState();
// Start in create mode if no ID, otherwise view mode
mode = widget._isCreate ? EntityPageMode.create : EntityPageMode.view;
}
String get _title {
switch (mode) {
case EntityPageMode.create:
return 'New Proxy Host';
case EntityPageMode.view:
return 'Proxy Host Details';
case EntityPageMode.edit:
return 'Edit Proxy Host';
}
}
void _handleSaved() {
if (widget._isCreate) {
// After create, close and return to list
widget.onClose();
} else {
// After edit, return to view mode and refresh
stopEditing();
ref.invalidate(proxyHostProvider(widget.proxyHostId!));
}
ref.invalidate(domainsProvider);
}
@override
Widget build(BuildContext context) {
// Create mode - no need to fetch existing data
if (widget._isCreate) {
return EntityPageScaffold(
title: _title,
onBack: widget.onClose,
child: ProxyHostForm(
mode: EntityPageMode.create,
onCancel: widget.onClose,
onSaved: _handleSaved,
),
);
}
// View/Edit mode - fetch existing proxy host
final proxyHostAsync = ref.watch(proxyHostProvider(widget.proxyHostId!));
return EntityPageScaffold(
title: _title,
onBack: widget.onClose,
actions: [
if (isViewing)
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Edit',
onPressed: startEditing,
),
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
onPressed: () =>
ref.invalidate(proxyHostProvider(widget.proxyHostId!)),
),
],
child: EntityAsyncContent<ProxyHost>(
isLoading: proxyHostAsync.isLoading,
error: proxyHostAsync.error,
data: proxyHostAsync.value,
onRetry: () => ref.invalidate(proxyHostProvider(widget.proxyHostId!)),
builder: (proxyHost) {
if (isEditing) {
return ProxyHostForm(
mode: EntityPageMode.edit,
proxyHost: proxyHost,
onCancel: stopEditing,
onSaved: _handleSaved,
);
}
return _ProxyHostView(proxyHost: proxyHost);
},
),
);
}
}
/// Read-only view of proxy host details.
class _ProxyHostView extends StatelessWidget {
const _ProxyHostView({required this.proxyHost});
final ProxyHost proxyHost;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Domain names
EntitySection(
title: 'Domain Names',
icon: Icons.public,
child: Wrap(
spacing: 8,
runSpacing: 8,
children: proxyHost.domainNames.map((domain) {
return Chip(
avatar: Icon(
proxyHost.sslEnabled ? Icons.lock : Icons.lock_open,
size: 16,
color: proxyHost.sslEnabled ? Colors.green : null,
),
label: Text(domain),
);
}).toList(),
),
),
const SizedBox(height: 24),
// Forward destination
EntitySection(
title: 'Forward Destination',
icon: Icons.arrow_forward,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.dns,
color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
proxyHost.forwardUrl,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontFamily: 'monospace'),
),
const SizedBox(height: 4),
Text(
'${proxyHost.forwardScheme.toUpperCase()}${proxyHost.forwardHost}:${proxyHost.forwardPort}',
style: TextStyle(
color: Theme.of(context).colorScheme.outline),
),
],
),
),
],
),
),
),
),
const SizedBox(height: 24),
// SSL Settings
EntitySection(
title: 'SSL Settings',
icon: Icons.verified_user,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
EntitySettingRow(
label: 'SSL Enabled',
value: proxyHost.sslEnabled,
),
if (proxyHost.sslEnabled) ...[
const Divider(),
EntitySettingRow(
label: 'Force SSL',
value: proxyHost.forceSSL,
subtitle: 'Redirect HTTP to HTTPS',
),
const Divider(),
EntitySettingRow(
label: 'HTTP/2 Support',
value: proxyHost.http2Support,
),
const Divider(),
EntitySettingRow(
label: 'HSTS Enabled',
value: proxyHost.hstsEnabled,
),
],
],
),
),
),
),
const SizedBox(height: 24),
// Advanced Settings
EntitySection(
title: 'Advanced Settings',
icon: Icons.settings,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
EntitySettingRow(
label: 'WebSocket Support',
value: proxyHost.websocketSupport,
),
const Divider(),
EntitySettingRow(
label: 'Block Exploits',
value: proxyHost.blockExploits,
),
const Divider(),
EntitySettingRow(
label: 'Cache Assets',
value: proxyHost.cacheAssets,
),
const Divider(),
EntitySettingRow(
label: 'Enabled',
value: proxyHost.enabled,
),
],
),
),
),
),
// Locations (if any)
if (proxyHost.locations.isNotEmpty) ...[
const SizedBox(height: 24),
EntitySection(
title: 'Custom Locations',
icon: Icons.route,
child: Card(
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: proxyHost.locations.length,
separatorBuilder: (_, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final loc = proxyHost.locations[index];
return ListTile(
leading: const Icon(Icons.subdirectory_arrow_right),
title: Text(loc.path,
style: const TextStyle(fontFamily: 'monospace')),
subtitle: Text(
'${loc.forwardScheme}://${loc.forwardHost}:${loc.forwardPort}'),
);
},
),
),
),
],
],
),
);
}
}
@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/shared/components/data_grid/adapters/core_api_source.dart';
import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart';
/// Domain info data class for DataGrid display.
class DomainData {
DomainData({
required this.domain,
required this.service,
required this.proxyHostId,
required this.sslEnabled,
this.certificateId,
});
factory DomainData.fromJson(Map<String, dynamic> json) => DomainData(
domain: json['domain'] as String,
service: json['service'] as String,
proxyHostId: json['proxy_host_id'] as int,
sslEnabled: json['ssl_enabled'] as bool? ?? false,
certificateId: json['certificate_id'] as int?,
);
final String domain;
final String service;
final int proxyHostId;
final bool sslEnabled;
final int? certificateId;
}
/// Page displaying the list of proxy hosts (domains) from NPM.
class ProxyHostsPage extends ConsumerStatefulWidget {
const ProxyHostsPage({super.key});
@override
ConsumerState<ProxyHostsPage> createState() => _ProxyHostsPageState();
}
class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
late final StateNotifierProvider<DataGridController<DomainData>,
DataGridState<DomainData>> _gridProvider;
@override
void initState() {
super.initState();
final dio = ref.read(coreApiClientProvider);
final source = CoreApiDataSource<DomainData>(
dio: dio,
endpoint: '/infrastructure/domains',
fromJson: DomainData.fromJson,
);
_gridProvider = dataGridProvider<DomainData>(
source: source,
config: _buildConfig(),
idSelector: (d) => d.proxyHostId.toString(),
);
}
DataGridConfig<DomainData> _buildConfig() {
return DataGridConfig<DomainData>(
columns: [
DataGridColumn<DomainData>(
header: 'Domain',
valueBuilder: (d) => d.domain,
sortable: true,
searchable: true,
width: const DataGridColumnWidth.flex(2),
cellBuilder: (context, d) => _DomainCell(domain: d),
),
DataGridColumn<DomainData>(
header: 'Service',
valueBuilder: (d) => d.service,
width: const DataGridColumnWidth.flex(1),
cellBuilder: (context, d) => _ServiceCell(service: d.service),
),
DataGridColumn<DomainData>(
header: 'SSL',
valueBuilder: (d) => d.sslEnabled ? 'Enabled' : 'Disabled',
width: const DataGridColumnWidth.fixed(100),
alignment: DataGridColumnAlignment.end,
cellBuilder: (context, d) => _SslBadge(enabled: d.sslEnabled),
),
],
actions: [
DataGridAction<DomainData>(
icon: Icons.edit,
label: 'Edit',
onTap: (d) async => _viewDomain(d),
),
],
enableSearch: true,
searchHint: 'Search domains...',
showHeader: true,
showFooter: true,
);
}
void _viewDomain(DomainData domain) {
ref.read(selectedProxyHostProvider.notifier).select(domain.proxyHostId);
}
void _createNew() {
ref.read(creatingProxyHostProvider.notifier).start();
}
@override
Widget build(BuildContext context) {
final selectedId = ref.watch(selectedProxyHostProvider);
final isCreating = ref.watch(creatingProxyHostProvider);
// Show create page if creating new
if (isCreating) {
return ProxyHostPage.create(
onClose: () {
ref.read(creatingProxyHostProvider.notifier).stop();
ref.read(_gridProvider.notifier).refresh();
},
);
}
// Show detail page if a proxy host is selected
if (selectedId != null) {
return ProxyHostPage(
proxyHostId: selectedId,
onClose: () {
ref.read(selectedProxyHostProvider.notifier).clear();
ref.read(_gridProvider.notifier).refresh();
},
);
}
return DataGrid<DomainData>(
provider: _gridProvider,
config: _buildConfig(),
idSelector: (d) => d.proxyHostId.toString(),
toolbarActions: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
onPressed: () => ref.read(_gridProvider.notifier).refresh(),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _createNew,
icon: const Icon(Icons.add),
label: const Text('New'),
),
],
);
}
}
class _DomainCell extends StatelessWidget {
const _DomainCell({required this.domain});
final DomainData domain;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: domain.sslEnabled
? Colors.green.withValues(alpha: 0.1)
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
domain.sslEnabled ? Icons.lock : Icons.lock_open,
size: 20,
color: domain.sslEnabled ? Colors.green : colorScheme.outline,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
domain.domain,
style: const TextStyle(fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}
class _ServiceCell extends StatelessWidget {
const _ServiceCell({required this.service});
final String service;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Text(
service,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
),
overflow: TextOverflow.ellipsis,
);
}
}
class _SslBadge extends StatelessWidget {
const _SslBadge({required this.enabled});
final bool enabled;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: enabled
? Colors.green.withValues(alpha: 0.1)
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(
enabled ? 'SSL' : 'HTTP',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: enabled ? Colors.green : colorScheme.outline,
),
),
);
}
}
@@ -0,0 +1,72 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/features/control_room/npm/data/datasources/proxy_hosts_datasource.dart';
import 'package:tatlock_ui/features/control_room/npm/data/models/proxy_host_model.dart';
import 'package:tatlock_ui/features/control_room/npm/domain/entities/proxy_host.dart';
part 'proxy_hosts_provider.g.dart';
/// Provider for the list of configured domains.
@riverpod
Future<List<DomainInfoModel>> domains(Ref ref) async {
final datasource = ref.watch(proxyHostsDatasourceProvider);
return datasource.getDomains();
}
/// Provider for a specific proxy host details.
@riverpod
Future<ProxyHost> proxyHost(Ref ref, int proxyId) async {
final datasource = ref.watch(proxyHostsDatasourceProvider);
final model = await datasource.getProxyHost(proxyId);
return model.toEntity();
}
/// Provider for selected proxy host ID (for detail view).
@riverpod
class SelectedProxyHost extends _$SelectedProxyHost {
@override
int? build() => null;
void select(int id) => state = id;
void clear() => state = null;
}
/// Provider for tracking if we're creating a new proxy host.
@riverpod
class CreatingProxyHost extends _$CreatingProxyHost {
@override
bool build() => false;
void start() => state = true;
void stop() => state = false;
}
/// Provider for creating a new proxy host.
@riverpod
Future<void> createProxyHost(
Ref ref, {
required List<String> domainNames,
required String forwardScheme,
required String forwardHost,
required int forwardPort,
bool sslEnabled = false,
}) async {
final datasource = ref.watch(proxyHostsDatasourceProvider);
await datasource.createProxyHost(
domainNames: domainNames,
forwardScheme: forwardScheme,
forwardHost: forwardHost,
forwardPort: forwardPort,
sslEnabled: sslEnabled,
);
}
/// Provider for updating an existing proxy host.
@riverpod
Future<void> updateProxyHost(
Ref ref, {
required int id,
required Map<String, dynamic> config,
}) async {
final datasource = ref.watch(proxyHostsDatasourceProvider);
await datasource.updateProxyHost(id, config);
}
@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/features/control_room/npm/domain/entities/proxy_host.dart';
import 'package:tatlock_ui/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart';
import 'package:tatlock_ui/shared/widgets/entity_form_dialog.dart';
import 'package:tatlock_ui/shared/widgets/entity_page.dart';
/// Form for creating or editing a proxy host.
class ProxyHostForm extends ConsumerStatefulWidget {
const ProxyHostForm({
super.key,
this.proxyHost,
this.mode = EntityPageMode.create,
required this.onCancel,
required this.onSaved,
});
/// Existing proxy host for editing (null for create).
final ProxyHost? proxyHost;
/// Form mode - create or edit.
final EntityPageMode mode;
final VoidCallback onCancel;
final VoidCallback onSaved;
bool get isEditing => mode == EntityPageMode.edit;
@override
ConsumerState<ProxyHostForm> createState() => _ProxyHostFormState();
}
class _ProxyHostFormState extends ConsumerState<ProxyHostForm> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _domainController;
late final TextEditingController _forwardHostController;
late final TextEditingController _forwardPortController;
late String _forwardScheme;
late bool _forceSSL;
late bool _http2Support;
late bool _websocketSupport;
late bool _blockExploits;
late bool _cacheAssets;
bool _isSaving = false;
String? _error;
@override
void initState() {
super.initState();
final host = widget.proxyHost;
_domainController = TextEditingController(
text: host?.domainNames.join(', ') ?? '',
);
_forwardHostController = TextEditingController(
text: host?.forwardHost ?? '',
);
_forwardPortController = TextEditingController(
text: host?.forwardPort.toString() ?? '80',
);
_forwardScheme = host?.forwardScheme ?? 'http';
_forceSSL = host?.forceSSL ?? false;
_http2Support = host?.http2Support ?? false;
_websocketSupport = host?.websocketSupport ?? false;
_blockExploits = host?.blockExploits ?? true;
_cacheAssets = host?.cacheAssets ?? false;
}
@override
void dispose() {
_domainController.dispose();
_forwardHostController.dispose();
_forwardPortController.dispose();
super.dispose();
}
Future<void> _handleSave() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isSaving = true;
_error = null;
});
try {
final domains = _domainController.text
.split(',')
.map((d) => d.trim())
.where((d) => d.isNotEmpty)
.toList();
if (widget.isEditing) {
await ref.read(updateProxyHostProvider(
id: widget.proxyHost!.id,
config: {
'domain_names': domains,
'forward_scheme': _forwardScheme,
'forward_host': _forwardHostController.text,
'forward_port': int.parse(_forwardPortController.text),
'ssl_forced': _forceSSL,
'http2_support': _http2Support ? 1 : 0,
'allow_websocket_upgrade': _websocketSupport ? 1 : 0,
'block_exploits': _blockExploits ? 1 : 0,
'caching_enabled': _cacheAssets ? 1 : 0,
},
).future);
} else {
await ref.read(createProxyHostProvider(
domainNames: domains,
forwardScheme: _forwardScheme,
forwardHost: _forwardHostController.text,
forwardPort: int.parse(_forwardPortController.text),
).future);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.isEditing
? 'Proxy host updated successfully'
: 'Proxy host created successfully'),
backgroundColor: Colors.green,
),
);
widget.onSaved();
}
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) {
setState(() => _isSaving = false);
}
}
}
@override
Widget build(BuildContext context) {
return EntityForm(
formKey: _formKey,
mode: widget.mode,
onCancel: widget.onCancel,
onSave: _handleSave,
isSaving: _isSaving,
error: _error,
children: [
// Domain Names
Text('Domain Names', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
TextFormField(
controller: _domainController,
decoration: const InputDecoration(
hintText: 'example.com, www.example.com',
helperText: 'Separate multiple domains with commas',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'At least one domain is required';
}
return null;
},
),
const SizedBox(height: 24),
// Forward Destination
Text('Forward Destination', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Scheme dropdown
SizedBox(
width: 120,
child: DropdownButtonFormField<String>(
initialValue: _forwardScheme,
decoration: const InputDecoration(
labelText: 'Scheme',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 'http', child: Text('HTTP')),
DropdownMenuItem(value: 'https', child: Text('HTTPS')),
],
onChanged: (value) {
if (value != null) setState(() => _forwardScheme = value);
},
),
),
const SizedBox(width: 12),
// Host
Expanded(
flex: 2,
child: TextFormField(
controller: _forwardHostController,
decoration: const InputDecoration(
labelText: 'Host',
hintText: '192.168.1.100 or hostname',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Host is required';
}
return null;
},
),
),
const SizedBox(width: 12),
// Port
SizedBox(
width: 100,
child: TextFormField(
controller: _forwardPortController,
decoration: const InputDecoration(
labelText: 'Port',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Required';
}
final port = int.tryParse(value);
if (port == null || port < 1 || port > 65535) {
return 'Invalid';
}
return null;
},
),
),
],
),
const SizedBox(height: 24),
// SSL & Security Options
Text('SSL & Security', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Card(
child: Column(
children: [
SwitchListTile(
title: const Text('Force SSL'),
subtitle: const Text('Redirect HTTP to HTTPS'),
value: _forceSSL,
onChanged: (v) => setState(() => _forceSSL = v),
),
const Divider(height: 1),
SwitchListTile(
title: const Text('HTTP/2 Support'),
value: _http2Support,
onChanged: (v) => setState(() => _http2Support = v),
),
const Divider(height: 1),
SwitchListTile(
title: const Text('Block Common Exploits'),
value: _blockExploits,
onChanged: (v) => setState(() => _blockExploits = v),
),
],
),
),
const SizedBox(height: 24),
// Advanced Options
Text('Advanced', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Card(
child: Column(
children: [
SwitchListTile(
title: const Text('WebSocket Support'),
value: _websocketSupport,
onChanged: (v) => setState(() => _websocketSupport = v),
),
const Divider(height: 1),
SwitchListTile(
title: const Text('Cache Assets'),
value: _cacheAssets,
onChanged: (v) => setState(() => _cacheAssets = v),
),
],
),
),
],
);
}
}
@@ -0,0 +1,403 @@
import 'package:flutter/material.dart' hide Stack;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/features/control_room/containers/presentation/pages/containers_list_page.dart';
import 'package:tatlock_ui/features/control_room/npm/presentation/pages/proxy_hosts_page.dart';
import 'package:tatlock_ui/features/control_room/router.dart';
import 'package:tatlock_ui/features/control_room/stacks/data/repositories/stack_repository_impl.dart';
import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart';
import 'package:tatlock_ui/features/control_room/stacks/presentation/pages/stack_detail_page.dart';
import 'package:tatlock_ui/features/control_room/stacks/presentation/providers/stacks_provider.dart';
import 'package:tatlock_ui/features/control_room/stacks/presentation/widgets/stack_list_tile.dart';
import 'package:tatlock_ui/shared/layouts/widgets/filter_panel.dart';
import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart';
/// Main Control Room page with nav panel and section content.
class ControlRoomPage extends ConsumerWidget {
const ControlRoomPage({
super.key,
this.nav = ControlRoomNav.containers,
});
/// The current nav item to display.
final ControlRoomNav nav;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: Row(
children: [
// Nav Panel - section navigation with grouping
NavPanel(
title: 'Sections',
icon: Icons.dns_outlined,
items: ControlRoomNav.values.map((n) => n.toNavItem()).toList(),
selectedId: nav.id,
onItemSelected: (id) {
final newNav = ControlRoomNav.values.firstWhere(
(n) => n.id == id,
);
// Navigate to section URL
context.go(pathForNav(newNav));
// Clear stack selection when changing sections
ref.read(selectedStackProvider.notifier).clear();
},
),
// Divider
VerticalDivider(
width: 1,
thickness: 1,
color: colorScheme.outlineVariant,
),
// Section content
Expanded(
child: _SectionContent(nav: nav),
),
],
),
);
}
}
/// Renders content for the selected nav item.
class _SectionContent extends ConsumerWidget {
const _SectionContent({required this.nav});
final ControlRoomNav nav;
@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),
),
],
);
}
}
/// Placeholder for nav items not yet implemented.
class _PlaceholderSection extends StatelessWidget {
const _PlaceholderSection({required this.nav});
final ControlRoomNav nav;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
nav.icon,
size: 64,
color: colorScheme.outline,
),
const SizedBox(height: 16),
Text(
nav.label,
style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'${nav.section} • Coming soon',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.outline,
),
),
],
),
);
}
}
/// Stacks filter panel for Containers section (includes "All Containers" option).
class _StacksFilterPanel extends ConsumerWidget {
const _StacksFilterPanel();
@override
Widget build(BuildContext context, WidgetRef ref) {
final stacksAsync = ref.watch(stacksProvider);
final selectedStack = ref.watch(selectedStackProvider);
final colorScheme = Theme.of(context).colorScheme;
return FilterPanel(
title: 'Stacks',
icon: Icons.layers,
onRefresh: () => ref.invalidate(stacksProvider),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// All containers option
ListTile(
selected: selectedStack == null,
selectedTileColor:
colorScheme.primaryContainer.withValues(alpha: 0.3),
leading: Icon(
Icons.all_inbox,
color: selectedStack == null
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
title: const Text('All Containers'),
onTap: () => ref.read(selectedStackProvider.notifier).clear(),
),
const Divider(height: 1),
// Stacks list
Expanded(
child: stacksAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, _) => Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
color: colorScheme.error,
),
const SizedBox(height: 8),
Text(
'Failed to load stacks',
style: TextStyle(color: colorScheme.error),
),
],
),
),
),
data: (stacks) => _StacksList(
stacks: stacks,
selectedStackId: selectedStack,
onStackSelected: (id) =>
ref.read(selectedStackProvider.notifier).select(id),
onStackAction: (id, action) =>
_handleStackAction(context, ref, id, action),
),
),
),
],
),
);
}
Future<void> _handleStackAction(
BuildContext context,
WidgetRef ref,
String stackId,
String action,
) async {
final repository = ref.read(stackRepositoryProvider);
final messenger = ScaffoldMessenger.of(context);
final colorScheme = Theme.of(context).colorScheme;
try {
switch (action) {
case 'start':
await repository.startStack(stackId);
case 'stop':
await repository.stopStack(stackId);
case 'restart':
await repository.restartStack(stackId);
}
// Refresh data
ref.invalidate(stacksProvider);
// Show success snackbar
messenger.showSnackBar(
SnackBar(
content: Text('Stack ${action}ed successfully'),
backgroundColor: colorScheme.primaryContainer,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(
content: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
'Failed to $action stack: ${e.toString()}',
style: TextStyle(color: colorScheme.onErrorContainer),
),
),
],
),
backgroundColor: colorScheme.errorContainer,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 4),
),
);
}
}
}
class _StacksList extends StatefulWidget {
const _StacksList({
required this.stacks,
required this.selectedStackId,
required this.onStackSelected,
required this.onStackAction,
});
final List<Stack> stacks;
final String? selectedStackId;
final void Function(String) onStackSelected;
final void Function(String, String) onStackAction;
@override
State<_StacksList> createState() => _StacksListState();
}
class _StacksListState extends State<_StacksList> {
final _searchController = TextEditingController();
String _searchQuery = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<Stack> get _filteredStacks {
if (_searchQuery.isEmpty) return widget.stacks;
final query = _searchQuery.toLowerCase();
return widget.stacks.where((s) {
return s.name.toLowerCase().contains(query);
}).toList();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final filtered = _filteredStacks;
return Column(
children: [
// Search field
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search stacks...',
prefixIcon: const Icon(Icons.search, size: 18),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 16),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
)
: null,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
style: const TextStyle(fontSize: 13),
onChanged: (value) => setState(() => _searchQuery = value),
),
),
// Stack list or empty state
Expanded(
child: filtered.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_searchQuery.isEmpty
? Icons.layers_clear
: Icons.search_off,
size: 32,
color: colorScheme.outline,
),
const SizedBox(height: 8),
Text(
_searchQuery.isEmpty
? 'No stacks found'
: 'No stacks match "$_searchQuery"',
style: TextStyle(
color: colorScheme.outline,
fontSize: 13,
),
textAlign: TextAlign.center,
),
],
),
),
)
: ListView.builder(
itemCount: filtered.length,
itemBuilder: (context, index) {
final stack = filtered[index];
return StackListTile(
stack: stack,
isSelected: stack.id == widget.selectedStackId,
onTap: () => widget.onStackSelected(stack.id),
onStart: () => widget.onStackAction(stack.id, 'start'),
onStop: () => widget.onStackAction(stack.id, 'stop'),
onRestart: () => widget.onStackAction(stack.id, 'restart'),
);
},
),
),
],
);
}
}
+80
View File
@@ -0,0 +1,80 @@
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/shared/layouts/widgets/nav_panel.dart';
/// Route paths for Control Room.
abstract class ControlRoomRoutes {
static const base = '/control-room';
// Portainer
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';
}
/// 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');
const ControlRoomNav(this.id, this.label, this.icon, this.section);
final String id;
final String label;
final IconData icon;
final String section;
NavItem toNavItem() => NavItem(
id: id,
label: label,
icon: icon,
section: section,
);
/// Get route path for this nav item.
String get path => '/control-room/$id';
}
/// Get route path for a nav item.
String pathForNav(ControlRoomNav nav) => nav.path;
/// Control Room routes for go_router.
List<RouteBase> controlRoomRoutes() {
return [
// Redirect /control-room to /control-room/containers
GoRoute(
path: ControlRoomRoutes.base,
name: 'controlRoom',
redirect: (context, state) => ControlRoomRoutes.containers,
),
// Generate routes for all nav items
for (final nav in ControlRoomNav.values)
GoRoute(
path: nav.path,
name: 'controlRoom${_capitalize(nav.id.replaceAll('-', '_'))}',
builder: (context, state) => ControlRoomPage(nav: nav),
),
];
}
String _capitalize(String s) {
if (s.isEmpty) return s;
return s
.split('_')
.map((part) => part[0].toUpperCase() + part.substring(1))
.join('');
}
@@ -0,0 +1,132 @@
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/control_room/containers/data/datasources/containers_datasource.dart';
import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart';
import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart';
part 'stacks_datasource.g.dart';
/// Data source for stack operations.
///
/// Stacks are derived from container labels (Docker Compose convention).
/// The Core API provides endpoints for stack YAML and environment variables.
class StacksDatasource {
StacksDatasource(this._dio, this._containersDatasource);
final Dio _dio;
final ContainersDatasource _containersDatasource;
/// Gets all stacks by aggregating container data.
Future<List<Stack>> getStacks() async {
final containers = await _containersDatasource.getContainers();
final containerEntities = containers.map((c) => c.toEntity()).toList();
// Group containers by stack name
final stackMap = <String, List<Container>>{};
for (final container in containerEntities) {
final stackName = container.stackName;
if (stackName != null) {
stackMap.putIfAbsent(stackName, () => []).add(container);
}
}
// Convert to Stack entities
return stackMap.entries.map((entry) {
final name = entry.key;
final stackContainers = entry.value;
final runningCount = stackContainers.where((c) => c.isRunning).length;
return Stack(
id: name, // Use name as ID for compose stacks
name: name,
type: StackType.compose,
status: runningCount == stackContainers.length
? StackStatus.active
: runningCount > 0
? StackStatus.active
: StackStatus.inactive,
containerCount: stackContainers.length,
runningCount: runningCount,
);
}).toList()
..sort((a, b) => a.name.compareTo(b.name));
}
/// Gets a single stack by ID.
Future<Stack> getStack(String id) async {
final stacks = await getStacks();
return stacks.firstWhere(
(s) => s.id == id,
orElse: () => throw Exception('Stack not found: $id'),
);
}
/// Performs an action on all containers in a stack.
Future<void> stackAction(String stackId, String action) async {
final containers = await _containersDatasource.getContainers();
final stackContainers = containers
.map((c) => c.toEntity())
.where((c) => c.stackId == stackId)
.toList();
for (final container in stackContainers) {
await _containersDatasource.containerAction(container.fullId, action);
}
}
/// Gets the compose YAML for a stack.
Future<String> getStackYaml(String stackId) async {
final response = await _dio.get<String>(
'/infrastructure/stacks/$stackId/compose',
);
return response.data ?? '';
}
/// Updates the compose YAML for a stack.
Future<void> updateStackYaml(String stackId, String yaml) async {
await _dio.put<void>(
'/infrastructure/stacks/$stackId/compose',
data: yaml,
options: Options(contentType: 'text/yaml'),
);
}
/// Gets the environment variables for a stack.
Future<Map<String, String>> getStackEnvVars(String stackId) async {
final response = await _dio.get<Map<String, dynamic>>(
'/infrastructure/stacks/$stackId/env',
);
return response.data?.map((k, v) => MapEntry(k, v.toString())) ?? {};
}
/// Updates the environment variables for a stack.
Future<void> updateStackEnvVars(
String stackId,
Map<String, String> envVars,
) async {
await _dio.put<void>(
'/infrastructure/stacks/$stackId/env',
data: envVars,
);
}
/// Deploys/redeploys a stack with current configuration.
Future<void> deployStack(String stackId) async {
await _dio.post<void>('/infrastructure/stacks/$stackId/deploy');
}
/// Rebuilds a stack (pulls fresh images and recreates containers).
Future<void> rebuildStack(String stackId) async {
await _dio.post<void>('/infrastructure/stacks/$stackId/rebuild');
}
}
/// Provides the stacks datasource.
@riverpod
StacksDatasource stacksDatasource(Ref ref) {
final dio = ref.watch(coreApiClientProvider);
final containersDatasource = ref.watch(containersDatasourceProvider);
return StacksDatasource(dio, containersDatasource);
}
@@ -0,0 +1,61 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart';
part 'stack_model.freezed.dart';
part 'stack_model.g.dart';
/// Stack data model for API serialization.
@freezed
sealed class StackModel with _$StackModel {
const factory StackModel({
required String id,
required String name,
@JsonKey(name: 'type') String? typeString,
@JsonKey(name: 'status') String? statusString,
@JsonKey(name: 'container_count') @Default(0) int containerCount,
@JsonKey(name: 'running_count') @Default(0) int runningCount,
@JsonKey(name: 'compose_file') String? composeFile,
String? environment,
@JsonKey(name: 'created_at') String? createdAt,
@JsonKey(name: 'updated_at') String? updatedAt,
}) = _StackModel;
const StackModel._();
factory StackModel.fromJson(Map<String, dynamic> json) =>
_$StackModelFromJson(json);
/// Converts to domain entity.
Stack toEntity() {
return Stack(
id: id,
name: name,
type: _parseStackType(typeString),
status: _parseStackStatus(statusString),
containerCount: containerCount,
runningCount: runningCount,
composeFile: composeFile,
environment: environment,
createdAt: createdAt != null ? DateTime.tryParse(createdAt!) : null,
updatedAt: updatedAt != null ? DateTime.tryParse(updatedAt!) : null,
);
}
StackType _parseStackType(String? type) {
return switch (type?.toLowerCase()) {
'compose' => StackType.compose,
'swarm' => StackType.swarm,
'kubernetes' || 'k8s' => StackType.kubernetes,
_ => StackType.compose,
};
}
StackStatus _parseStackStatus(String? status) {
return switch (status?.toLowerCase()) {
'active' || 'running' => StackStatus.active,
'inactive' || 'stopped' => StackStatus.inactive,
'error' => StackStatus.error,
_ => StackStatus.unknown,
};
}
}
@@ -0,0 +1,81 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/features/control_room/stacks/data/datasources/stacks_datasource.dart';
import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart';
import 'package:tatlock_ui/features/control_room/stacks/domain/repositories/stack_repository.dart';
part 'stack_repository_impl.g.dart';
/// Implementation of StackRepository.
class StackRepositoryImpl implements StackRepository {
StackRepositoryImpl(this._datasource);
final StacksDatasource _datasource;
@override
Future<List<Stack>> getStacks() async {
return _datasource.getStacks();
}
@override
Future<Stack> getStack(String id) async {
return _datasource.getStack(id);
}
@override
Future<void> startStack(String id) async {
await _datasource.stackAction(id, 'start');
}
@override
Future<void> stopStack(String id) async {
await _datasource.stackAction(id, 'stop');
}
@override
Future<void> restartStack(String id) async {
await _datasource.stackAction(id, 'restart');
}
@override
Future<void> removeStack(String id) async {
// TODO: Implement stack removal
throw UnimplementedError('Stack removal not yet implemented');
}
@override
Future<String> getStackYaml(String id) async {
return _datasource.getStackYaml(id);
}
@override
Future<void> updateStackYaml(String id, String yaml) async {
await _datasource.updateStackYaml(id, yaml);
}
@override
Future<Map<String, String>> getStackEnvVars(String id) async {
return _datasource.getStackEnvVars(id);
}
@override
Future<void> updateStackEnvVars(String id, Map<String, String> envVars) async {
await _datasource.updateStackEnvVars(id, envVars);
}
@override
Future<void> deployStack(String id) async {
await _datasource.deployStack(id);
}
@override
Future<void> rebuildStack(String id) async {
await _datasource.rebuildStack(id);
}
}
/// Provides the stack repository.
@riverpod
StackRepository stackRepository(Ref ref) {
final datasource = ref.watch(stacksDatasourceProvider);
return StackRepositoryImpl(datasource);
}
@@ -0,0 +1,72 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'stack.freezed.dart';
/// Container orchestration stack (Docker Compose stack).
@freezed
sealed class Stack with _$Stack {
const factory Stack({
/// Unique stack identifier.
required String id,
/// Stack name.
required String name,
/// Stack type (compose, swarm, kubernetes).
@Default(StackType.compose) StackType type,
/// Current stack status.
@Default(StackStatus.unknown) StackStatus status,
/// Number of containers in the stack.
@Default(0) int containerCount,
/// Number of running containers.
@Default(0) int runningCount,
/// Path to the compose file (if applicable).
String? composeFile,
/// Compose YAML content.
String? yaml,
/// Environment variables for the stack.
@Default({}) Map<String, String> envVars,
/// Environment name (e.g., production, staging).
String? environment,
/// When the stack was created.
DateTime? createdAt,
/// When the stack was last updated.
DateTime? updatedAt,
}) = _Stack;
const Stack._();
/// Whether all containers in the stack are running.
bool get isHealthy => runningCount == containerCount && containerCount > 0;
/// Whether the stack has any running containers.
bool get hasRunningContainers => runningCount > 0;
/// Whether the stack is partially running.
bool get isPartial =>
runningCount > 0 && runningCount < containerCount;
}
/// Stack orchestration type.
enum StackType {
compose,
swarm,
kubernetes,
}
/// Stack status.
enum StackStatus {
active,
inactive,
error,
unknown,
}
@@ -0,0 +1,40 @@
import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart';
/// Repository interface for stack operations.
abstract class StackRepository {
/// Gets all stacks.
Future<List<Stack>> getStacks();
/// Gets a single stack by ID.
Future<Stack> getStack(String id);
/// Starts all containers in a stack.
Future<void> startStack(String id);
/// Stops all containers in a stack.
Future<void> stopStack(String id);
/// Restarts all containers in a stack.
Future<void> restartStack(String id);
/// Removes a stack (stops and removes containers).
Future<void> removeStack(String id);
/// Gets the compose YAML for a stack.
Future<String> getStackYaml(String id);
/// Updates the compose YAML for a stack.
Future<void> updateStackYaml(String id, String yaml);
/// Gets the environment variables for a stack.
Future<Map<String, String>> getStackEnvVars(String id);
/// Updates the environment variables for a stack.
Future<void> updateStackEnvVars(String id, Map<String, String> envVars);
/// Deploys/redeploys a stack with current configuration.
Future<void> deployStack(String id);
/// Rebuilds a stack (pulls fresh images and recreates containers).
Future<void> rebuildStack(String id);
}
@@ -0,0 +1,343 @@
import 'package:flutter/material.dart' hide Stack;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart'
as container_entity;
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/features/control_room/stacks/domain/entities/stack.dart';
import 'package:tatlock_ui/features/control_room/stacks/presentation/providers/stacks_provider.dart';
import 'package:tatlock_ui/features/control_room/stacks/presentation/widgets/env_vars_editor.dart';
import 'package:tatlock_ui/features/control_room/stacks/presentation/widgets/yaml_editor.dart';
/// Detail page for a selected stack with YAML editor, env vars, and container list.
class StackDetailPage extends ConsumerStatefulWidget {
const StackDetailPage({
super.key,
required this.stackId,
});
final String stackId;
@override
ConsumerState<StackDetailPage> createState() => _StackDetailPageState();
}
class _StackDetailPageState extends ConsumerState<StackDetailPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final stackAsync = ref.watch(stackProvider(widget.stackId));
final containersAsync = ref.watch(containersProvider);
final colorScheme = Theme.of(context).colorScheme;
// Listen for config action results
ref.listen<AsyncValue<void>>(stackConfigActionsProvider, (previous, next) {
if (previous?.isLoading == true && !next.isLoading) {
next.whenOrNull(
data: (_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Configuration saved'),
backgroundColor: colorScheme.primaryContainer,
behavior: SnackBarBehavior.floating,
),
);
},
error: (error, _) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to save: $error'),
backgroundColor: colorScheme.errorContainer,
behavior: SnackBarBehavior.floating,
),
);
},
);
}
});
return Column(
children: [
// Stack editor (top section - 60%)
Expanded(
flex: 6,
child: stackAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('Error: $error')),
data: (stack) => _StackEditor(
stack: stack,
tabController: _tabController,
),
),
),
// Divider with drag handle appearance
Container(
height: 8,
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.outline,
borderRadius: BorderRadius.circular(2),
),
),
),
),
// Container list (bottom section - 40%)
Expanded(
flex: 4,
child: containersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('Error: $error')),
data: (containers) => _CompactContainerList(
containers: containers,
stackId: widget.stackId,
),
),
),
],
);
}
}
/// Stack configuration editor with tabs for YAML and environment variables.
class _StackEditor extends ConsumerWidget {
const _StackEditor({
required this.stack,
required this.tabController,
});
final Stack stack;
final TabController tabController;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: [
// Header with stack info and actions
Container(
height: 56,
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
alignment: Alignment.bottomCenter,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
border: Border(
bottom: BorderSide(color: colorScheme.outlineVariant),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Icon(Icons.layers, color: colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
stack.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
Text(
'${stack.runningCount}/${stack.containerCount} containers running',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
OutlinedButton.icon(
onPressed: () {
ref.read(stackConfigActionsProvider.notifier).rebuild(stack.id);
},
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Rebuild'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: () {
ref.read(stackConfigActionsProvider.notifier).deploy(stack.id);
},
icon: const Icon(Icons.rocket_launch, size: 18),
label: const Text('Deploy'),
),
],
),
),
// Tab bar
TabBar(
controller: tabController,
tabs: const [
Tab(text: 'Compose YAML'),
Tab(text: 'Environment Variables'),
],
),
// Tab content
Expanded(
child: TabBarView(
controller: tabController,
children: [
YamlEditor(stackId: stack.id),
EnvVarsEditor(stackId: stack.id),
],
),
),
],
);
}
}
/// Compact container list for the docked bottom section.
class _CompactContainerList extends ConsumerWidget {
const _CompactContainerList({
required this.containers,
required this.stackId,
});
final List<container_entity.Container> containers;
final String stackId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final actions = ref.read(containerActionsProvider.notifier);
// Filter containers for this stack
final stackContainers = containers
.where((c) => c.stackId == stackId || c.stackName == stackId)
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(Icons.dns, size: 18, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
'Containers (${stackContainers.length})',
style: Theme.of(context).textTheme.titleSmall,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: 'Refresh',
onPressed: () => ref.invalidate(containersProvider),
),
],
),
),
const Divider(height: 1),
// Container list
Expanded(
child: stackContainers.isEmpty
? Center(
child: Text(
'No containers in this stack',
style: TextStyle(color: colorScheme.outline),
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: stackContainers.length,
itemBuilder: (context, index) {
final container = stackContainers[index];
return Card(
margin: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
child: ListTile(
dense: true,
leading: ContainerStatusBadge.withStatus(
state: container.state,
status: container.status,
),
title: Text(
container.name,
style: const TextStyle(fontSize: 13),
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
container.image,
style: TextStyle(
fontSize: 11,
color: colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
trailing: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 18),
itemBuilder: (context) => [
if (container.canStart)
const PopupMenuItem(
value: 'start',
child: Text('Start'),
),
if (container.canStop)
const PopupMenuItem(
value: 'stop',
child: Text('Stop'),
),
if (container.canRestart)
const PopupMenuItem(
value: 'restart',
child: Text('Restart'),
),
const PopupMenuItem(
value: 'logs',
child: Text('View Logs'),
),
],
onSelected: (action) {
switch (action) {
case 'start':
actions.start(container.fullId);
case 'stop':
actions.stop(container.fullId);
case 'restart':
actions.restart(container.fullId);
case 'logs':
showContainerLogs(
context,
containerId: container.fullId,
containerName: container.name,
);
}
},
),
),
);
},
),
),
],
);
}
}
@@ -0,0 +1,91 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/features/control_room/stacks/data/repositories/stack_repository_impl.dart';
import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart';
part 'stacks_provider.g.dart';
/// Provides the list of all stacks.
@riverpod
Future<List<Stack>> stacks(Ref ref) async {
final repository = ref.watch(stackRepositoryProvider);
return repository.getStacks();
}
/// Provides a single stack by ID.
@riverpod
Future<Stack> stack(Ref ref, String id) async {
final repository = ref.watch(stackRepositoryProvider);
return repository.getStack(id);
}
/// Currently selected stack ID (null = all containers).
@riverpod
class SelectedStack extends _$SelectedStack {
@override
String? build() => null;
void select(String? stackId) {
state = stackId;
}
void clear() {
state = null;
}
}
/// Provides the YAML content for a stack.
@riverpod
Future<String> stackYaml(Ref ref, String stackId) async {
final repository = ref.watch(stackRepositoryProvider);
return repository.getStackYaml(stackId);
}
/// Provides the environment variables for a stack.
@riverpod
Future<Map<String, String>> stackEnvVars(Ref ref, String stackId) async {
final repository = ref.watch(stackRepositoryProvider);
return repository.getStackEnvVars(stackId);
}
/// Controller for stack configuration actions.
@riverpod
class StackConfigActions extends _$StackConfigActions {
@override
AsyncValue<void> build() => const AsyncValue.data(null);
Future<void> saveYaml(String stackId, String yaml) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final repository = ref.read(stackRepositoryProvider);
await repository.updateStackYaml(stackId, yaml);
ref.invalidate(stackYamlProvider(stackId));
});
}
Future<void> saveEnvVars(String stackId, Map<String, String> envVars) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final repository = ref.read(stackRepositoryProvider);
await repository.updateStackEnvVars(stackId, envVars);
ref.invalidate(stackEnvVarsProvider(stackId));
});
}
Future<void> deploy(String stackId) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final repository = ref.read(stackRepositoryProvider);
await repository.deployStack(stackId);
ref.invalidate(stacksProvider);
});
}
Future<void> rebuild(String stackId) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final repository = ref.read(stackRepositoryProvider);
await repository.rebuildStack(stackId);
ref.invalidate(stacksProvider);
});
}
}
@@ -0,0 +1,351 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/features/control_room/stacks/presentation/providers/stacks_provider.dart';
/// Environment variables editor for stack configuration.
class EnvVarsEditor extends ConsumerStatefulWidget {
const EnvVarsEditor({
super.key,
required this.stackId,
});
final String stackId;
@override
ConsumerState<EnvVarsEditor> createState() => _EnvVarsEditorState();
}
class _EnvVarsEditorState extends ConsumerState<EnvVarsEditor> {
final List<_EnvVarEntry> _entries = [];
Map<String, String> _originalEnvVars = {};
bool _hasChanges = false;
void _initializeEntries(Map<String, String> envVars) {
if (_originalEnvVars.isEmpty && envVars.isNotEmpty) {
_originalEnvVars = Map.from(envVars);
_entries.clear();
for (final entry in envVars.entries) {
_entries.add(_EnvVarEntry(
keyController: TextEditingController(text: entry.key),
valueController: TextEditingController(text: entry.value),
));
}
}
}
void _checkChanges() {
final currentMap = <String, String>{};
for (final entry in _entries) {
final key = entry.keyController.text.trim();
if (key.isNotEmpty) {
currentMap[key] = entry.valueController.text;
}
}
final hasChanges = !_mapsEqual(currentMap, _originalEnvVars);
if (hasChanges != _hasChanges) {
setState(() => _hasChanges = hasChanges);
}
}
bool _mapsEqual(Map<String, String> a, Map<String, String> b) {
if (a.length != b.length) return false;
for (final key in a.keys) {
if (!b.containsKey(key) || a[key] != b[key]) return false;
}
return true;
}
void _addEntry() {
setState(() {
_entries.add(_EnvVarEntry(
keyController: TextEditingController(),
valueController: TextEditingController(),
));
});
}
void _removeEntry(int index) {
setState(() {
_entries[index].dispose();
_entries.removeAt(index);
_checkChanges();
});
}
void _onSave() {
final envVars = <String, String>{};
for (final entry in _entries) {
final key = entry.keyController.text.trim();
if (key.isNotEmpty) {
envVars[key] = entry.valueController.text;
}
}
ref
.read(stackConfigActionsProvider.notifier)
.saveEnvVars(widget.stackId, envVars);
setState(() {
_originalEnvVars = Map.from(envVars);
_hasChanges = false;
});
}
void _onReset() {
for (final entry in _entries) {
entry.dispose();
}
_entries.clear();
for (final entry in _originalEnvVars.entries) {
_entries.add(_EnvVarEntry(
keyController: TextEditingController(text: entry.key),
valueController: TextEditingController(text: entry.value),
));
}
setState(() => _hasChanges = false);
}
@override
void dispose() {
for (final entry in _entries) {
entry.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final envVarsAsync = ref.watch(stackEnvVarsProvider(widget.stackId));
final colorScheme = Theme.of(context).colorScheme;
final configState = ref.watch(stackConfigActionsProvider);
final isLoading = configState.isLoading;
return envVarsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: colorScheme.error, size: 48),
const SizedBox(height: 16),
Text(
'Failed to load environment variables',
style: TextStyle(color: colorScheme.error),
),
const SizedBox(height: 8),
Text(
error.toString(),
style: TextStyle(color: colorScheme.outline, fontSize: 12),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () =>
ref.invalidate(stackEnvVarsProvider(widget.stackId)),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
data: (envVars) {
_initializeEntries(envVars);
return Column(
children: [
// Toolbar
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
border: Border(
bottom: BorderSide(color: colorScheme.outlineVariant),
),
),
child: Row(
children: [
Icon(
Icons.settings,
size: 18,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
'.env',
style: TextStyle(
fontFamily: 'monospace',
fontSize: 13,
color: colorScheme.onSurfaceVariant,
),
),
if (_hasChanges) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Modified',
style: TextStyle(
fontSize: 11,
color: colorScheme.onTertiaryContainer,
),
),
),
],
const Spacer(),
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Add variable',
onPressed: _addEntry,
),
if (_hasChanges) ...[
TextButton(
onPressed: isLoading ? null : _onReset,
child: const Text('Reset'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: isLoading ? null : _onSave,
icon: isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save, size: 18),
label: const Text('Save'),
),
],
],
),
),
// Environment variables list
Expanded(
child: _entries.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.settings_outlined,
size: 48,
color: colorScheme.outline,
),
const SizedBox(height: 16),
Text(
'No environment variables',
style: TextStyle(color: colorScheme.outline),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: _addEntry,
icon: const Icon(Icons.add),
label: const Text('Add Variable'),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _entries.length,
itemBuilder: (context, index) {
final entry = _entries[index];
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
// Key field
Expanded(
flex: 2,
child: TextField(
controller: entry.keyController,
onChanged: (_) => _checkChanges(),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 13,
),
decoration: InputDecoration(
labelText: 'Key',
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(width: 8),
// Equals sign
Text(
'=',
style: TextStyle(
fontSize: 18,
color: colorScheme.outline,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
// Value field
Expanded(
flex: 3,
child: TextField(
controller: entry.valueController,
onChanged: (_) => _checkChanges(),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 13,
),
decoration: InputDecoration(
labelText: 'Value',
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(width: 8),
// Delete button
IconButton(
icon: Icon(
Icons.delete_outline,
color: colorScheme.error,
),
tooltip: 'Remove',
onPressed: () => _removeEntry(index),
),
],
),
);
},
),
),
],
);
},
);
}
}
/// Helper class to manage key-value pair controllers.
class _EnvVarEntry {
_EnvVarEntry({
required this.keyController,
required this.valueController,
});
final TextEditingController keyController;
final TextEditingController valueController;
void dispose() {
keyController.dispose();
valueController.dispose();
}
}
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart' hide Stack;
import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart';
/// List tile for displaying a stack in the sidebar.
class StackListTile extends StatelessWidget {
const StackListTile({
super.key,
required this.stack,
required this.isSelected,
required this.onTap,
this.onStart,
this.onStop,
this.onRestart,
});
final Stack stack;
final bool isSelected;
final VoidCallback onTap;
final VoidCallback? onStart;
final VoidCallback? onStop;
final VoidCallback? onRestart;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
selected: isSelected,
selectedTileColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
leading: _buildStatusIndicator(context),
title: Text(
stack.name,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(
'${stack.runningCount}/${stack.containerCount} containers',
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
),
trailing: _buildActions(context),
onTap: onTap,
);
}
Widget _buildStatusIndicator(BuildContext context) {
final color = switch (stack.status) {
StackStatus.active when stack.isHealthy => Colors.green,
StackStatus.active when stack.isPartial => Colors.orange,
StackStatus.active => Colors.green,
StackStatus.inactive => Theme.of(context).colorScheme.outline,
StackStatus.error => Theme.of(context).colorScheme.error,
StackStatus.unknown => Theme.of(context).colorScheme.outline,
};
return Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
boxShadow: [
if (stack.isHealthy)
BoxShadow(
color: color.withValues(alpha: 0.4),
blurRadius: 4,
spreadRadius: 1,
),
],
),
);
}
Widget? _buildActions(BuildContext context) {
if (onStart == null && onStop == null && onRestart == null) {
return null;
}
return PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 18),
tooltip: 'Stack actions',
onSelected: (action) {
switch (action) {
case 'start':
onStart?.call();
case 'stop':
onStop?.call();
case 'restart':
onRestart?.call();
}
},
itemBuilder: (context) => [
if (onStart != null && !stack.isHealthy)
const PopupMenuItem(
value: 'start',
child: Row(
children: [
Icon(Icons.play_arrow, size: 18),
SizedBox(width: 8),
Text('Start'),
],
),
),
if (onStop != null && stack.hasRunningContainers)
const PopupMenuItem(
value: 'stop',
child: Row(
children: [
Icon(Icons.stop, size: 18),
SizedBox(width: 8),
Text('Stop'),
],
),
),
if (onRestart != null && stack.hasRunningContainers)
const PopupMenuItem(
value: 'restart',
child: Row(
children: [
Icon(Icons.refresh, size: 18),
SizedBox(width: 8),
Text('Restart'),
],
),
),
],
);
}
}
@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/features/control_room/stacks/presentation/providers/stacks_provider.dart';
import 'package:tatlock_ui/shared/components/code_editor/code_editor.dart';
/// YAML editor for stack compose configuration.
class YamlEditor extends ConsumerStatefulWidget {
const YamlEditor({
super.key,
required this.stackId,
});
final String stackId;
@override
ConsumerState<YamlEditor> createState() => _YamlEditorState();
}
class _YamlEditorState extends ConsumerState<YamlEditor> {
final _editorKey = GlobalKey<CodeEditorState>();
bool _hasChanges = false;
String _originalYaml = '';
bool _initialized = false;
void _initializeEditor(String yaml) {
if (!_initialized && yaml.isNotEmpty) {
_originalYaml = yaml;
_initialized = true;
}
}
void _onChanged(String value) {
setState(() {
_hasChanges = value != _originalYaml;
});
}
void _onSave() {
final currentText = _editorKey.currentState?.text ?? '';
ref
.read(stackConfigActionsProvider.notifier)
.saveYaml(widget.stackId, currentText);
setState(() {
_originalYaml = currentText;
_hasChanges = false;
});
}
void _onReset() {
_editorKey.currentState?.text = _originalYaml;
setState(() {
_hasChanges = false;
});
}
@override
Widget build(BuildContext context) {
final yamlAsync = ref.watch(stackYamlProvider(widget.stackId));
final colorScheme = Theme.of(context).colorScheme;
final configState = ref.watch(stackConfigActionsProvider);
final isLoading = configState.isLoading;
return yamlAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: colorScheme.error, size: 48),
const SizedBox(height: 16),
Text(
'Failed to load YAML',
style: TextStyle(color: colorScheme.error),
),
const SizedBox(height: 8),
Text(
error.toString(),
style: TextStyle(color: colorScheme.outline, fontSize: 12),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => ref.invalidate(stackYamlProvider(widget.stackId)),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
data: (yaml) {
_initializeEditor(yaml);
return Column(
children: [
// Toolbar
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
border: Border(
bottom: BorderSide(color: colorScheme.outlineVariant),
),
),
child: Row(
children: [
Icon(
Icons.code,
size: 18,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
'docker-compose.yml',
style: TextStyle(
fontFamily: 'monospace',
fontSize: 13,
color: colorScheme.onSurfaceVariant,
),
),
if (_hasChanges) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Modified',
style: TextStyle(
fontSize: 11,
color: colorScheme.onTertiaryContainer,
),
),
),
],
const Spacer(),
if (_hasChanges) ...[
TextButton(
onPressed: isLoading ? null : _onReset,
child: const Text('Reset'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: isLoading ? null : _onSave,
icon: isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save, size: 18),
label: const Text('Save'),
),
],
],
),
),
// Editor with syntax highlighting
Expanded(
child: CodeEditor(
key: _editorKey,
language: CodeEditorLanguage.yaml,
initialValue: yaml,
onChanged: _onChanged,
),
),
],
);
},
);
}
}
@@ -0,0 +1,104 @@
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/quick_link_model.dart';
import 'package:tatlock_ui/features/front_hall/domain/entities/quick_link.dart';
part 'quick_links_datasource.g.dart';
/// Data source for quick link operations.
///
/// Provides CRUD operations for Front Hall quick links via Core API
/// dashboard/quick-links endpoint.
class QuickLinksDatasource {
QuickLinksDatasource(this._dio);
final Dio _dio;
static const _basePath = '/dashboard/quick-links';
/// Gets all quick links.
///
/// API returns: {"links": [...], "total": N}
Future<List<QuickLink>> getQuickLinks() async {
final response = await _dio.get<Map<String, dynamic>>(_basePath);
final data = response.data;
if (data == null) {
return [];
}
final links = data['links'] as List<dynamic>? ?? [];
return links
.map((json) => QuickLinkModel.fromJson(json as Map<String, dynamic>))
.map((model) => model.toEntity())
.toList();
}
/// Gets a single quick link by ID.
Future<QuickLink> getQuickLink(int id) async {
final response = await _dio.get<Map<String, dynamic>>('$_basePath/$id');
final data = response.data;
if (data == null) {
throw Exception('Quick link not found: $id');
}
return QuickLinkModel.fromJson(data).toEntity();
}
/// Creates a new quick link.
Future<QuickLink> createQuickLink(QuickLink link) async {
final model = QuickLinkModel.fromEntity(link);
final response = await _dio.post<Map<String, dynamic>>(
_basePath,
data: model.toJson(),
);
final data = response.data;
if (data == null) {
throw Exception('Failed to create quick link');
}
return QuickLinkModel.fromJson(data).toEntity();
}
/// Updates an existing quick link.
Future<QuickLink> updateQuickLink(QuickLink link) async {
final model = QuickLinkModel.fromEntity(link);
final id = int.tryParse(link.id) ?? 0;
final response = await _dio.put<Map<String, dynamic>>(
'$_basePath/$id',
data: model.toJson(),
);
final data = response.data;
if (data == null) {
throw Exception('Failed to update quick link');
}
return QuickLinkModel.fromJson(data).toEntity();
}
/// Deletes a quick link.
Future<void> deleteQuickLink(int id) async {
await _dio.delete<void>('$_basePath/$id');
}
/// Reorders quick links by updating positions.
///
/// API expects: {"link_ids": [1, 2, 3]}
Future<void> reorderQuickLinks(List<int> orderedIds) async {
await _dio.post<void>(
'$_basePath/reorder',
data: {'link_ids': orderedIds},
);
}
}
/// Provides the quick links datasource.
@riverpod
QuickLinksDatasource quickLinksDatasource(Ref ref) {
final dio = ref.watch(coreApiClientProvider);
return QuickLinksDatasource(dio);
}
@@ -0,0 +1,79 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:tatlock_ui/features/front_hall/domain/entities/quick_link.dart';
part 'quick_link_model.freezed.dart';
part 'quick_link_model.g.dart';
/// Quick link data model matching Core API dashboard/quick-links endpoint.
///
/// Field mapping:
/// - title (API) ↔ name (Flutter entity)
/// - icon (API) ↔ iconName (Flutter entity)
/// - position (API) ↔ sortOrder (Flutter entity)
/// - is_visible (API) ↔ isActive (Flutter entity)
/// - link_type (API) ↔ type (Flutter entity)
@freezed
sealed class QuickLinkModel with _$QuickLinkModel {
const factory QuickLinkModel({
@Default(0) int id,
required String title,
required String url,
String? icon,
String? description,
String? category,
@Default(0) int position,
@JsonKey(name: 'is_visible') @Default(true) bool isVisible,
@JsonKey(name: 'link_type') @Default('iframe') String linkType,
String? color,
@JsonKey(name: 'background_color') String? backgroundColor,
}) = _QuickLinkModel;
const QuickLinkModel._();
factory QuickLinkModel.fromJson(Map<String, dynamic> json) =>
_$QuickLinkModelFromJson(json);
/// Converts to domain entity.
QuickLink toEntity() {
return QuickLink(
id: id.toString(),
name: title,
url: url,
iconName: icon ?? 'link',
category: category,
type: _parseQuickLinkType(linkType),
sortOrder: position,
isActive: isVisible,
);
}
/// Creates model from domain entity.
factory QuickLinkModel.fromEntity(QuickLink entity) {
return QuickLinkModel(
id: int.tryParse(entity.id) ?? 0,
title: entity.name,
url: entity.url,
icon: entity.iconName,
category: entity.category,
position: entity.sortOrder,
isVisible: entity.isActive,
linkType: _formatQuickLinkType(entity.type),
);
}
}
/// Parses API link_type string to QuickLinkType enum.
QuickLinkType _parseQuickLinkType(String linkType) {
return switch (linkType) {
'new_tab' => QuickLinkType.newTab,
_ => QuickLinkType.iframe,
};
}
/// Formats QuickLinkType enum to API link_type string.
String _formatQuickLinkType(QuickLinkType type) {
return switch (type) {
QuickLinkType.newTab => 'new_tab',
QuickLinkType.iframe => 'iframe',
};
}
@@ -0,0 +1,91 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'system_stats_model.freezed.dart';
part 'system_stats_model.g.dart';
/// System statistics response from Core API.
@freezed
sealed class SystemStats with _$SystemStats {
const factory SystemStats({
required CpuStats cpu,
required MemoryStats memory,
required List<DiskStats> disks,
required NetworkStats network,
required GpuStats gpu,
required String hostname,
@JsonKey(name: 'queried_at') required DateTime queriedAt,
}) = _SystemStats;
factory SystemStats.fromJson(Map<String, dynamic> json) =>
_$SystemStatsFromJson(json);
}
@freezed
sealed class CpuStats with _$CpuStats {
const factory CpuStats({
@JsonKey(name: 'usage_percent') required double usagePercent,
required int cores,
@JsonKey(name: 'load_1m') double? load1m,
@JsonKey(name: 'load_5m') double? load5m,
@JsonKey(name: 'load_15m') double? load15m,
}) = _CpuStats;
factory CpuStats.fromJson(Map<String, dynamic> json) =>
_$CpuStatsFromJson(json);
}
@freezed
sealed class MemoryStats with _$MemoryStats {
const factory MemoryStats({
@JsonKey(name: 'usage_percent') required double usagePercent,
@JsonKey(name: 'total_bytes') required int totalBytes,
@JsonKey(name: 'used_bytes') required int usedBytes,
@JsonKey(name: 'available_bytes') required int availableBytes,
}) = _MemoryStats;
factory MemoryStats.fromJson(Map<String, dynamic> json) =>
_$MemoryStatsFromJson(json);
}
@freezed
sealed class DiskStats with _$DiskStats {
const factory DiskStats({
@JsonKey(name: 'mount_point') required String mountPoint,
required String device,
required String fstype,
@JsonKey(name: 'usage_percent') required double usagePercent,
@JsonKey(name: 'total_bytes') required int totalBytes,
@JsonKey(name: 'used_bytes') required int usedBytes,
@JsonKey(name: 'free_bytes') required int freeBytes,
}) = _DiskStats;
factory DiskStats.fromJson(Map<String, dynamic> json) =>
_$DiskStatsFromJson(json);
}
@freezed
sealed class NetworkStats with _$NetworkStats {
const factory NetworkStats({
@JsonKey(name: 'bytes_sent') required int bytesSent,
@JsonKey(name: 'bytes_recv') required int bytesRecv,
@JsonKey(name: 'bytes_total') required int bytesTotal,
}) = _NetworkStats;
factory NetworkStats.fromJson(Map<String, dynamic> json) =>
_$NetworkStatsFromJson(json);
}
@freezed
sealed class GpuStats with _$GpuStats {
const factory GpuStats({
required bool available,
String? name,
@JsonKey(name: 'usage_percent') double? usagePercent,
@JsonKey(name: 'total_bytes') int? totalBytes,
@JsonKey(name: 'used_bytes') int? usedBytes,
@JsonKey(name: 'free_bytes') int? freeBytes,
}) = _GpuStats;
factory GpuStats.fromJson(Map<String, dynamic> json) =>
_$GpuStatsFromJson(json);
}
@@ -0,0 +1,53 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'quick_link.freezed.dart';
/// Quick link type for navigation behavior.
enum QuickLinkType {
/// Opens in embedded iframe within the app.
iframe,
/// Opens in a new browser tab.
newTab,
}
/// Quick link entity for Front Hall navigation.
///
/// Represents a shortcut link that can either open embedded content
/// in an iframe or navigate to an external URL in a new tab.
@freezed
sealed class QuickLink with _$QuickLink {
const factory QuickLink({
/// Unique identifier.
required String id,
/// Display name.
required String name,
/// Target URL.
required String url,
/// Material icon name (e.g., 'home', 'settings', 'movie').
required String iconName,
/// Category for grouping (e.g., 'Home', 'AMP', 'Coding', 'Infrastructure').
String? category,
/// Navigation behavior type.
@Default(QuickLinkType.iframe) QuickLinkType type,
/// Sort order within category.
@Default(0) int sortOrder,
/// Whether the link is active/visible.
@Default(true) bool isActive,
}) = _QuickLink;
const QuickLink._();
/// Whether this link opens in an iframe.
bool get isIframe => type == QuickLinkType.iframe;
/// Whether this link opens in a new tab.
bool get isNewTab => type == QuickLinkType.newTab;
}
@@ -1,155 +1,65 @@
import 'package:flutter/material.dart';
import '../../../../version.g.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/features/front_hall/presentation/providers/front_hall_state_provider.dart';
import 'package:tatlock_ui/features/front_hall/presentation/widgets/dashboard_content.dart';
import 'package:tatlock_ui/features/front_hall/presentation/widgets/iframe_view.dart';
import 'package:tatlock_ui/features/front_hall/presentation/widgets/quick_link_settings_content.dart';
import 'package:tatlock_ui/features/front_hall/presentation/widgets/quick_links_panel.dart';
/// Front Hall - estate overview and quick access.
class FrontHallPage extends StatelessWidget {
///
/// Three-mode content area:
/// - Dashboard: Stats, weather, welcome message
/// - Iframe: Embedded external content (web only)
/// - Settings: Quick link management
class FrontHallPage extends ConsumerWidget {
const FrontHallPage({super.key});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final frontHallState = ref.watch(frontHallStateProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Front Hall'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
// TODO: Refresh data
},
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Welcome card
Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.waving_hand,
size: 32,
color: colorScheme.primary,
),
const SizedBox(width: 12),
Flexible(
child: Text(
'Welcome to Tatlock',
style: Theme.of(context).textTheme.headlineSmall,
),
),
],
),
const SizedBox(height: 12),
Text(
'Your homelab dashboard is ready. More features coming soon.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
const SizedBox(height: 16),
return Row(
children: [
// Left panel - Quick Links
const QuickLinksPanel(),
// Quick stats placeholder
Wrap(
spacing: 12,
runSpacing: 12,
children: [
SizedBox(
width: 160,
child: _StatCard(
icon: Icons.memory,
label: 'CPU',
value: '--',
color: colorScheme.primary,
),
),
SizedBox(
width: 160,
child: _StatCard(
icon: Icons.storage,
label: 'Memory',
value: '--',
color: colorScheme.secondary,
),
),
SizedBox(
width: 160,
child: _StatCard(
icon: Icons.dns,
label: 'Containers',
value: '--',
color: colorScheme.tertiary,
),
),
],
),
const SizedBox(height: 24),
// Version info
Center(
child: Text(
'${AppVersion.name} v${AppVersion.fullVersion}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.outline,
),
),
),
],
),
);
}
}
class _StatCard extends StatelessWidget {
const _StatCard({
required this.icon,
required this.label,
required this.value,
required this.color,
});
final IconData icon;
final String label;
final String value;
final Color color;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Icon(icon, size: 32, color: color),
const SizedBox(height: 8),
Text(
value,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
],
// Vertical divider
VerticalDivider(
width: 1,
thickness: 1,
color: colorScheme.outlineVariant,
),
),
// Main content area - switches based on mode
Expanded(
child: _buildContent(context, ref, frontHallState),
),
],
);
}
Widget _buildContent(
BuildContext context,
WidgetRef ref,
FrontHallState state,
) {
switch (state.mode) {
case FrontHallMode.dashboard:
return const DashboardContent();
case FrontHallMode.iframe:
return IframeView(
key: ValueKey(state.activeIframeUrl),
url: state.activeIframeUrl!,
title: state.activeIframeTitle ?? 'External Content',
onClose: () =>
ref.read(frontHallStateProvider.notifier).showDashboard(),
);
case FrontHallMode.settings:
return const QuickLinkSettingsContent();
}
}
}
@@ -0,0 +1,82 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'front_hall_state_provider.freezed.dart';
part 'front_hall_state_provider.g.dart';
/// Front Hall content display mode.
enum FrontHallMode {
/// Shows dashboard widgets (stats, weather, etc.).
dashboard,
/// Shows embedded iframe content.
iframe,
/// Shows link management/settings UI.
settings,
}
/// State for Front Hall content area.
@freezed
sealed class FrontHallState with _$FrontHallState {
const factory FrontHallState({
/// Current display mode.
@Default(FrontHallMode.dashboard) FrontHallMode mode,
/// Active iframe URL (when mode is iframe).
String? activeIframeUrl,
/// Active iframe title (when mode is iframe).
String? activeIframeTitle,
/// Selected link ID for editing (when mode is settings).
/// Null means creating a new link.
String? selectedLinkId,
}) = _FrontHallState;
const FrontHallState._();
}
/// Controller for Front Hall state.
@riverpod
class FrontHallStateNotifier extends _$FrontHallStateNotifier {
@override
FrontHallState build() => const FrontHallState();
/// Shows the dashboard (default mode).
void showDashboard() {
state = const FrontHallState();
}
/// Shows an iframe with the given URL and title.
void showIframe(String url, String title) {
state = FrontHallState(
mode: FrontHallMode.iframe,
activeIframeUrl: url,
activeIframeTitle: title,
);
}
/// Enters settings mode, optionally selecting a link for editing.
void enterSettings({String? linkId}) {
state = FrontHallState(
mode: FrontHallMode.settings,
selectedLinkId: linkId,
);
}
/// Selects a link for editing (while in settings mode).
void selectLink(String linkId) {
state = state.copyWith(selectedLinkId: linkId);
}
/// Clears the selected link (for creating new).
void clearSelectedLink() {
state = state.copyWith(selectedLinkId: null);
}
/// Exits settings mode and returns to dashboard.
void exitSettings() {
state = const FrontHallState();
}
}
@@ -0,0 +1,229 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/features/front_hall/data/datasources/quick_links_datasource.dart';
import 'package:tatlock_ui/features/front_hall/domain/entities/quick_link.dart';
part 'quick_links_provider.g.dart';
/// Provides the list of all quick links.
/// Falls back to default links if API is unavailable.
@riverpod
Future<List<QuickLink>> quickLinks(Ref ref) async {
try {
final datasource = ref.watch(quickLinksDatasourceProvider);
final links = await datasource.getQuickLinks();
// Return defaults if API returns empty list
return links.isEmpty ? getDefaultQuickLinks() : links;
} catch (_) {
// Return default links if API fails (e.g., backend not running)
return getDefaultQuickLinks();
}
}
/// Provides a single quick link by ID.
/// Falls back to default links if API is unavailable.
@riverpod
Future<QuickLink> quickLink(Ref ref, String id) async {
// First try to find in defaults (works offline)
final defaults = getDefaultQuickLinks();
final defaultLink = defaults.where((link) => link.id == id).firstOrNull;
if (defaultLink != null) {
return defaultLink;
}
// If not in defaults, try API
try {
final datasource = ref.watch(quickLinksDatasourceProvider);
final intId = int.tryParse(id);
if (intId == null) {
throw Exception('Invalid link ID: $id');
}
return await datasource.getQuickLink(intId);
} catch (_) {
throw Exception('Link not found: $id');
}
}
/// Controller for quick link CRUD operations.
@riverpod
class QuickLinkActions extends _$QuickLinkActions {
@override
AsyncValue<void> build() => const AsyncValue.data(null);
/// Safely sets state, ignoring disposal errors.
void _safeSetState(AsyncValue<void> newState) {
try {
state = newState;
} catch (_) {
// Provider was disposed - ignore
}
}
/// Safely invalidates providers after async operations.
void _safeInvalidate(List<ProviderOrFamily> providers) {
try {
for (final provider in providers) {
ref.invalidate(provider);
}
} catch (_) {
// Provider was disposed - ignore
}
}
/// Creates a new quick link.
Future<QuickLink?> create(QuickLink link) async {
QuickLink? result;
try {
_safeSetState(const AsyncValue.loading());
final datasource = ref.read(quickLinksDatasourceProvider);
result = await datasource.createQuickLink(link);
_safeInvalidate([quickLinksProvider]);
_safeSetState(const AsyncValue.data(null));
} catch (e, st) {
_safeSetState(AsyncValue.error(e, st));
}
return result;
}
/// Updates an existing quick link.
Future<QuickLink?> update(QuickLink link) async {
QuickLink? result;
try {
_safeSetState(const AsyncValue.loading());
final datasource = ref.read(quickLinksDatasourceProvider);
result = await datasource.updateQuickLink(link);
_safeInvalidate([quickLinksProvider, quickLinkProvider(link.id)]);
_safeSetState(const AsyncValue.data(null));
} catch (e, st) {
_safeSetState(AsyncValue.error(e, st));
}
return result;
}
/// Deletes a quick link.
Future<void> delete(String id) async {
try {
_safeSetState(const AsyncValue.loading());
final datasource = ref.read(quickLinksDatasourceProvider);
final intId = int.tryParse(id) ?? 0;
await datasource.deleteQuickLink(intId);
_safeInvalidate([quickLinksProvider]);
_safeSetState(const AsyncValue.data(null));
} catch (e, st) {
_safeSetState(AsyncValue.error(e, st));
}
}
/// Reorders quick links.
Future<void> reorder(List<String> orderedIds) async {
try {
_safeSetState(const AsyncValue.loading());
final datasource = ref.read(quickLinksDatasourceProvider);
final intIds = orderedIds.map((id) => int.tryParse(id) ?? 0).toList();
await datasource.reorderQuickLinks(intIds);
_safeInvalidate([quickLinksProvider]);
_safeSetState(const AsyncValue.data(null));
} catch (e, st) {
_safeSetState(AsyncValue.error(e, st));
}
}
}
/// Default seed data for quick links (used when API returns empty).
List<QuickLink> getDefaultQuickLinks() {
return [
// Home category
const QuickLink(
id: 'jellyfin',
name: 'Jellyfin',
url: 'https://jellyfin.schweitz.net',
iconName: 'movie',
category: 'Home',
type: QuickLinkType.iframe,
sortOrder: 0,
),
const QuickLink(
id: 'webui',
name: 'Open WebUI',
url: 'https://webui.schweitz.net',
iconName: 'chat',
category: 'Home',
type: QuickLinkType.iframe,
sortOrder: 1,
),
const QuickLink(
id: 'searxng',
name: 'SearXNG',
url: 'https://search.schweitz.net',
iconName: 'search',
category: 'Home',
type: QuickLinkType.newTab,
sortOrder: 2,
),
// AMP category
const QuickLink(
id: 'amp',
name: 'AMP Home',
url: 'https://amp.schweitz.net',
iconName: 'videogame_asset',
category: 'AMP',
type: QuickLinkType.newTab,
sortOrder: 0,
),
// Coding category
const QuickLink(
id: 'gitea',
name: 'Gitea',
url: 'https://git.schweitz.net',
iconName: 'code',
category: 'Coding',
type: QuickLinkType.iframe,
sortOrder: 0,
),
const QuickLink(
id: 'cloud-ide',
name: 'Cloud IDE',
url: 'https://code.schweitz.net',
iconName: 'terminal',
category: 'Coding',
type: QuickLinkType.newTab,
sortOrder: 1,
),
// Infrastructure category
const QuickLink(
id: 'netdata',
name: 'Netdata',
url: 'https://netdata.schweitz.net',
iconName: 'monitoring',
category: 'Infrastructure',
type: QuickLinkType.iframe,
sortOrder: 0,
),
const QuickLink(
id: 'portainer',
name: 'Portainer',
url: 'https://portainer.schweitz.net',
iconName: 'dns',
category: 'Infrastructure',
type: QuickLinkType.iframe,
sortOrder: 1,
),
const QuickLink(
id: 'npm',
name: 'Proxy Manager',
url: 'https://npm.schweitz.net',
iconName: 'public',
category: 'Infrastructure',
type: QuickLinkType.iframe,
sortOrder: 2,
),
const QuickLink(
id: 'core-api',
name: 'Core API',
url: 'https://api.schweitz.net/docs',
iconName: 'api',
category: 'Infrastructure',
type: QuickLinkType.iframe,
sortOrder: 3,
),
];
}
@@ -0,0 +1,14 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/core/api/api_client.dart';
import 'package:tatlock_ui/features/front_hall/data/models/system_stats_model.dart';
part 'system_stats_provider.g.dart';
/// Fetches system stats from Core API.
@riverpod
Future<SystemStats> systemStats(Ref ref) async {
final dio = ref.watch(coreApiClientProvider);
final response = await dio.get('/tools/system/stats');
return SystemStats.fromJson(response.data as Map<String, dynamic>);
}
@@ -0,0 +1,258 @@
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/system_stats_provider.dart';
import 'package:tatlock_ui/shared/theme/stoplight_colors.dart';
import 'package:tatlock_ui/shared/widgets/widgets.dart';
import 'package:tatlock_ui/version.g.dart';
/// Dashboard content shown in Front Hall when mode is dashboard.
///
/// Displays system stats with gauges, weather, air quality, and version info.
/// Auto-refreshes system stats every 30 seconds.
class DashboardContent extends ConsumerStatefulWidget {
const DashboardContent({super.key});
@override
ConsumerState<DashboardContent> createState() => _DashboardContentState();
}
class _DashboardContentState extends ConsumerState<DashboardContent> {
Timer? _refreshTimer;
@override
void initState() {
super.initState();
_refreshTimer = Timer.periodic(
const Duration(seconds: 30),
(_) => ref.invalidate(systemStatsProvider),
);
}
@override
void dispose() {
_refreshTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final systemStatsAsync = ref.watch(systemStatsProvider);
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Welcome card
Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.waving_hand,
size: 32,
color: colorScheme.primary,
),
const SizedBox(width: 12),
Flexible(
child: Text(
'Welcome to Tatlock',
style: Theme.of(context).textTheme.headlineSmall,
),
),
],
),
const SizedBox(height: 12),
Text(
'Your homelab dashboard is ready.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
const SizedBox(height: 16),
// System Stats - Gauges
_SectionHeader(title: 'System Stats', icon: Icons.monitor_heart),
const SizedBox(height: 8),
systemStatsAsync.when(
data: (stats) => _SystemStatsCard(stats: stats),
loading: () => const Card(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
),
error: (error, _) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Text(
'Failed to load system stats',
style: TextStyle(color: colorScheme.error),
),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.invalidate(systemStatsProvider),
),
],
),
),
),
),
const SizedBox(height: 24),
// Environment - Weather & Air Quality
_SectionHeader(title: 'Environment', icon: Icons.eco),
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()),
],
);
}
// Stack on narrow screens
return Column(
children: [
WeatherWidget(),
const SizedBox(height: 12),
AirQualityWidget(),
],
);
},
),
const SizedBox(height: 24),
// Version info
Center(
child: Text(
'${AppVersion.name} v${AppVersion.fullVersion}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.outline,
),
),
),
],
);
}
}
/// Section header with icon and title.
class _SectionHeader extends StatelessWidget {
const _SectionHeader({
required this.title,
required this.icon,
});
final String title;
final IconData icon;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
Icon(
icon,
size: 18,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
],
);
}
}
/// Card displaying system stats with gauges.
class _SystemStatsCard extends StatelessWidget {
const _SystemStatsCard({required this.stats});
final SystemStats stats;
@override
Widget build(BuildContext context) {
// Build gauges list: CPU, Memory, GPU (if available), then all disks
final gauges = <GaugeData>[
GaugeData(
value: stats.cpu.usagePercent / 100,
label: 'CPU',
icon: Icons.memory,
color: StoplightColors.forPercent(stats.cpu.usagePercent),
),
GaugeData(
value: stats.memory.usagePercent / 100,
label: 'RAM',
icon: Icons.storage,
color: StoplightColors.forPercent(stats.memory.usagePercent),
),
if (stats.gpu.available && stats.gpu.usagePercent != null)
GaugeData(
value: stats.gpu.usagePercent! / 100,
label: 'VRAM',
icon: Icons.videocam,
color: StoplightColors.forPercent(stats.gpu.usagePercent!),
),
// Add a gauge for each disk
...stats.disks.map(
(disk) => GaugeData(
value: disk.usagePercent / 100,
label: _formatDiskLabel(disk),
icon: Icons.disc_full,
color: StoplightColors.forPercent(disk.usagePercent),
),
),
];
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: GaugeRow(
gaugeSize: 120,
gauges: gauges,
),
),
);
}
/// Formats disk label from mount point.
String _formatDiskLabel(DiskStats disk) {
final mount = disk.mountPoint;
if (mount == '/') return 'Root';
if (mount == '/hostfs') return 'Host';
if (mount.startsWith('/hostfs/')) return mount.substring(8);
if (mount.startsWith('/mnt/')) return mount.substring(5);
if (mount.startsWith('/media/')) return mount.substring(7);
// Return last path segment
final parts = mount.split('/');
return parts.isNotEmpty ? parts.last : mount;
}
}
@@ -0,0 +1,4 @@
// Conditional export for platform-specific iframe implementation.
// Web uses actual iframe embedding, other platforms open in browser.
export 'iframe_view_stub.dart'
if (dart.library.html) 'iframe_view_web.dart';
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
/// Stub implementation for non-web platforms.
///
/// Since iframes are web-only, this shows a message and offers
/// to open the link in an external browser.
class IframeView extends StatelessWidget {
const IframeView({
super.key,
required this.url,
required this.title,
required this.onClose,
});
final String url;
final String title;
final VoidCallback onClose;
Future<void> _openInBrowser() async {
final uri = Uri.tryParse(url);
if (uri != null && await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// Auto-open in browser and show message
WidgetsBinding.instance.addPostFrameCallback((_) {
_openInBrowser();
});
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.open_in_browser,
size: 64,
color: colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'Opening in Browser',
style: textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Embedded views are not supported on this platform.\n"$title" is opening in your browser.',
textAlign: TextAlign.center,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
Row(
mainAxisSize: MainAxisSize.min,
children: [
OutlinedButton.icon(
onPressed: _openInBrowser,
icon: const Icon(Icons.open_in_new),
label: const Text('Open Again'),
),
const SizedBox(width: 12),
FilledButton.icon(
onPressed: onClose,
icon: const Icon(Icons.arrow_back),
label: const Text('Back to Dashboard'),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,145 @@
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).
///
/// Uses HtmlElementView for Flutter web to embed an iframe.
/// Includes a header bar with title, refresh, open in new tab, and close actions.
class IframeView extends StatefulWidget {
const IframeView({
super.key,
required this.url,
required this.title,
required this.onClose,
});
/// URL to display in the iframe.
final String url;
/// Title displayed in the header bar.
final String title;
/// Callback when the close button is pressed.
final VoidCallback onClose;
@override
State<IframeView> createState() => _IframeViewState();
}
class _IframeViewState extends State<IframeView> {
late final String _viewType;
late web.HTMLIFrameElement _iframe;
bool _isLoading = true;
@override
void initState() {
super.initState();
_viewType = 'iframe-${widget.url.hashCode}-${DateTime.now().millisecondsSinceEpoch}';
_createIframe();
}
void _createIframe() {
_iframe = web.document.createElement('iframe') as web.HTMLIFrameElement
..src = widget.url
..style.border = 'none'
..style.width = '100%'
..style.height = '100%'
..allow = 'fullscreen';
_iframe.onLoad.listen((_) {
if (mounted) {
setState(() => _isLoading = false);
}
});
// Register the view factory
ui_web.platformViewRegistry.registerViewFactory(
_viewType,
(int viewId) => _iframe,
);
}
void _refresh() {
setState(() => _isLoading = true);
// Reload the iframe by setting src again
_iframe.src = widget.url;
}
Future<void> _openInNewTab() async {
final uri = Uri.tryParse(widget.url);
if (uri != null && await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Column(
children: [
// Header bar
Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
border: Border(
bottom: BorderSide(color: colorScheme.outlineVariant),
),
),
child: Row(
children: [
// Title
Expanded(
child: Text(
widget.title,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
// Actions
IconButton(
icon: const Icon(Icons.open_in_new, size: 20),
tooltip: 'Open in new tab',
onPressed: _openInNewTab,
),
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Refresh',
onPressed: _refresh,
),
IconButton(
icon: const Icon(Icons.close, size: 20),
tooltip: 'Close',
onPressed: widget.onClose,
),
],
),
),
// Iframe content
Expanded(
child: Stack(
children: [
HtmlElementView(viewType: _viewType),
// Loading overlay
if (_isLoading)
Container(
color: colorScheme.surface,
child: const Center(
child: CircularProgressIndicator(),
),
),
],
),
),
],
);
}
}
@@ -0,0 +1,343 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/features/front_hall/domain/entities/quick_link.dart';
import 'package:tatlock_ui/features/front_hall/presentation/providers/quick_links_provider.dart';
import 'package:tatlock_ui/shared/widgets/entity_page.dart';
import 'package:tatlock_ui/shared/widgets/icon_picker.dart';
/// Form for creating and editing Quick Links.
///
/// Handles validation, submission, and state management for link data.
class QuickLinkForm extends ConsumerStatefulWidget {
const QuickLinkForm({
super.key,
required this.mode,
this.quickLink,
required this.onCancel,
required this.onSaved,
});
final EntityPageMode mode;
final QuickLink? quickLink;
final VoidCallback onCancel;
final VoidCallback onSaved;
@override
ConsumerState<QuickLinkForm> createState() => _QuickLinkFormState();
}
class _QuickLinkFormState extends ConsumerState<QuickLinkForm> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _urlController;
late final TextEditingController _categoryController;
late String _iconName;
late QuickLinkType _type;
late bool _isActive;
bool _isSubmitting = false;
@override
void initState() {
super.initState();
final link = widget.quickLink;
_nameController = TextEditingController(text: link?.name ?? '');
_urlController = TextEditingController(text: link?.url ?? '');
_categoryController = TextEditingController(text: link?.category ?? '');
_iconName = link?.iconName ?? 'link';
_type = link?.type ?? QuickLinkType.iframe;
_isActive = link?.isActive ?? true;
}
@override
void dispose() {
_nameController.dispose();
_urlController.dispose();
_categoryController.dispose();
super.dispose();
}
bool get isCreate => widget.mode == EntityPageMode.create;
Future<void> _handleSubmit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isSubmitting = true);
try {
final actions = ref.read(quickLinkActionsProvider.notifier);
final category = _categoryController.text.trim();
final QuickLink link;
if (isCreate) {
// Create new entity
link = QuickLink(
id: _nameController.text.toLowerCase().replaceAll(RegExp(r'\s+'), '-'),
name: _nameController.text.trim(),
url: _urlController.text.trim(),
iconName: _iconName,
category: category.isEmpty ? null : category,
type: _type,
isActive: _isActive,
sortOrder: 0,
);
await actions.create(link);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Link created successfully'),
behavior: SnackBarBehavior.floating,
),
);
}
} else {
// Update: use copyWith to preserve all existing data
link = widget.quickLink!.copyWith(
name: _nameController.text.trim(),
url: _urlController.text.trim(),
iconName: _iconName,
category: category.isEmpty ? null : category,
type: _type,
isActive: _isActive,
);
await actions.update(link);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Link updated successfully'),
behavior: SnackBarBehavior.floating,
),
);
}
}
// Refresh the list before exiting
ref.invalidate(quickLinksProvider);
widget.onSaved();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
behavior: SnackBarBehavior.floating,
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
}
}
}
Future<void> _handleDelete() async {
if (widget.quickLink == null) return;
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Link'),
content: Text('Are you sure you want to delete "${widget.quickLink!.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Delete'),
),
],
),
);
if (confirm == true) {
setState(() => _isSubmitting = true);
try {
await ref.read(quickLinkActionsProvider.notifier).delete(widget.quickLink!.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Link deleted'),
behavior: SnackBarBehavior.floating,
),
);
widget.onSaved();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error deleting: $e'),
behavior: SnackBarBehavior.floating,
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
}
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24),
children: [
// Name field
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
hintText: 'Enter link name',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Name is required';
}
return null;
},
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
// URL field
TextFormField(
controller: _urlController,
decoration: const InputDecoration(
labelText: 'URL',
hintText: 'https://example.com',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'URL is required';
}
final uri = Uri.tryParse(value);
if (uri == null || !uri.hasScheme) {
return 'Enter a valid URL with scheme (http/https)';
}
return null;
},
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
// Category field
TextFormField(
controller: _categoryController,
decoration: const InputDecoration(
labelText: 'Category',
hintText: 'e.g., Home, Infrastructure, Coding',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.done,
),
const SizedBox(height: 24),
// Icon picker
IconPicker(
selectedIcon: _iconName,
onChanged: (icon) => setState(() => _iconName = icon),
),
const SizedBox(height: 24),
// Link type
Text(
'Link Type',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
SegmentedButton<QuickLinkType>(
segments: const [
ButtonSegment(
value: QuickLinkType.iframe,
icon: Icon(Icons.web),
label: Text('Iframe'),
),
ButtonSegment(
value: QuickLinkType.newTab,
icon: Icon(Icons.open_in_new),
label: Text('New Tab'),
),
],
selected: {_type},
onSelectionChanged: (selected) {
setState(() => _type = selected.first);
},
),
const SizedBox(height: 8),
Text(
_type == QuickLinkType.iframe
? 'Opens embedded in the dashboard'
: 'Opens in a new browser tab',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.outline,
),
),
const SizedBox(height: 24),
// Active toggle
SwitchListTile(
title: const Text('Active'),
subtitle: const Text('Show this link in the panel'),
value: _isActive,
onChanged: (value) => setState(() => _isActive = value),
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 32),
// Action buttons
Row(
children: [
if (!isCreate) ...[
OutlinedButton.icon(
onPressed: _isSubmitting ? null : _handleDelete,
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
),
icon: const Icon(Icons.delete),
label: const Text('Delete'),
),
const Spacer(),
] else
const Spacer(),
TextButton(
onPressed: _isSubmitting ? null : widget.onCancel,
child: const Text('Cancel'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _isSubmitting ? null : _handleSubmit,
child: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(isCreate ? 'Create' : 'Save'),
),
],
),
],
),
);
}
}
@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/features/front_hall/presentation/providers/front_hall_state_provider.dart';
import 'package:tatlock_ui/features/front_hall/presentation/providers/quick_links_provider.dart';
import 'package:tatlock_ui/features/front_hall/presentation/widgets/quick_link_form.dart';
import 'package:tatlock_ui/shared/widgets/entity_page.dart';
/// Settings content for Quick Link management.
///
/// Shows either:
/// - Create form (when isCreating state is set)
/// - Edit form (when a link is selected)
/// - Empty state with "Add New Link" prompt (when no link selected)
class QuickLinkSettingsContent extends ConsumerStatefulWidget {
const QuickLinkSettingsContent({super.key});
@override
ConsumerState<QuickLinkSettingsContent> createState() =>
_QuickLinkSettingsContentState();
}
class _QuickLinkSettingsContentState
extends ConsumerState<QuickLinkSettingsContent> {
bool _isCreating = false;
void _startCreating() {
setState(() => _isCreating = true);
// Clear any selected link when creating new
ref.read(frontHallStateProvider.notifier).clearSelectedLink();
}
void _stopCreating() {
setState(() => _isCreating = false);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final frontHallState = ref.watch(frontHallStateProvider);
final selectedLinkId = frontHallState.selectedLinkId;
// Create mode - show create form
if (_isCreating) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _stopCreating,
),
title: const Text('Create New Link'),
),
body: QuickLinkForm(
mode: EntityPageMode.create,
onCancel: _stopCreating,
onSaved: () =>
ref.read(frontHallStateProvider.notifier).exitSettings(),
),
);
}
// No link selected - show empty state
if (selectedLinkId == null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.add_link,
size: 64,
color: colorScheme.outline,
),
const SizedBox(height: 16),
Text(
'No link selected',
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Select a link from the panel to edit, or create a new one.',
textAlign: TextAlign.center,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.outline,
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _startCreating,
icon: const Icon(Icons.add),
label: const Text('Create New Link'),
),
],
),
),
);
}
// Link selected - show edit form
final linkAsync = ref.watch(quickLinkProvider(selectedLinkId));
return linkAsync.when(
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('Error loading link: $error'),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () =>
ref.invalidate(quickLinkProvider(selectedLinkId)),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
data: (link) => Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => ref
.read(frontHallStateProvider.notifier)
.clearSelectedLink(),
),
title: Text('Edit: ${link.name}'),
),
body: QuickLinkForm(
key: ValueKey(link.id), // Force recreation when link changes
mode: EntityPageMode.edit,
quickLink: link,
onCancel: () => ref
.read(frontHallStateProvider.notifier)
.clearSelectedLink(),
onSaved: () =>
ref.read(frontHallStateProvider.notifier).exitSettings(),
),
),
);
}
}
@@ -0,0 +1,683 @@
import 'dart:ui' show lerpDouble;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/features/front_hall/domain/entities/quick_link.dart';
import 'package:tatlock_ui/features/front_hall/presentation/providers/front_hall_state_provider.dart';
import 'package:tatlock_ui/features/front_hall/presentation/providers/quick_links_provider.dart';
import 'package:tatlock_ui/shared/layouts/widgets/panel_header.dart';
import 'package:tatlock_ui/shared/widgets/icon_picker.dart';
import 'package:url_launcher/url_launcher.dart';
/// Left-side panel for quick link navigation in Front Hall.
///
/// Shows categorized links that can open in iframe or new tab.
/// Adapts behavior based on current mode (normal vs settings).
class QuickLinksPanel extends ConsumerWidget {
const QuickLinksPanel({
super.key,
this.width = 280,
});
final double width;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final frontHallState = ref.watch(frontHallStateProvider);
final quickLinksAsync = ref.watch(quickLinksProvider);
final isSettingsMode = frontHallState.mode == FrontHallMode.settings;
return SizedBox(
width: width,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header changes based on mode
if (isSettingsMode)
_SettingsModeHeader(
onBack: () =>
ref.read(frontHallStateProvider.notifier).exitSettings(),
)
else
PanelHeader(
title: 'Quick Links',
icon: Icons.link,
dockToBottom: true,
actions: [
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Refresh',
onPressed: () => ref.invalidate(quickLinksProvider),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
// Add new link button (settings mode only)
if (isSettingsMode) ...[
Padding(
padding: const EdgeInsets.all(8),
child: FilledButton.icon(
onPressed: () => ref
.read(frontHallStateProvider.notifier)
.clearSelectedLink(),
icon: const Icon(Icons.add, size: 18),
label: const Text('Add New Link'),
),
),
Divider(height: 1, color: colorScheme.outlineVariant),
],
// Links list
Expanded(
child: quickLinksAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => _ErrorState(
error: error,
onRetry: () => ref.invalidate(quickLinksProvider),
),
data: (links) {
// Use default links if empty
final displayLinks =
links.isEmpty ? getDefaultQuickLinks() : links;
return _QuickLinksList(
links: displayLinks,
selectedLinkId: frontHallState.selectedLinkId,
isSettingsMode: isSettingsMode,
);
},
),
),
// Settings button (normal mode only)
if (!isSettingsMode) ...[
Divider(height: 1, color: colorScheme.outlineVariant),
Padding(
padding: const EdgeInsets.all(8),
child: TextButton.icon(
onPressed: () => ref
.read(frontHallStateProvider.notifier)
.enterSettings(),
icon: const Icon(Icons.settings, size: 18),
label: const Text('Settings'),
),
),
],
],
),
);
}
}
/// Header for settings mode with back button.
class _SettingsModeHeader extends StatelessWidget {
const _SettingsModeHeader({required this.onBack});
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
border: Border(
bottom: BorderSide(color: colorScheme.outlineVariant),
),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Back to Dashboard',
onPressed: onBack,
),
const SizedBox(width: 4),
Icon(Icons.link, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
'Quick Links',
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
/// List of quick links grouped by category.
///
/// In settings mode, shows a flat reorderable list.
/// In normal mode, shows grouped list with category headers.
class _QuickLinksList extends ConsumerWidget {
const _QuickLinksList({
required this.links,
required this.selectedLinkId,
required this.isSettingsMode,
});
final List<QuickLink> links;
final String? selectedLinkId;
final bool isSettingsMode;
/// Groups links by category, preserving order.
List<(String?, List<QuickLink>)> get _groupedLinks {
final groups = <String?, List<QuickLink>>{};
final order = <String?>[];
for (final link in links) {
if (!groups.containsKey(link.category)) {
groups[link.category] = [];
order.add(link.category);
}
groups[link.category]!.add(link);
}
return order.map((category) => (category, groups[category]!)).toList();
}
bool get _showCategoryHeaders {
final categories =
links.map((l) => l.category).where((c) => c != null).toSet();
return categories.length > 1;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
// In settings mode, show reorderable list
if (isSettingsMode) {
return _buildReorderableList(context, ref);
}
// In normal mode, show grouped list
return ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: _buildLinkList(context, ref),
);
}
Widget _buildReorderableList(BuildContext context, WidgetRef ref) {
return ReorderableListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: links.length,
onReorder: (oldIndex, newIndex) => _handleReorder(ref, oldIndex, newIndex),
proxyDecorator: (child, index, animation) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
final elevation = lerpDouble(0, 8, animation.value) ?? 0;
return Material(
elevation: elevation,
borderRadius: BorderRadius.circular(8),
child: child,
);
},
child: child,
);
},
itemBuilder: (context, index) {
final link = links[index];
return _ReorderableLinkTile(
key: ValueKey(link.id),
link: link,
index: index,
isSelected: link.id == selectedLinkId,
onTap: () => _handleLinkTap(ref, link),
onEdit: () => ref
.read(frontHallStateProvider.notifier)
.selectLink(link.id),
onDelete: () => _handleDelete(context, ref, link),
);
},
);
}
void _handleReorder(WidgetRef ref, int oldIndex, int newIndex) {
// Adjust for the removal
if (newIndex > oldIndex) {
newIndex -= 1;
}
// Create new ordered list
final reorderedLinks = List<QuickLink>.from(links);
final item = reorderedLinks.removeAt(oldIndex);
reorderedLinks.insert(newIndex, item);
// Extract IDs in new order
final orderedIds = reorderedLinks.map((l) => l.id).toList();
// Call reorder API
ref.read(quickLinkActionsProvider.notifier).reorder(orderedIds);
}
List<Widget> _buildLinkList(BuildContext context, WidgetRef ref) {
final widgets = <Widget>[];
final showHeaders = _showCategoryHeaders;
for (final (category, categoryLinks) in _groupedLinks) {
// Add category header if multiple categories exist
if (showHeaders && category != null) {
widgets.add(_CategoryHeader(title: category));
}
// Add links
for (final link in categoryLinks) {
widgets.add(
_QuickLinkTile(
link: link,
isSelected: link.id == selectedLinkId,
isSettingsMode: isSettingsMode,
onTap: () => _handleLinkTap(ref, link),
onEdit: isSettingsMode
? () => ref
.read(frontHallStateProvider.notifier)
.selectLink(link.id)
: null,
onDelete: isSettingsMode
? () => _handleDelete(context, ref, link)
: null,
),
);
}
}
return widgets;
}
void _handleLinkTap(WidgetRef ref, QuickLink link) {
if (isSettingsMode) {
// In settings mode, select link for editing
ref.read(frontHallStateProvider.notifier).selectLink(link.id);
} else {
// Close any open content first to ensure clean state
ref.read(frontHallStateProvider.notifier).showDashboard();
// Then open the new link
if (link.isIframe) {
ref
.read(frontHallStateProvider.notifier)
.showIframe(link.url, link.name);
} else {
// Open in new tab
_openInNewTab(link.url);
}
}
}
Future<void> _openInNewTab(String url) async {
final uri = Uri.tryParse(url);
if (uri != null && await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
Future<void> _handleDelete(
BuildContext context,
WidgetRef ref,
QuickLink link,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Link'),
content: Text('Delete "${link.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true) {
await ref.read(quickLinkActionsProvider.notifier).delete(link.id);
}
}
}
/// Reorderable link tile with drag handle for settings mode.
class _ReorderableLinkTile extends StatelessWidget {
const _ReorderableLinkTile({
super.key,
required this.link,
required this.index,
required this.isSelected,
required this.onTap,
required this.onEdit,
required this.onDelete,
});
final QuickLink link;
final int index;
final bool isSelected;
final VoidCallback onTap;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
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,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Row(
children: [
// Drag handle
ReorderableDragStartListener(
index: index,
child: MouseRegion(
cursor: SystemMouseCursors.grab,
child: Icon(
Icons.drag_indicator,
size: 20,
color: colorScheme.outline,
),
),
),
const SizedBox(width: 8),
// Icon
Icon(
getIconData(link.iconName),
size: 20,
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
// Name and subtitle
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
link.name,
style: textTheme.bodyMedium?.copyWith(
color: isSelected
? colorScheme.primary
: colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.w600 : null,
),
),
Text(
link.category ?? 'No category',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.outline,
fontSize: 11,
),
),
],
),
),
// Overflow menu
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
size: 18,
color: colorScheme.onSurfaceVariant,
),
padding: EdgeInsets.zero,
itemBuilder: (context) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit, size: 18),
SizedBox(width: 8),
Text('Edit'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, size: 18),
SizedBox(width: 8),
Text('Delete'),
],
),
),
],
onSelected: (value) {
switch (value) {
case 'edit':
onEdit();
case 'delete':
onDelete();
}
},
),
],
),
),
),
),
);
}
}
/// Category header with left accent bar.
class _CategoryHeader extends StatelessWidget {
const _CategoryHeader({required this.title});
final String title;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Container(
margin: const EdgeInsets.only(top: 8, bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
border: Border(
left: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
),
child: Text(
title.toUpperCase(),
style: textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
letterSpacing: 0.5,
fontWeight: FontWeight.w600,
),
),
);
}
}
/// Individual quick link tile.
class _QuickLinkTile extends StatelessWidget {
const _QuickLinkTile({
required this.link,
required this.isSelected,
required this.isSettingsMode,
required this.onTap,
this.onEdit,
this.onDelete,
});
final QuickLink link;
final bool isSelected;
final bool isSettingsMode;
final VoidCallback onTap;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
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,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Icon(
getIconData(link.iconName),
size: 20,
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
link.name,
style: textTheme.bodyMedium?.copyWith(
color:
isSelected ? colorScheme.primary : colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.w600 : null,
),
),
if (isSettingsMode)
Text(
link.isNewTab ? 'Opens in new tab' : 'Opens in iframe',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.outline,
fontSize: 11,
),
),
],
),
),
// New tab indicator (normal mode)
if (!isSettingsMode && link.isNewTab)
Icon(
Icons.open_in_new,
size: 14,
color: colorScheme.outline,
),
// Overflow menu (settings mode)
if (isSettingsMode)
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
size: 18,
color: colorScheme.onSurfaceVariant,
),
padding: EdgeInsets.zero,
itemBuilder: (context) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit, size: 18),
SizedBox(width: 8),
Text('Edit'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, size: 18),
SizedBox(width: 8),
Text('Delete'),
],
),
),
],
onSelected: (value) {
switch (value) {
case 'edit':
onEdit?.call();
case 'delete':
onDelete?.call();
}
},
),
],
),
),
),
),
);
}
}
/// Error state widget.
class _ErrorState extends StatelessWidget {
const _ErrorState({
required this.error,
required this.onRetry,
});
final Object error;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
color: colorScheme.error,
size: 32,
),
const SizedBox(height: 8),
Text(
'Failed to load links',
style: TextStyle(color: colorScheme.error),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Retry'),
),
],
),
),
);
}
}
@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/core/api/api_client.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';
/// Simple group data class for display.
class GroupData {
GroupData({
required this.id,
required this.name,
required this.isSuperuser,
required this.memberCount,
this.parentName,
});
factory GroupData.fromJson(Map<String, dynamic> json) => GroupData(
id: json['id'] as String,
name: json['name'] as String,
isSuperuser: json['is_superuser'] as bool? ?? false,
memberCount: json['member_count'] as int? ?? 0,
parentName: json['parent_name'] as String?,
);
final String id;
final String name;
final bool isSuperuser;
final int memberCount;
final String? parentName;
}
/// Groups list page using the shared DataGrid component.
class GroupsListPage extends ConsumerStatefulWidget {
const GroupsListPage({super.key});
@override
ConsumerState<GroupsListPage> createState() => _GroupsListPageState();
}
class _GroupsListPageState extends ConsumerState<GroupsListPage> {
late final StateNotifierProvider<DataGridController<GroupData>,
DataGridState<GroupData>> _gridProvider;
bool _isSyncing = false;
@override
void initState() {
super.initState();
// Create provider in initState to ensure stable reference
final dio = ref.read(coreApiClientProvider);
final source = CoreApiDataSource<GroupData>(
dio: dio,
endpoint: '/auth/groups',
fromJson: GroupData.fromJson,
);
_gridProvider = dataGridProvider<GroupData>(
source: source,
config: _buildConfig(),
idSelector: (g) => g.id,
);
}
Future<void> _syncFromAuthentik() async {
if (_isSyncing) return;
setState(() => _isSyncing = true);
try {
final dio = ref.read(coreApiClientProvider);
await dio.post<void>('/auth/groups/sync-from-authentik');
if (!mounted) return;
ref.read(_gridProvider.notifier).refresh();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Groups synced from Authentik'),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Sync failed: $e'),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 4),
),
);
} finally {
if (mounted) setState(() => _isSyncing = false);
}
}
DataGridConfig<GroupData> _buildConfig() {
return DataGridConfig<GroupData>(
columns: [
DataGridColumn<GroupData>(
header: 'Name',
valueBuilder: (g) => g.name,
sortable: true,
searchable: true,
width: const DataGridColumnWidth.flex(2),
),
DataGridColumn<GroupData>(
header: 'Members',
valueBuilder: (g) => g.memberCount.toString(),
width: const DataGridColumnWidth.fixed(100),
alignment: DataGridColumnAlignment.end,
),
DataGridColumn<GroupData>(
header: 'Type',
valueBuilder: (g) => g.isSuperuser ? 'Superuser' : 'Standard',
cellBuilder: (context, g) => _GroupTypeBadge(isSuperuser: g.isSuperuser),
width: const DataGridColumnWidth.fixed(120),
),
DataGridColumn<GroupData>(
header: 'Parent',
valueBuilder: (g) => g.parentName ?? '-',
width: const DataGridColumnWidth.flex(1),
),
],
enableSearch: true,
searchHint: 'Search groups...',
showHeader: true,
showFooter: true,
);
}
@override
Widget build(BuildContext context) {
return DataGrid<GroupData>(
provider: _gridProvider,
config: _buildConfig(),
idSelector: (g) => g.id,
toolbarActions: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
onPressed: () => ref.read(_gridProvider.notifier).refresh(),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _isSyncing ? null : _syncFromAuthentik,
icon: _isSyncing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
label: const Text('Sync from Authentik'),
),
],
);
}
}
class _GroupTypeBadge extends StatelessWidget {
const _GroupTypeBadge({required this.isSuperuser});
final bool isSuperuser;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isSuperuser
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(
isSuperuser ? 'Superuser' : 'Standard',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: isSuperuser
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
);
}
}
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/features/security/router.dart';
import 'package:tatlock_ui/features/security/users/users_list_page.dart';
import 'package:tatlock_ui/features/security/groups/groups_list_page.dart';
import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart';
/// Main Security room page with nav panel and section content.
class SecurityPage extends ConsumerWidget {
const SecurityPage({
super.key,
this.nav = SecurityNav.users,
});
/// The current nav item to display.
final SecurityNav nav;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: Row(
children: [
// Nav Panel - section navigation with grouping
NavPanel(
title: 'Sections',
icon: Icons.security_outlined,
items: SecurityNav.values.map((n) => n.toNavItem()).toList(),
selectedId: nav.id,
onItemSelected: (id) {
final newNav = SecurityNav.values.firstWhere(
(n) => n.id == id,
);
// Navigate to section URL
context.go(pathForSecurityNav(newNav));
},
),
// Divider
VerticalDivider(
width: 1,
thickness: 1,
color: colorScheme.outlineVariant,
),
// Section content
Expanded(
child: _SectionContent(nav: nav),
),
],
),
);
}
}
/// Renders content for the selected nav item.
class _SectionContent extends StatelessWidget {
const _SectionContent({required this.nav});
final SecurityNav nav;
@override
Widget build(BuildContext context) {
switch (nav) {
case SecurityNav.users:
return const UsersListPage();
case SecurityNav.groups:
return const GroupsListPage();
default:
return _PlaceholderSection(nav: nav);
}
}
}
/// Placeholder for nav items not yet implemented.
class _PlaceholderSection extends StatelessWidget {
const _PlaceholderSection({required this.nav});
final SecurityNav nav;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
nav.icon,
size: 64,
color: colorScheme.outline,
),
const SizedBox(height: 16),
Text(
nav.label,
style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'${nav.section} • Coming soon',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.outline,
),
),
],
),
);
}
}
+74
View File
@@ -0,0 +1,74 @@
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/shared/layouts/widgets/nav_panel.dart';
/// Route paths for Security room.
abstract class SecurityRoutes {
static const base = '/security';
// User Management (Authentik)
static const users = '/security/users';
static const groups = '/security/groups';
// Monitoring (future)
static const cveMonitor = '/security/cve-monitor';
static const networkMonitor = '/security/network-monitor';
static const dnsAdblock = '/security/dns-adblock';
}
/// Security room navigation items with section grouping.
enum SecurityNav {
// User Management section (moved from Control Room)
users('users', 'Users', Icons.people, 'User Management'),
groups('groups', 'Groups', Icons.group_work, 'User Management'),
// Monitoring section (placeholders for future)
cveMonitor('cve-monitor', 'CVE Monitor', Icons.security, 'Monitoring'),
networkMonitor('network-monitor', 'Network Monitor', Icons.router, 'Monitoring'),
dnsAdblock('dns-adblock', 'DNS / Adblock', Icons.dns, 'Monitoring');
const SecurityNav(this.id, this.label, this.icon, this.section);
final String id;
final String label;
final IconData icon;
final String section;
NavItem toNavItem() => NavItem(
id: id,
label: label,
icon: icon,
section: section,
);
/// Get route path for this nav item.
String get path => '/security/$id';
}
/// Get route path for a nav item.
String pathForSecurityNav(SecurityNav nav) => nav.path;
/// Security room routes for go_router.
List<RouteBase> securityRoutes() {
return [
// Redirect /security to /security/users
GoRoute(
path: SecurityRoutes.base,
name: 'security',
redirect: (context, state) => SecurityRoutes.users,
),
// Generate routes for all nav items
for (final nav in SecurityNav.values)
GoRoute(
path: nav.path,
name: 'security${_capitalize(nav.id.replaceAll('-', '_'))}',
builder: (context, state) => SecurityPage(nav: nav),
),
];
}
String _capitalize(String s) {
if (s.isEmpty) return s;
return s
.split('_')
.map((part) => part[0].toUpperCase() + part.substring(1))
.join('');
}
@@ -0,0 +1,306 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/core/api/api_client.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';
/// Simple user data class for display.
class UserData {
UserData({
required this.id,
required this.email,
required this.name,
this.avatarUrl,
required this.createdAt,
this.lastLogin,
this.roles = const [],
});
factory UserData.fromJson(Map<String, dynamic> json) => UserData(
id: json['id'] as String,
email: json['email'] as String,
name: json['name'] as String,
avatarUrl: json['avatar_url'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
lastLogin: json['last_login'] != null
? DateTime.parse(json['last_login'] as String)
: null,
roles: (json['roles'] as List<dynamic>?)?.cast<String>() ?? [],
);
final String id;
final String email;
final String name;
final String? avatarUrl;
final DateTime createdAt;
final DateTime? lastLogin;
final List<String> roles;
bool get isAdmin => roles.any((r) => r.endsWith(':admin'));
String get initials {
final parts = name.split(' ');
if (parts.length >= 2) {
return '${parts.first[0]}${parts.last[0]}'.toUpperCase();
}
return name.substring(0, name.length.clamp(0, 2)).toUpperCase();
}
String get lastLoginFormatted {
if (lastLogin == null) return 'Never';
final diff = DateTime.now().difference(lastLogin!);
if (diff.inDays > 30) {
return '${lastLogin!.day}/${lastLogin!.month}/${lastLogin!.year}';
}
if (diff.inDays > 0) return '${diff.inDays}d ago';
if (diff.inHours > 0) return '${diff.inHours}h ago';
if (diff.inMinutes > 0) return '${diff.inMinutes}m ago';
return 'Just now';
}
}
/// Users list page using the shared DataGrid component.
class UsersListPage extends ConsumerStatefulWidget {
const UsersListPage({super.key});
@override
ConsumerState<UsersListPage> createState() => _UsersListPageState();
}
class _UsersListPageState extends ConsumerState<UsersListPage> {
late final StateNotifierProvider<DataGridController<UserData>,
DataGridState<UserData>> _gridProvider;
bool _isSyncing = false;
@override
void initState() {
super.initState();
final dio = ref.read(coreApiClientProvider);
final source = CoreApiDataSource<UserData>(
dio: dio,
endpoint: '/auth/users',
fromJson: UserData.fromJson,
);
_gridProvider = dataGridProvider<UserData>(
source: source,
config: _buildConfig(),
idSelector: (u) => u.id,
);
}
Future<void> _syncFromAuthentik() async {
if (_isSyncing) return;
setState(() => _isSyncing = true);
try {
final dio = ref.read(coreApiClientProvider);
await dio.post<void>('/auth/users/sync-from-authentik');
if (!mounted) return;
ref.read(_gridProvider.notifier).refresh();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Users synced from Authentik'),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Sync failed: $e'),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 4),
),
);
} finally {
if (mounted) setState(() => _isSyncing = false);
}
}
DataGridConfig<UserData> _buildConfig() {
return DataGridConfig<UserData>(
columns: [
DataGridColumn<UserData>(
header: 'User',
valueBuilder: (u) => u.name,
sortable: true,
searchable: true,
width: const DataGridColumnWidth.flex(2),
cellBuilder: (context, u) => _UserCell(user: u),
),
DataGridColumn<UserData>(
header: 'Roles',
valueBuilder: (u) => u.roles.join(', '),
width: const DataGridColumnWidth.flex(1),
cellBuilder: (context, u) => _RolesCell(roles: u.roles),
),
DataGridColumn<UserData>(
header: 'Last Login',
valueBuilder: (u) => u.lastLoginFormatted,
width: const DataGridColumnWidth.fixed(120),
alignment: DataGridColumnAlignment.end,
),
],
enableSearch: true,
searchHint: 'Search users...',
showHeader: true,
showFooter: true,
);
}
@override
Widget build(BuildContext context) {
return DataGrid<UserData>(
provider: _gridProvider,
config: _buildConfig(),
idSelector: (u) => u.id,
toolbarActions: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
onPressed: () => ref.read(_gridProvider.notifier).refresh(),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _isSyncing ? null : _syncFromAuthentik,
icon: _isSyncing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
label: const Text('Sync from Authentik'),
),
],
);
}
}
class _UserCell extends StatelessWidget {
const _UserCell({required this.user});
final UserData user;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: colorScheme.primaryContainer,
backgroundImage:
user.avatarUrl != null ? NetworkImage(user.avatarUrl!) : null,
child: user.avatarUrl == null
? Text(
user.initials,
style: TextStyle(
fontSize: 12,
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w500,
),
)
: null,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Flexible(
child: Text(
user.name,
style: const TextStyle(fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
if (user.isAdmin) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Admin',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: colorScheme.onPrimaryContainer,
),
),
),
],
],
),
Text(
user.email,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
);
}
}
class _RolesCell extends StatelessWidget {
const _RolesCell({required this.roles});
final List<String> roles;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
if (roles.isEmpty) {
return Text(
'No roles',
style: TextStyle(
fontSize: 12,
color: colorScheme.outline,
fontStyle: FontStyle.italic,
),
);
}
return Wrap(
spacing: 4,
runSpacing: 4,
children: roles.take(3).map((role) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Text(
role.split(':').last,
style: TextStyle(
fontSize: 10,
color: colorScheme.onSurfaceVariant,
),
),
);
}).toList(),
);
}
}
+16 -6
View File
@@ -4,19 +4,29 @@ import 'package:flutter/material.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();
// Use path-based URLs on web (no-op on mobile/desktop)
configureUrlStrategy();
developer.log(
'${AppVersion.name} v${AppVersion.fullVersion}',
name: 'tatlock_ui',
);
runApp(
const ProviderScope(
child: TatlockApp(),
),
);
// 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()));
}
+168 -31
View File
@@ -1,29 +1,42 @@
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 '../shared/layouts/app_scaffold.dart';
import '../features/front_hall/presentation/pages/front_hall_page.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/security/router.dart';
import 'package:tatlock_ui/shared/layouts/app_scaffold.dart';
part 'app_router.g.dart';
/// Route paths as constants.
abstract class AppRoutes {
static const frontHall = '/';
static const controlRoom = '/control-room';
static const containers = '/control-room/containers';
static const containerDetail = '/control-room/containers/:id';
static const parlor = '/parlor';
static const settings = '/settings';
static const callback = '/callback';
}
/// Provides the GoRouter instance.
@riverpod
GoRouter appRouter(AppRouterRef ref) {
GoRouter appRouter(Ref ref) {
return GoRouter(
initialLocation: AppRoutes.frontHall,
debugLogDiagnostics: true,
routes: [
// OIDC callback route (handles auth code exchange)
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'],
),
),
// Main app routes (inside shell with app scaffold)
ShellRoute(
builder: (context, state, child) => AppScaffold(child: child),
routes: [
@@ -32,30 +45,8 @@ GoRouter appRouter(AppRouterRef ref) {
name: 'frontHall',
builder: (context, state) => const FrontHallPage(),
),
GoRoute(
path: AppRoutes.controlRoom,
name: 'controlRoom',
builder: (context, state) =>
const _PlaceholderPage(title: 'Control Room'),
routes: [
GoRoute(
path: 'containers',
name: 'containers',
builder: (context, state) =>
const _PlaceholderPage(title: 'Containers'),
routes: [
GoRoute(
path: ':id',
name: 'containerDetail',
builder: (context, state) {
final id = state.pathParameters['id']!;
return _PlaceholderPage(title: 'Container: $id');
},
),
],
),
],
),
...controlRoomRoutes(),
...securityRoutes(),
GoRoute(
path: AppRoutes.parlor,
name: 'parlor',
@@ -108,3 +99,149 @@ class _PlaceholderPage extends StatelessWidget {
);
}
}
/// 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 mounted before any async work
if (!mounted) return;
// Check for error from Authentik
if (widget.error != null) {
// Silent OIDC (prompt=none) failed - no existing session
// Fall back to regular OIDC flow to show login UI
if (widget.error == 'login_required') {
ref.read(authProvider.notifier).signIn();
return;
}
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;
}
// Get notifier reference before async gap to avoid disposed ref errors
final authNotifier = ref.read(authProvider.notifier);
// Exchange code for tokens
try {
await authNotifier.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),
),
),
],
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'code_editor_language.dart';
export 'code_editor_language.dart';
/// A generic code editor widget with syntax highlighting.
///
/// Supports multiple languages via [CodeEditorLanguage] configuration.
class CodeEditor extends StatefulWidget {
const CodeEditor({
super.key,
required this.language,
this.initialValue = '',
this.onChanged,
this.readOnly = false,
this.showLineNumbers = true,
});
/// The programming language for syntax highlighting.
final CodeEditorLanguage language;
/// Initial content of the editor.
final String initialValue;
/// Called when the content changes.
final ValueChanged<String>? onChanged;
/// Whether the editor is read-only.
final bool readOnly;
/// Whether to show line numbers.
final bool showLineNumbers;
@override
State<CodeEditor> createState() => CodeEditorState();
}
class CodeEditorState extends State<CodeEditor> {
late CodeController _controller;
@override
void initState() {
super.initState();
_initController();
}
void _initController() {
_controller = CodeController(
text: widget.initialValue,
language: widget.language.mode,
);
_controller.addListener(_onTextChanged);
}
void _onTextChanged() {
widget.onChanged?.call(_controller.text);
}
@override
void didUpdateWidget(CodeEditor oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.language != widget.language) {
// Language changed, recreate controller
_controller.removeListener(_onTextChanged);
final currentText = _controller.text;
_controller.dispose();
_controller = CodeController(
text: currentText,
language: widget.language.mode,
);
_controller.addListener(_onTextChanged);
}
}
@override
void dispose() {
_controller.removeListener(_onTextChanged);
_controller.dispose();
super.dispose();
}
/// Gets the current text content.
String get text => _controller.text;
/// Sets the text content programmatically.
set text(String value) {
_controller.text = value;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
return CodeTheme(
data: isDark ? _darkTheme(colorScheme) : _lightTheme(colorScheme),
child: SingleChildScrollView(
child: CodeField(
controller: _controller,
readOnly: widget.readOnly,
minLines: 1,
gutterStyle: widget.showLineNumbers
? GutterStyle(
showLineNumbers: true,
showErrors: false,
showFoldingHandles: false,
margin: 0,
textStyle: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: colorScheme.outline,
),
background: colorScheme.surfaceContainerLow,
)
: GutterStyle.none,
textStyle: TextStyle(
fontFamily: 'monospace',
fontSize: 13,
height: 1.5,
color: colorScheme.onSurface,
),
padding: const EdgeInsets.all(16),
background: colorScheme.surfaceContainerLowest,
),
),
);
}
CodeThemeData _lightTheme(ColorScheme colorScheme) {
return CodeThemeData(
styles: {
'root': TextStyle(
color: colorScheme.onSurface,
backgroundColor: colorScheme.surfaceContainerLowest,
),
'keyword': TextStyle(color: Colors.purple.shade700),
'string': TextStyle(color: Colors.green.shade700),
'number': TextStyle(color: Colors.blue.shade700),
'comment': TextStyle(
color: Colors.grey.shade600,
fontStyle: FontStyle.italic,
),
'attr': TextStyle(color: Colors.orange.shade800),
'literal': TextStyle(color: Colors.teal.shade700),
'built_in': TextStyle(color: Colors.indigo.shade600),
'type': TextStyle(color: Colors.cyan.shade800),
'variable': TextStyle(color: Colors.brown.shade600),
'symbol': TextStyle(color: Colors.red.shade700),
'section': TextStyle(color: Colors.deepPurple.shade700),
'meta': TextStyle(color: Colors.pink.shade700),
},
);
}
CodeThemeData _darkTheme(ColorScheme colorScheme) {
return CodeThemeData(
styles: {
'root': TextStyle(
color: colorScheme.onSurface,
backgroundColor: colorScheme.surfaceContainerLowest,
),
'keyword': const TextStyle(color: Color(0xFFC792EA)),
'string': const TextStyle(color: Color(0xFFC3E88D)),
'number': const TextStyle(color: Color(0xFFF78C6C)),
'comment': TextStyle(
color: Colors.grey.shade500,
fontStyle: FontStyle.italic,
),
'attr': const TextStyle(color: Color(0xFFFFCB6B)),
'literal': const TextStyle(color: Color(0xFF82AAFF)),
'built_in': const TextStyle(color: Color(0xFF89DDFF)),
'type': const TextStyle(color: Color(0xFFFFCB6B)),
'variable': const TextStyle(color: Color(0xFFEEFFFF)),
'symbol': const TextStyle(color: Color(0xFFF07178)),
'section': const TextStyle(color: Color(0xFF82AAFF)),
'meta': const TextStyle(color: Color(0xFFF78C6C)),
},
);
}
}
@@ -0,0 +1,39 @@
import 'package:highlight/highlight_core.dart';
import 'package:highlight/languages/yaml.dart' as hl_yaml;
import 'package:highlight/languages/json.dart' as hl_json;
import 'package:highlight/languages/dockerfile.dart' as hl_dockerfile;
import 'package:highlight/languages/bash.dart' as hl_bash;
import 'package:highlight/languages/ini.dart' as hl_ini;
/// Supported languages for the code editor.
enum CodeEditorLanguage {
yaml('yaml', 'YAML'),
json('json', 'JSON'),
dockerfile('dockerfile', 'Dockerfile'),
bash('bash', 'Bash'),
ini('ini', 'INI/ENV'),
plaintext('plaintext', 'Plain Text');
const CodeEditorLanguage(this.id, this.displayName);
final String id;
final String displayName;
/// Gets the highlight Mode for this language.
Mode? get mode {
switch (this) {
case CodeEditorLanguage.yaml:
return hl_yaml.yaml;
case CodeEditorLanguage.json:
return hl_json.json;
case CodeEditorLanguage.dockerfile:
return hl_dockerfile.dockerfile;
case CodeEditorLanguage.bash:
return hl_bash.bash;
case CodeEditorLanguage.ini:
return hl_ini.ini;
case CodeEditorLanguage.plaintext:
return null;
}
}
}
@@ -0,0 +1,138 @@
import 'package:dio/dio.dart';
import 'package:tatlock_ui/shared/components/data_grid/data_grid_source.dart';
/// Data source adapter for Core API endpoints.
///
/// Implements the DataGrid data source interface for fetching data
/// from the Core API with support for search, sorting, and pagination.
///
/// Example:
/// ```dart
/// final containersSource = CoreApiDataSource<Container>(
/// dio: dio,
/// endpoint: '/infrastructure/containers',
/// fromJson: Container.fromJson,
/// searchParam: 'search',
/// sortParam: 'sort_by',
/// orderParam: 'order',
/// );
/// ```
class CoreApiDataSource<T> extends DataGridSource<T> {
CoreApiDataSource({
required this.dio,
required this.endpoint,
required this.fromJson,
this.searchParam = 'search',
this.sortParam = 'sort',
this.orderParam = 'order',
this.offsetParam = 'offset',
this.limitParam = 'limit',
this.itemsKey = 'items',
this.totalCountKey = 'total',
});
/// Dio HTTP client instance.
final Dio dio;
/// API endpoint path.
final String endpoint;
/// Function to parse JSON into the item type.
final T Function(Map<String, dynamic> json) fromJson;
/// Query parameter name for search.
final String searchParam;
/// Query parameter name for sort field.
final String sortParam;
/// Query parameter name for sort order.
final String orderParam;
/// Query parameter name for pagination offset.
final String offsetParam;
/// Query parameter name for pagination limit.
final String limitParam;
/// JSON key for items array in response.
final String itemsKey;
/// JSON key for total count in response.
final String totalCountKey;
@override
Future<DataGridResult<T>> fetch({
String? searchQuery,
String? sortField,
bool sortDescending = false,
int? offset,
int? limit,
}) async {
final queryParams = <String, dynamic>{};
if (searchQuery != null && searchQuery.isNotEmpty) {
queryParams[searchParam] = searchQuery;
}
if (sortField != null) {
queryParams[sortParam] = sortField;
queryParams[orderParam] = sortDescending ? 'desc' : 'asc';
}
if (offset != null) {
queryParams[offsetParam] = offset;
}
if (limit != null) {
queryParams[limitParam] = limit;
}
final response = await dio.get<dynamic>(
endpoint,
queryParameters: queryParams,
);
final responseData = response.data!;
// Handle both paginated and wrapped responses
List<dynamic> itemsJson;
int totalCount;
if (responseData is List) {
// Direct array response: [...]
itemsJson = responseData;
totalCount = itemsJson.length;
} else if (responseData is Map<String, dynamic>) {
final data = responseData;
if (data.containsKey(itemsKey)) {
// Paginated response: { items: [...], total: N }
itemsJson = data[itemsKey] as List<dynamic>;
totalCount = data[totalCountKey] as int? ?? itemsJson.length;
} else {
// Try common wrapper patterns: { data: [...] } or { results: [...] }
itemsJson = (data['data'] ?? data['results'] ?? []) as List<dynamic>;
totalCount = data['count'] as int? ??
data['total'] as int? ??
data['totalCount'] as int? ??
itemsJson.length;
}
} else {
throw FormatException('Unexpected response type: ${responseData.runtimeType}');
}
final items = itemsJson
.map((json) => fromJson(json as Map<String, dynamic>))
.toList();
final hasMore = offset != null && limit != null
? (offset + items.length) < totalCount
: false;
return DataGridResult(
items: items,
totalCount: totalCount,
hasMore: hasMore,
);
}
}
@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'data_grid_config.dart';
import 'data_grid_provider.dart';
import 'data_grid_state.dart';
import 'widgets/data_grid_bulk_actions.dart';
import 'widgets/data_grid_empty_state.dart';
import 'widgets/data_grid_footer.dart';
import 'widgets/data_grid_header.dart';
import 'widgets/data_grid_row.dart';
import 'widgets/data_grid_search_bar.dart';
/// A configurable data grid widget for displaying tabular data.
///
/// Features:
/// - Sortable columns
/// - Row selection (single and bulk)
/// - Per-row and bulk actions
/// - Search/filtering
/// - Pagination or infinite scroll
/// - Custom cell rendering
/// - Empty, loading, and error states
///
/// Usage:
/// ```dart
/// DataGrid<Container>(
/// provider: containersGridProvider,
/// config: containersGridConfig,
/// idSelector: (c) => c.id,
/// )
/// ```
class DataGrid<T> extends ConsumerWidget {
const DataGrid({
super.key,
required this.provider,
required this.config,
required this.idSelector,
this.toolbarActions,
});
/// The Riverpod provider for this grid's state.
final StateNotifierProvider<DataGridController<T>, DataGridState<T>> provider;
/// Grid configuration.
final DataGridConfig<T> config;
/// Function to extract unique ID from an item.
final Object Function(T item) idSelector;
/// Additional actions to show in the toolbar (next to search).
final List<Widget>? toolbarActions;
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(provider);
final controller = ref.read(provider.notifier);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Toolbar: search + bulk actions + custom actions
if (config.enableSearch ||
(state.selectedCount > 0 && config.bulkActions.isNotEmpty) ||
toolbarActions != null)
_buildToolbar(context, state, controller),
// Header
if (config.showHeader)
DataGridHeader<T>(
config: config,
sortColumnIndex: state.sortColumnIndex,
sortDescending: state.sortDescending,
onSort: controller.sortBy,
showCheckbox: config.rowsSelectable,
allSelected: state.allSelected,
someSelected: state.someSelected,
onSelectAll: controller.toggleSelectAll,
),
// Content area
Expanded(
child: _buildContent(context, state, controller),
),
// Footer
if (config.showFooter)
DataGridFooter(
totalCount: state.totalCount,
displayedCount: state.items.length,
dataMode: config.dataMode,
currentPage: state.currentPage,
onPageChange: controller.goToPage,
isLoading: state.isLoading,
),
],
);
}
Widget _buildToolbar(
BuildContext context,
DataGridState<T> state,
DataGridController<T> controller,
) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
border: Border(
bottom: BorderSide(color: colorScheme.outlineVariant),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (config.enableSearch)
DataGridSearchBar(
onSearch: controller.search,
onClear: controller.clearSearch,
hintText: config.searchHint,
initialValue: state.searchQuery,
),
if (state.selectedCount > 0 && config.bulkActions.isNotEmpty) ...[
const SizedBox(width: 16),
Expanded(
child: DataGridBulkActions<T>(
selectedCount: state.selectedCount,
bulkActions: config.bulkActions,
onClearSelection: controller.clearSelection,
getSelectedItems: controller.getSelectedItems,
),
),
] else ...[
const Spacer(),
],
if (toolbarActions != null) ...toolbarActions!,
],
),
);
}
Widget _buildContent(
BuildContext context,
DataGridState<T> state,
DataGridController<T> controller,
) {
// Initial loading state
if (state.isInitialLoad && state.isLoading) {
return config.loadingBuilder?.call(context) ??
const DataGridLoadingState();
}
// Error state
if (state.hasError && state.items.isEmpty) {
return config.errorBuilder?.call(context, state.error!, controller.refresh) ??
DataGridErrorState(
error: state.error!,
onRetry: controller.refresh,
);
}
// Empty state
if (state.isEmpty) {
return config.emptyStateBuilder?.call(context) ??
DataGridEmptyState(
title: state.searchQuery.isNotEmpty
? 'No results found'
: 'No items found',
subtitle: state.searchQuery.isNotEmpty
? 'Try a different search term'
: null,
);
}
// Data rows
return _buildListView(state, controller);
}
Widget _buildListView(
DataGridState<T> state,
DataGridController<T> controller,
) {
final isInfiniteScroll = config.dataMode is InfiniteDataMode;
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (isInfiniteScroll &&
notification is ScrollEndNotification &&
notification.metrics.extentAfter < 200) {
controller.loadMore();
}
return false;
},
child: ListView.builder(
itemCount: state.items.length + (state.isLoading && !state.isInitialLoad ? 1 : 0),
itemBuilder: (context, index) {
// Loading indicator at bottom for infinite scroll
if (index >= state.items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final item = state.items[index];
final isSelected = controller.isSelected(item);
return DataGridRow<T>(
item: item,
index: index,
config: config,
isSelected: isSelected,
onSelect: () => controller.toggleSelection(item),
onTap: config.onRowTap != null ? () => config.onRowTap!(item) : null,
backgroundColor: config.rowColor?.call(context, item, index),
);
},
),
);
}
}
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
/// Per-row action for DataGrid.
class DataGridAction<T> {
const DataGridAction({
required this.icon,
required this.label,
required this.onTap,
this.showWhen,
this.destructive = false,
this.requiresConfirmation = false,
this.confirmationMessage,
});
/// Icon to display in the action menu.
final IconData icon;
/// Label for the action.
final String label;
/// Callback when the action is triggered.
final Future<void> Function(T item) onTap;
/// Condition to show/hide this action for specific items.
final bool Function(T item)? showWhen;
/// Whether this is a destructive action (styled differently).
final bool destructive;
/// Whether to show a confirmation dialog before executing.
final bool requiresConfirmation;
/// Custom confirmation message. Defaults to "Are you sure?".
final String? confirmationMessage;
/// Checks if this action should be shown for the given item.
bool shouldShow(T item) => showWhen?.call(item) ?? true;
}
/// Bulk action for selected rows in DataGrid.
class DataGridBulkAction<T> {
const DataGridBulkAction({
required this.icon,
required this.label,
required this.onTap,
this.minSelected = 1,
this.maxSelected,
this.destructive = false,
this.requiresConfirmation = false,
this.confirmationMessage,
});
/// Icon to display.
final IconData icon;
/// Label for the action.
final String label;
/// Callback when the action is triggered with selected items.
final Future<void> Function(List<T> items) onTap;
/// Minimum number of items that must be selected.
final int minSelected;
/// Maximum number of items that can be selected (null = no limit).
final int? maxSelected;
/// Whether this is a destructive action.
final bool destructive;
/// Whether to show a confirmation dialog before executing.
final bool requiresConfirmation;
/// Custom confirmation message.
final String? confirmationMessage;
/// Checks if this action is available for the given selection count.
bool isAvailable(int selectedCount) {
if (selectedCount < minSelected) return false;
if (maxSelected != null && selectedCount > maxSelected!) return false;
return true;
}
}
@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
/// Column width specification for DataGrid columns.
sealed class DataGridColumnWidth {
const DataGridColumnWidth._();
/// Fixed width in logical pixels.
const factory DataGridColumnWidth.fixed(double width) = GridFixedWidth;
/// Flexible width with flex factor (like Expanded).
const factory DataGridColumnWidth.flex([int flex]) = GridFlexWidth;
/// Fraction of available width (0.0 to 1.0).
const factory DataGridColumnWidth.fraction(double fraction) =
GridFractionWidth;
}
/// Fixed column width.
final class GridFixedWidth extends DataGridColumnWidth {
const GridFixedWidth(this.width) : super._();
final double width;
}
/// Flexible column width.
final class GridFlexWidth extends DataGridColumnWidth {
const GridFlexWidth([this.flex = 1]) : super._();
final int flex;
}
/// Fractional column width.
final class GridFractionWidth extends DataGridColumnWidth {
const GridFractionWidth(this.fraction)
: assert(fraction > 0 && fraction <= 1),
super._();
final double fraction;
}
/// Column alignment options.
enum DataGridColumnAlignment {
start,
center,
end,
}
/// Column definition for DataGrid.
class DataGridColumn<T> {
const DataGridColumn({
required this.header,
required this.valueBuilder,
this.cellBuilder,
this.cellControlsBuilder,
this.width = const DataGridColumnWidth.flex(1),
this.alignment = DataGridColumnAlignment.start,
this.sortable = false,
this.sortField,
this.searchable = false,
this.visible = true,
this.tooltip,
});
/// Column header text.
final String header;
/// Extracts the string value from an item for sorting/searching.
final String Function(T item) valueBuilder;
/// Custom cell widget builder. If null, displays valueBuilder result as text.
final Widget Function(BuildContext context, T item)? cellBuilder;
/// Additional controls to show in the cell (e.g., quick actions).
final Widget Function(BuildContext context, T item)? cellControlsBuilder;
/// Column width specification.
final DataGridColumnWidth width;
/// Text alignment within the column.
final DataGridColumnAlignment alignment;
/// Whether this column can be sorted.
final bool sortable;
/// API field name for server-side sorting. Defaults to using header if null.
final String? sortField;
/// Whether this column is included in search.
final bool searchable;
/// Whether this column is visible.
final bool visible;
/// Tooltip builder for cell hover.
final String Function(T item)? tooltip;
/// Gets the effective sort field name.
String get effectiveSortField => sortField ?? header.toLowerCase();
/// Converts alignment enum to CrossAxisAlignment.
CrossAxisAlignment get crossAxisAlignment => switch (alignment) {
DataGridColumnAlignment.start => CrossAxisAlignment.start,
DataGridColumnAlignment.center => CrossAxisAlignment.center,
DataGridColumnAlignment.end => CrossAxisAlignment.end,
};
/// Converts alignment enum to TextAlign.
TextAlign get textAlign => switch (alignment) {
DataGridColumnAlignment.start => TextAlign.start,
DataGridColumnAlignment.center => TextAlign.center,
DataGridColumnAlignment.end => TextAlign.end,
};
}
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'data_grid_action.dart';
import 'data_grid_column.dart';
/// Data loading mode for the grid.
sealed class DataGridDataMode {
const DataGridDataMode._();
/// Load all data at once.
const factory DataGridDataMode.all() = AllDataMode;
/// Paginated loading with page controls.
const factory DataGridDataMode.paginated({int pageSize}) = PaginatedDataMode;
/// Infinite scroll loading.
const factory DataGridDataMode.infinite({
int initialLoad,
int loadMoreThreshold,
}) = InfiniteDataMode;
}
/// Load all data mode.
final class AllDataMode extends DataGridDataMode {
const AllDataMode() : super._();
}
/// Paginated data mode.
final class PaginatedDataMode extends DataGridDataMode {
const PaginatedDataMode({this.pageSize = 25}) : super._();
final int pageSize;
}
/// Infinite scroll data mode.
final class InfiniteDataMode extends DataGridDataMode {
const InfiniteDataMode({
this.initialLoad = 50,
this.loadMoreThreshold = 10,
}) : super._();
final int initialLoad;
final int loadMoreThreshold;
}
/// Main configuration for a DataGrid.
class DataGridConfig<T> {
const DataGridConfig({
required this.columns,
this.actions = const [],
this.bulkActions = const [],
this.rowsSelectable = false,
this.showHeader = true,
this.showFooter = true,
this.enableSearch = false,
this.searchHint = 'Search...',
this.defaultSortColumn,
this.defaultSortDescending = false,
this.emptyStateBuilder,
this.loadingBuilder,
this.errorBuilder,
this.onRowTap,
this.cellPadding = const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
this.headerHeight = 48.0,
this.rowHeight,
this.dataMode = const DataGridDataMode.all(),
this.rowColor,
this.alternatingRowColors = false,
});
/// Column definitions.
final List<DataGridColumn<T>> columns;
/// Per-row actions (shown in actions menu).
final List<DataGridAction<T>> actions;
/// Bulk actions for selected rows.
final List<DataGridBulkAction<T>> bulkActions;
/// Whether rows can be selected.
final bool rowsSelectable;
/// Whether to show the header row.
final bool showHeader;
/// Whether to show the footer with count/pagination.
final bool showFooter;
/// Whether to show search bar.
final bool enableSearch;
/// Placeholder text for search input.
final String searchHint;
/// Index of column to sort by default.
final int? defaultSortColumn;
/// Whether default sort is descending.
final bool defaultSortDescending;
/// Custom empty state widget builder.
final Widget Function(BuildContext context)? emptyStateBuilder;
/// Custom loading widget builder.
final Widget Function(BuildContext context)? loadingBuilder;
/// Custom error widget builder.
final Widget Function(BuildContext context, Object error, VoidCallback retry)?
errorBuilder;
/// Callback when a row is tapped.
final void Function(T item)? onRowTap;
/// Padding for each cell.
final EdgeInsetsGeometry cellPadding;
/// Height of the header row.
final double headerHeight;
/// Height of each data row. If null, rows size to content.
final double? rowHeight;
/// Data loading mode.
final DataGridDataMode dataMode;
/// Custom row background color builder.
final Color? Function(BuildContext context, T item, int index)? rowColor;
/// Whether to use alternating row colors.
final bool alternatingRowColors;
/// Gets visible columns only.
List<DataGridColumn<T>> get visibleColumns =>
columns.where((c) => c.visible).toList();
/// Gets searchable column indices.
List<int> get searchableColumnIndices => columns
.asMap()
.entries
.where((e) => e.value.searchable)
.map((e) => e.key)
.toList();
}
@@ -0,0 +1,88 @@
/// DataGrid component for displaying tabular data.
///
/// This library provides a configurable, feature-rich data grid widget
/// for Flutter applications using Riverpod for state management.
///
/// Features:
/// - Sortable columns
/// - Row selection (single and bulk)
/// - Per-row and bulk actions with confirmations
/// - Search/filtering
/// - Pagination or infinite scroll
/// - Custom cell rendering
/// - Empty, loading, and error states
/// - Responsive column widths
///
/// Example:
/// ```dart
/// // Define your data source
/// final usersSource = InMemoryDataSource<User>(
/// items: users,
/// searchMatcher: (user, query) =>
/// user.name.toLowerCase().contains(query.toLowerCase()),
/// );
///
/// // Define grid configuration
/// final usersConfig = DataGridConfig<User>(
/// columns: [
/// DataGridColumn(
/// header: 'Name',
/// valueBuilder: (u) => u.name,
/// sortable: true,
/// searchable: true,
/// ),
/// DataGridColumn(
/// header: 'Email',
/// valueBuilder: (u) => u.email,
/// ),
/// DataGridColumn(
/// header: 'Status',
/// valueBuilder: (u) => u.status,
/// cellBuilder: (context, u) => StatusBadge(status: u.status),
/// ),
/// ],
/// actions: [
/// DataGridAction(
/// icon: Icons.edit,
/// label: 'Edit',
/// onTap: (user) async => editUser(user),
/// ),
/// DataGridAction(
/// icon: Icons.delete,
/// label: 'Delete',
/// onTap: (user) async => deleteUser(user),
/// destructive: true,
/// requiresConfirmation: true,
/// ),
/// ],
/// rowsSelectable: true,
/// enableSearch: true,
/// );
///
/// // Create the provider
/// final usersGridProvider = dataGridProvider<User>(
/// source: usersSource,
/// config: usersConfig,
/// idSelector: (u) => u.id,
/// );
///
/// // Use in widget
/// DataGrid<User>(
/// provider: usersGridProvider,
/// config: usersConfig,
/// idSelector: (u) => u.id,
/// )
/// ```
library;
export 'package:flutter_riverpod/legacy.dart'
show StateNotifierProvider, StateNotifier;
export 'data_grid.dart';
export 'data_grid_action.dart';
export 'data_grid_column.dart';
export 'data_grid_config.dart';
export 'data_grid_provider.dart';
export 'data_grid_source.dart';
export 'data_grid_state.dart';
export 'widgets/data_grid_empty_state.dart';
@@ -0,0 +1,303 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'data_grid_config.dart';
import 'data_grid_source.dart';
import 'data_grid_state.dart';
/// Controller for a DataGrid instance.
///
/// Manages loading, searching, sorting, and selection state.
class DataGridController<T> extends StateNotifier<DataGridState<T>> {
DataGridController({
required this.source,
required this.config,
required this.idSelector,
}) : super(DataGridState<T>(
sortColumnIndex: config.defaultSortColumn,
sortDescending: config.defaultSortDescending,
)) {
// Initial load
_load();
}
/// Data source for fetching items.
final DataGridSource<T> source;
/// Grid configuration.
final DataGridConfig<T> config;
/// Function to extract unique ID from an item.
final Object Function(T item) idSelector;
/// Debounce timer for search.
Timer? _searchDebounce;
/// All items before local filtering (for local search).
List<T> _allItems = [];
@override
void dispose() {
_searchDebounce?.cancel();
super.dispose();
}
/// Loads or reloads data from the source.
Future<void> _load({bool refresh = false}) async {
if (refresh) {
state = state.copyWith(isLoading: true, error: null);
} else {
state = state.copyWith(isLoading: true, isInitialLoad: true, error: null);
}
try {
final sortField = state.sortColumnIndex != null
? config.columns[state.sortColumnIndex!].effectiveSortField
: null;
final (offset, limit) = _getPaginationParams();
final result = await source.fetch(
sortField: sortField,
sortDescending: state.sortDescending,
offset: offset,
limit: limit,
);
// Store all items for local filtering
_allItems = result.items;
// Apply local filter if search query exists
final filteredItems = _applyLocalFilter(_allItems);
state = state.copyWith(
items: filteredItems,
totalCount: filteredItems.length,
hasMore: result.hasMore,
isLoading: false,
isInitialLoad: false,
error: null,
);
} catch (e, stack) {
debugPrint('DataGrid load error: $e\n$stack');
state = state.copyWith(
isLoading: false,
isInitialLoad: false,
error: e,
);
}
}
/// Applies local filtering based on search query.
List<T> _applyLocalFilter(List<T> items) {
if (state.searchQuery.isEmpty) return items;
final query = state.searchQuery.toLowerCase();
return items.where((item) {
// Check all searchable columns
for (final column in config.columns) {
if (column.searchable) {
final value = column.valueBuilder(item);
if (value.toLowerCase().contains(query)) {
return true;
}
}
}
return false;
}).toList();
}
/// Gets pagination parameters based on data mode.
(int?, int?) _getPaginationParams() {
return switch (config.dataMode) {
AllDataMode() => (null, null),
PaginatedDataMode(:final pageSize) => (
state.currentPage * pageSize,
pageSize,
),
InfiniteDataMode(:final initialLoad) => (0, initialLoad),
};
}
/// Refreshes the grid data.
Future<void> refresh() => _load(refresh: true);
/// Sets the search query with debouncing (filters locally).
void search(String query) {
_searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 150), () {
if (state.searchQuery != query) {
state = state.copyWith(searchQuery: query, currentPage: 0);
_applyFilterAndUpdateState();
}
});
}
/// Clears the search query.
void clearSearch() {
_searchDebounce?.cancel();
if (state.searchQuery.isNotEmpty) {
state = state.copyWith(searchQuery: '', currentPage: 0);
_applyFilterAndUpdateState();
}
}
/// Applies local filter and updates state with filtered items.
void _applyFilterAndUpdateState() {
final filteredItems = _applyLocalFilter(_allItems);
state = state.copyWith(
items: filteredItems,
totalCount: filteredItems.length,
);
}
/// Sorts by the given column index.
void sortBy(int columnIndex) {
final column = config.columns[columnIndex];
if (!column.sortable) return;
final newDescending =
state.sortColumnIndex == columnIndex ? !state.sortDescending : false;
state = state.copyWith(
sortColumnIndex: columnIndex,
sortDescending: newDescending,
currentPage: 0,
);
_load(refresh: true);
}
/// Clears sorting.
void clearSort() {
state = state.copyWith(
sortColumnIndex: null,
sortDescending: false,
currentPage: 0,
);
_load(refresh: true);
}
/// Toggles selection of an item.
void toggleSelection(T item) {
if (!config.rowsSelectable) return;
final id = idSelector(item);
final newSelection = Set<Object>.from(state.selectedIds);
if (newSelection.contains(id)) {
newSelection.remove(id);
} else {
newSelection.add(id);
}
state = state.copyWith(selectedIds: newSelection);
}
/// Selects all visible items.
void selectAll() {
if (!config.rowsSelectable) return;
final allIds = state.items.map(idSelector).toSet();
state = state.copyWith(selectedIds: allIds);
}
/// Clears all selections.
void clearSelection() {
state = state.copyWith(selectedIds: {});
}
/// Toggles select all / clear all.
void toggleSelectAll() {
if (state.allSelected) {
clearSelection();
} else {
selectAll();
}
}
/// Checks if an item is selected.
bool isSelected(T item) => state.selectedIds.contains(idSelector(item));
/// Gets the selected items.
List<T> getSelectedItems() {
return state.items.where((item) => isSelected(item)).toList();
}
/// Goes to a specific page (for paginated mode).
void goToPage(int page) {
if (config.dataMode is! PaginatedDataMode) return;
final mode = config.dataMode as PaginatedDataMode;
final maxPage = (state.totalCount / mode.pageSize).ceil() - 1;
if (page < 0 || page > maxPage) return;
state = state.copyWith(currentPage: page);
_load(refresh: true);
}
/// Goes to the next page.
void nextPage() => goToPage(state.currentPage + 1);
/// Goes to the previous page.
void previousPage() => goToPage(state.currentPage - 1);
/// Loads more items (for infinite scroll mode).
Future<void> loadMore() async {
if (config.dataMode is! InfiniteDataMode) return;
if (state.isLoading || !state.hasMore) return;
state = state.copyWith(isLoading: true);
try {
final sortField = state.sortColumnIndex != null
? config.columns[state.sortColumnIndex!].effectiveSortField
: null;
final result = await source.fetch(
searchQuery: state.searchQuery.isEmpty ? null : state.searchQuery,
sortField: sortField,
sortDescending: state.sortDescending,
offset: state.items.length,
limit: (config.dataMode as InfiniteDataMode).initialLoad,
);
state = state.copyWith(
items: [...state.items, ...result.items],
totalCount: result.totalCount,
hasMore: result.hasMore,
isLoading: false,
);
} catch (e) {
state = state.copyWith(isLoading: false, error: e);
}
}
}
/// Creates a DataGridController provider for a specific grid.
///
/// Usage:
/// ```dart
/// final containersGridProvider = dataGridProvider<Container>(
/// source: containersDataSource,
/// config: containersGridConfig,
/// idSelector: (c) => c.id,
/// );
/// ```
StateNotifierProvider<DataGridController<T>, DataGridState<T>>
dataGridProvider<T>({
required DataGridSource<T> source,
required DataGridConfig<T> config,
required Object Function(T) idSelector,
}) {
return StateNotifierProvider<DataGridController<T>, DataGridState<T>>(
(ref) => DataGridController<T>(
source: source,
config: config,
idSelector: idSelector,
),
);
}

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