Compare commits

...
13 Commits
Author SHA1 Message Date
Jeroen SchweitzerandClaude Opus 4.5 04032a6dbc fix(auth): remove auto-signout on 401 in AuthInterceptor
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m2s
The AuthInterceptor was calling signOut() on any 401 error, which caused
the theme toggle to trigger logout when the preferences API returned 401.
Now 401 errors propagate to calling code for graceful handling.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 18:26:54 +01:00
21 changed files with 737 additions and 94 deletions
+2 -4
View File
@@ -59,10 +59,8 @@ This project uses version-tag-based CI/CD. Releases trigger automated Docker bui
3. Commit changes: `git commit -m "chore: release vX.X.X"` 3. Commit changes: `git commit -m "chore: release vX.X.X"`
4. Create git tag: `git tag vX.X.X` 4. Create git tag: `git tag vX.X.X`
5. Push with tags: `git push origin master --tags` 5. Push with tags: `git push origin master --tags`
6. Create release in Gitea UI (git.schweitz.net → Releases → New Release)
* Select the tag CI/CD auto-triggers when a tag starting with `v` is pushed.
* Add release notes (can copy from CHANGELOG)
* **Publish** the release (this triggers CI/CD)
**What happens on release:** **What happens on release:**
+64
View File
@@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.1.16] - 2026-01-05
### Fixed
- Theme toggle causing logout due to AuthInterceptor auto-signout on 401
- Removed aggressive `signOut()` call in `AuthInterceptor.onError`
- 401 errors now propagate to calling code for graceful handling
- Preferences API 401 no longer triggers full logout redirect
## [1.1.15] - 2026-01-05
### Fixed
- Theme toggle causing auth issues due to AuthNotifier auto-dispose
- Applied `@persistentRiverpod` annotation to AuthNotifier
- AuthProvider now persists for app lifetime, preventing rebuild on theme change
## [1.1.14] - 2026-01-04
### Fixed
- Theme toggle causing auth issues due to ThemeProvider auto-dispose
- Added `@persistentRiverpod` annotation for providers that need keepAlive
- ThemeProvider now persists for app lifetime
### Added
- `@persistentRiverpod` annotation in `core/providers/annotations.dart`
- Reusable annotation for providers that should not auto-dispose
- Documented in ARCHITECTURE.md
## [1.1.13] - 2026-01-04
### Added
- Settings page with Appearance, Navigation, and Account sections
- Theme toggle in user profile dropdown (System/Light/Dark)
- Theme syncs with API preferences on login
- Default room preference syncs with backend
### Changed
- Theme changes now persist to both local storage and API
## [1.1.12] - 2026-01-04
### Changed
- Control Room navigation reorganized:
- New "Stack" section with Containers and Proxy Hosts
- New "Data Management" section with PostgreSQL, Redis, Qdrant, Neo4j placeholders
- Removed: Networks, Volumes, Images (Portainer) and Redirections, Streams, Certificates (NPM)
## [1.1.11] - 2026-01-04
### Fixed
- API client providers now use `keepAlive: true` to prevent Ref invalidation
- Fixes "DioException [unknown]: null" error on /security/users and other API pages
- AuthInterceptor's stored Ref was becoming invalid when provider auto-disposed
## [1.1.10] - 2026-01-04
### Changed
- Removed page swipe transitions - all navigation is now instant (NoTransitionPage)
## [1.1.9] - 2026-01-04
### Changed
- Moved health check to `/health` directory - URL is now `/health` instead of `/health.html`
- Enables NPM forward auth path exclusion for health endpoint
## [1.1.8] - 2026-01-04 ## [1.1.8] - 2026-01-04
### Changed ### Changed
+35
View File
@@ -420,6 +420,41 @@ ContainerRepository containerRepository(Ref ref) {
} }
``` ```
### Persistent Providers
By default, `@riverpod` generates providers with `isAutoDispose: true`, meaning they dispose when no longer watched. This causes issues for:
- **API clients** with interceptors that store a `Ref`
- **App-level state** like theme, auth, config
- **Providers with listeners** to other providers
Use `@persistentRiverpod` from `core/providers/annotations.dart` for these cases:
```dart
import 'package:tatlock_ui/core/providers/annotations.dart';
// ✅ Correct - persists for app lifetime
@persistentRiverpod
Dio coreApiClient(Ref ref) { ... }
@persistentRiverpod
class ThemeNotifier extends _$ThemeNotifier { ... }
// ❌ Wrong - auto-dispose can invalidate stored Ref
@riverpod
Dio coreApiClient(Ref ref) { ... }
```
**When to use `@persistentRiverpod`:**
| Use Case | Annotation | Example |
|----------|------------|---------|
| API clients with interceptors | `@persistentRiverpod` | `coreApiClient`, `tatlockApiClient` |
| Auth state provider | `@persistentRiverpod` | `AuthNotifier` |
| Theme/config providers | `@persistentRiverpod` | `ThemeNotifier` |
| Feature data providers | `@riverpod` (default) | `ContainersNotifier` |
| UI state providers | `@riverpod` (default) | `SearchFilterNotifier` |
## File Naming Conventions ## File Naming Conventions
| Type | Convention | Example | | Type | Convention | Example |
+3 -2
View File
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/core/api/api_interceptors.dart'; import 'package:tatlock_ui/core/api/api_interceptors.dart';
import 'package:tatlock_ui/core/config/app_config.dart'; import 'package:tatlock_ui/core/config/app_config.dart';
import 'package:tatlock_ui/core/providers/annotations.dart';
import 'api_client_native.dart' if (dart.library.html) 'api_client_web.dart' import 'api_client_native.dart' if (dart.library.html) 'api_client_web.dart'
as platform; as platform;
@@ -9,7 +10,7 @@ import 'api_client_native.dart' if (dart.library.html) 'api_client_web.dart'
part 'api_client.g.dart'; part 'api_client.g.dart';
/// Provides the Dio instance for Core API. /// Provides the Dio instance for Core API.
@riverpod @persistentRiverpod
Dio coreApiClient(Ref ref) { Dio coreApiClient(Ref ref) {
final options = BaseOptions( final options = BaseOptions(
baseUrl: AppConfig.coreApiUrl, baseUrl: AppConfig.coreApiUrl,
@@ -33,7 +34,7 @@ Dio coreApiClient(Ref ref) {
} }
/// Provides the Dio instance for Tatlock API. /// Provides the Dio instance for Tatlock API.
@riverpod @persistentRiverpod
Dio tatlockApiClient(Ref ref) { Dio tatlockApiClient(Ref ref) {
final options = BaseOptions( final options = BaseOptions(
baseUrl: AppConfig.tatlockApiUrl, baseUrl: AppConfig.tatlockApiUrl,
+3 -10
View File
@@ -38,16 +38,9 @@ class AuthInterceptor extends Interceptor {
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) { void onError(DioException err, ErrorInterceptorHandler handler) {
// Skip auth error handling for LAN development // Don't auto-signout on 401 - let calling code handle auth errors gracefully.
if (!AppConfig.requiresAuth) { // Auto-signout was causing issues (e.g., theme toggle triggering logout when
handler.next(err); // preferences API returned 401).
return;
}
if (err.response?.statusCode == 401) {
// Token expired - trigger re-authentication
_ref.read(authProvider.notifier).signOut();
}
handler.next(err); handler.next(err);
} }
} }
+2 -1
View File
@@ -6,6 +6,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart'; import '../config/app_config.dart';
import '../providers/annotations.dart';
import 'auth_datasource.dart'; import 'auth_datasource.dart';
import 'auth_state.dart'; import 'auth_state.dart';
import 'oidc_service.dart'; import 'oidc_service.dart';
@@ -24,7 +25,7 @@ part 'auth_provider.g.dart';
/// ///
/// After OIDC authentication, syncs with core-api via POST /auth/sync /// After OIDC authentication, syncs with core-api via POST /auth/sync
/// to get user profile, roles, and preferences. /// to get user profile, roles, and preferences.
@riverpod @persistentRiverpod
class AuthNotifier extends _$AuthNotifier { class AuthNotifier extends _$AuthNotifier {
// Storage keys // Storage keys
static const _accessTokenKey = 'auth_access_token'; static const _accessTokenKey = 'auth_access_token';
+15
View File
@@ -0,0 +1,15 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
/// Riverpod annotation for providers that should persist for the app lifetime.
///
/// Use this instead of `@riverpod` when:
/// - The provider holds app-level state (theme, auth, config)
/// - The provider stores a Ref that must remain valid (API clients with interceptors)
/// - Disposing would cause flickering or re-initialization issues
///
/// Example:
/// ```dart
/// @persistentRiverpod
/// class ThemeNotifier extends _$ThemeNotifier { ... }
/// ```
const persistentRiverpod = Riverpod(keepAlive: true);
+43 -1
View File
@@ -1,6 +1,10 @@
import 'dart:developer' as developer;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/providers/annotations.dart';
part 'theme_provider.g.dart'; part 'theme_provider.g.dart';
@@ -17,13 +21,21 @@ enum ThemeSetting {
} }
/// Provider for theme setting state. /// Provider for theme setting state.
@riverpod ///
/// Syncs with API preferences when user is authenticated. On login, the theme
/// from API preferences takes precedence over local storage.
@persistentRiverpod
class ThemeNotifier extends _$ThemeNotifier { class ThemeNotifier extends _$ThemeNotifier {
static const _prefsKey = 'theme_setting'; static const _prefsKey = 'theme_setting';
@override @override
ThemeSetting build() { ThemeSetting build() {
// Load local setting first for immediate UI
_loadSavedSetting(); _loadSavedSetting();
// Listen for auth state changes to sync from API preferences
_syncFromAuthPreferences();
return ThemeSetting.system; return ThemeSetting.system;
} }
@@ -39,6 +51,36 @@ class ThemeNotifier extends _$ThemeNotifier {
} }
} }
/// Listen to auth state and sync theme from API preferences.
void _syncFromAuthPreferences() {
ref.listen(authProvider, (_, next) {
next.whenData((auth) {
final apiTheme = auth.preferences?.theme;
if (apiTheme != null && apiTheme.isNotEmpty) {
try {
final themeSetting = ThemeSetting.values.byName(apiTheme);
if (themeSetting != state) {
developer.log(
'Syncing theme from API: $apiTheme',
name: 'theme',
);
state = themeSetting;
// Also persist to local storage for offline use
_saveToLocalStorage(themeSetting);
}
} catch (_) {
// Invalid theme value from API, keep current
}
}
});
});
}
Future<void> _saveToLocalStorage(ThemeSetting setting) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, setting.name);
}
/// Update theme setting and persist to storage. /// Update theme setting and persist to storage.
Future<void> setSetting(ThemeSetting setting) async { Future<void> setSetting(ThemeSetting setting) async {
state = setting; state = setting;
@@ -69,44 +69,11 @@ class _SectionContent extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
switch (nav) { return switch (nav) {
case ControlRoomNav.containers: ControlRoomNav.containers => const _ContainersSection(),
return const _ContainersSection(); ControlRoomNav.proxyHosts => const ProxyHostsPage(),
case ControlRoomNav.proxyHosts: _ => _PlaceholderSection(nav: nav),
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),
),
],
);
} }
} }
@@ -150,6 +117,36 @@ class _PlaceholderSection extends StatelessWidget {
} }
} }
/// 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),
),
],
);
}
}
/// Stacks filter panel for Containers section (includes "All Containers" option). /// Stacks filter panel for Containers section (includes "All Containers" option).
class _StacksFilterPanel extends ConsumerWidget { class _StacksFilterPanel extends ConsumerWidget {
const _StacksFilterPanel(); const _StacksFilterPanel();
+17 -19
View File
@@ -1,35 +1,32 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/features/control_room/presentation/pages/control_room_page.dart'; import 'package:tatlock_ui/features/control_room/presentation/pages/control_room_page.dart';
import 'package:tatlock_ui/routing/app_router.dart';
import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart'; import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart';
/// Route paths for Control Room. /// Route paths for Control Room.
abstract class ControlRoomRoutes { abstract class ControlRoomRoutes {
static const base = '/control-room'; static const base = '/control-room';
// Portainer // Stack
static const containers = '/control-room/containers'; 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 proxyHosts = '/control-room/proxy-hosts';
static const redirections = '/control-room/redirections'; // Data Management
static const streams = '/control-room/streams'; static const postgres = '/control-room/postgres';
static const certificates = '/control-room/certificates'; static const redis = '/control-room/redis';
static const qdrant = '/control-room/qdrant';
static const neo4j = '/control-room/neo4j';
} }
/// Control Room navigation items with section grouping. /// Control Room navigation items with section grouping.
enum ControlRoomNav { enum ControlRoomNav {
// Portainer section // Stack Management section - Docker containers and reverse proxy
containers('containers', 'Containers', Icons.dns, 'Portainer'), containers('containers', 'Containers', Icons.dns, 'Stack Management'),
networks('networks', 'Networks', Icons.hub, 'Portainer'), proxyHosts('proxy-hosts', 'Proxy Hosts', Icons.public, 'Stack Management'),
volumes('volumes', 'Volumes', Icons.storage, 'Portainer'), // Data Management section - Database browsers
images('images', 'Images', Icons.photo_library, 'Portainer'), postgres('postgres', 'PostgreSQL', Icons.table_chart, 'Data Management'),
// NPM section redis('redis', 'Redis', Icons.memory, 'Data Management'),
proxyHosts('proxy-hosts', 'Proxy Hosts', Icons.public, 'NPM'), qdrant('qdrant', 'Qdrant', Icons.scatter_plot, 'Data Management'),
redirections('redirections', 'Redirections', Icons.alt_route, 'NPM'), neo4j('neo4j', 'Neo4j', Icons.hub, 'Data Management');
streams('streams', 'Streams', Icons.stream, 'NPM'),
certificates('certificates', 'SSL Certificates', Icons.verified_user, 'NPM');
const ControlRoomNav(this.id, this.label, this.icon, this.section); const ControlRoomNav(this.id, this.label, this.icon, this.section);
@@ -66,7 +63,8 @@ List<RouteBase> controlRoomRoutes() {
GoRoute( GoRoute(
path: nav.path, path: nav.path,
name: 'controlRoom${_capitalize(nav.id.replaceAll('-', '_'))}', name: 'controlRoom${_capitalize(nav.id.replaceAll('-', '_'))}',
builder: (context, state) => ControlRoomPage(nav: nav), pageBuilder: (context, state) =>
noTransitionPage(context, state, ControlRoomPage(nav: nav)),
), ),
]; ];
} }
+3 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/features/security/presentation/pages/security_page.dart'; import 'package:tatlock_ui/features/security/presentation/pages/security_page.dart';
import 'package:tatlock_ui/routing/app_router.dart';
import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart'; import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart';
/// Route paths for Security room. /// Route paths for Security room.
@@ -60,7 +61,8 @@ List<RouteBase> securityRoutes() {
GoRoute( GoRoute(
path: nav.path, path: nav.path,
name: 'security${_capitalize(nav.id.replaceAll('-', '_'))}', name: 'security${_capitalize(nav.id.replaceAll('-', '_'))}',
builder: (context, state) => SecurityPage(nav: nav), pageBuilder: (context, state) =>
noTransitionPage(context, state, SecurityPage(nav: nav)),
), ),
]; ];
} }
@@ -0,0 +1,306 @@
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/theme/theme_provider.dart';
/// Settings page with user preferences.
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final currentTheme = ref.watch(themeProvider);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
body: authState.when(
data: (auth) => SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Page header
Text(
'Settings',
style: textTheme.headlineMedium,
),
const SizedBox(height: 32),
// Appearance section
_SectionHeader(title: 'Appearance'),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
_themeIcon(currentTheme),
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Theme', style: textTheme.titleMedium),
Text(
'Choose your preferred color scheme',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
DropdownButton<ThemeSetting>(
value: currentTheme,
underline: const SizedBox(),
onChanged: (value) {
if (value != null) {
_updateTheme(ref, value);
}
},
items: const [
DropdownMenuItem(
value: ThemeSetting.system,
child: Text('System'),
),
DropdownMenuItem(
value: ThemeSetting.light,
child: Text('Light'),
),
DropdownMenuItem(
value: ThemeSetting.dark,
child: Text('Dark'),
),
],
),
],
),
),
),
const SizedBox(height: 24),
// Navigation section
_SectionHeader(title: 'Navigation'),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
Icons.home_outlined,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Default Room',
style: textTheme.titleMedium,
),
Text(
'Room to show when app opens',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
DropdownButton<String>(
value:
auth.preferences?.defaultRoom ?? 'front-hall',
underline: const SizedBox(),
onChanged: (value) {
if (value != null) {
_updateDefaultRoom(ref, value);
}
},
items: const [
DropdownMenuItem(
value: 'front-hall',
child: Text('Front Hall'),
),
DropdownMenuItem(
value: 'control-room',
child: Text('Control Room'),
),
DropdownMenuItem(
value: 'parlor',
child: Text('Parlor'),
),
],
),
],
),
),
),
const SizedBox(height: 24),
// Account section
_SectionHeader(title: 'Account'),
const SizedBox(height: 8),
Card(
child: Column(
children: [
_AccountInfoTile(
icon: Icons.person_outline,
label: 'Name',
value: auth.userName ?? 'Not available',
),
const Divider(height: 1),
_AccountInfoTile(
icon: Icons.email_outlined,
label: 'Email',
value: auth.userEmail ?? 'Not available',
),
if (auth.roles.isNotEmpty) ...[
const Divider(height: 1),
_AccountInfoTile(
icon: Icons.shield_outlined,
label: 'Roles',
value: auth.roles.map((r) => r.name).join(', '),
),
],
],
),
),
],
),
),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
Text(
'Failed to load settings',
style: textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
error.toString(),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
IconData _themeIcon(ThemeSetting theme) {
return switch (theme) {
ThemeSetting.system => Icons.brightness_auto,
ThemeSetting.light => Icons.light_mode,
ThemeSetting.dark => Icons.dark_mode,
};
}
Future<void> _updateTheme(WidgetRef ref, ThemeSetting setting) async {
// Update local theme immediately for instant UI response
await ref.read(themeProvider.notifier).setSetting(setting);
// Sync to backend
try {
await ref.read(authProvider.notifier).updatePreferences(
theme: setting.name,
);
} catch (e) {
developer.log('Failed to sync theme preference: $e', name: 'settings');
}
}
Future<void> _updateDefaultRoom(WidgetRef ref, String room) async {
try {
await ref.read(authProvider.notifier).updatePreferences(
defaultRoom: room,
);
} catch (e) {
developer.log('Failed to sync default room: $e', name: 'settings');
}
}
}
/// Section header widget.
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.title});
final String title;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Text(
title.toUpperCase(),
style: textTheme.labelMedium?.copyWith(
color: colorScheme.primary,
letterSpacing: 1.0,
fontWeight: FontWeight.w600,
),
);
}
}
/// Account info tile widget.
class _AccountInfoTile extends StatelessWidget {
const _AccountInfoTile({
required this.icon,
required this.label,
required this.value,
});
final IconData icon;
final String label;
final String value;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
)),
const SizedBox(height: 2),
Text(value, style: textTheme.titleMedium),
],
),
),
],
),
);
}
}
+1 -4
View File
@@ -14,10 +14,7 @@ void main() async {
// Use path-based URLs on web (no-op on mobile/desktop) // Use path-based URLs on web (no-op on mobile/desktop)
configureUrlStrategy(); configureUrlStrategy();
developer.log( debugPrint('🪣 ${AppVersion.name} v${AppVersion.fullVersion}');
'${AppVersion.name} v${AppVersion.fullVersion}',
name: 'tatlock_ui',
);
// Initialize auth before starting the app. // Initialize auth before starting the app.
// This handles OIDC callback and silent auth on web. // This handles OIDC callback and silent auth on web.
+15 -5
View File
@@ -4,10 +4,19 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/features/control_room/router.dart'; import 'package:tatlock_ui/features/control_room/router.dart';
import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.dart'; import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.dart';
import 'package:tatlock_ui/features/security/router.dart'; import 'package:tatlock_ui/features/security/router.dart';
import 'package:tatlock_ui/features/settings/presentation/pages/settings_page.dart';
import 'package:tatlock_ui/shared/layouts/app_scaffold.dart'; import 'package:tatlock_ui/shared/layouts/app_scaffold.dart';
part 'app_router.g.dart'; part 'app_router.g.dart';
/// No-animation page builder for instant transitions
Page<void> noTransitionPage(BuildContext context, GoRouterState state, Widget child) {
return NoTransitionPage<void>(
key: state.pageKey,
child: child,
);
}
/// Route paths as constants. /// Route paths as constants.
abstract class AppRoutes { abstract class AppRoutes {
static const frontHall = '/'; static const frontHall = '/';
@@ -29,21 +38,22 @@ GoRouter appRouter(Ref ref) {
GoRoute( GoRoute(
path: AppRoutes.frontHall, path: AppRoutes.frontHall,
name: 'frontHall', name: 'frontHall',
builder: (context, state) => const FrontHallPage(), pageBuilder: (context, state) =>
noTransitionPage(context, state, const FrontHallPage()),
), ),
...controlRoomRoutes(), ...controlRoomRoutes(),
...securityRoutes(), ...securityRoutes(),
GoRoute( GoRoute(
path: AppRoutes.parlor, path: AppRoutes.parlor,
name: 'parlor', name: 'parlor',
builder: (context, state) => pageBuilder: (context, state) =>
const _PlaceholderPage(title: 'Parlor'), noTransitionPage(context, state, const _PlaceholderPage(title: 'Parlor')),
), ),
GoRoute( GoRoute(
path: AppRoutes.settings, path: AppRoutes.settings,
name: 'settings', name: 'settings',
builder: (context, state) => pageBuilder: (context, state) =>
const _PlaceholderPage(title: 'Settings'), noTransitionPage(context, state, const SettingsPage()),
), ),
], ],
), ),
@@ -1,7 +1,10 @@
import 'dart:developer' as developer;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart'; import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/theme/theme_provider.dart';
import 'package:tatlock_ui/routing/app_router.dart'; import 'package:tatlock_ui/routing/app_router.dart';
/// Profile dropdown menu in the header. /// Profile dropdown menu in the header.
@@ -13,6 +16,7 @@ class ProfileDropdown extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider); final authState = ref.watch(authProvider);
final currentTheme = ref.watch(themeProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return authState.when( return authState.when(
@@ -76,6 +80,102 @@ class ProfileDropdown extends ConsumerWidget {
), ),
), ),
// Theme submenu header
PopupMenuItem<String>(
enabled: false,
height: 32,
child: Text(
'THEME',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
letterSpacing: 0.5,
),
),
),
// Theme: System
PopupMenuItem<String>(
value: 'theme_system',
height: 40,
child: Row(
children: [
Icon(
Icons.brightness_auto,
size: 18,
color: currentTheme == ThemeSetting.system
? colorScheme.primary
: null,
),
const SizedBox(width: 12),
Text(
'System',
style: currentTheme == ThemeSetting.system
? TextStyle(color: colorScheme.primary)
: null,
),
const Spacer(),
if (currentTheme == ThemeSetting.system)
Icon(Icons.check, size: 16, color: colorScheme.primary),
],
),
),
// Theme: Light
PopupMenuItem<String>(
value: 'theme_light',
height: 40,
child: Row(
children: [
Icon(
Icons.light_mode,
size: 18,
color: currentTheme == ThemeSetting.light
? colorScheme.primary
: null,
),
const SizedBox(width: 12),
Text(
'Light',
style: currentTheme == ThemeSetting.light
? TextStyle(color: colorScheme.primary)
: null,
),
const Spacer(),
if (currentTheme == ThemeSetting.light)
Icon(Icons.check, size: 16, color: colorScheme.primary),
],
),
),
// Theme: Dark
PopupMenuItem<String>(
value: 'theme_dark',
height: 40,
child: Row(
children: [
Icon(
Icons.dark_mode,
size: 18,
color: currentTheme == ThemeSetting.dark
? colorScheme.primary
: null,
),
const SizedBox(width: 12),
Text(
'Dark',
style: currentTheme == ThemeSetting.dark
? TextStyle(color: colorScheme.primary)
: null,
),
const Spacer(),
if (currentTheme == ThemeSetting.dark)
Icon(Icons.check, size: 16, color: colorScheme.primary),
],
),
),
const PopupMenuDivider(),
// Logout (only if authenticated) // Logout (only if authenticated)
if (auth.isAuthenticated) if (auth.isAuthenticated)
const PopupMenuItem<String>( const PopupMenuItem<String>(
@@ -93,6 +193,12 @@ class ProfileDropdown extends ConsumerWidget {
switch (value) { switch (value) {
case 'settings': case 'settings':
context.go(AppRoutes.settings); context.go(AppRoutes.settings);
case 'theme_system':
_updateTheme(ref, ThemeSetting.system);
case 'theme_light':
_updateTheme(ref, ThemeSetting.light);
case 'theme_dark':
_updateTheme(ref, ThemeSetting.dark);
case 'logout': case 'logout':
ref.read(authProvider.notifier).signOut(); ref.read(authProvider.notifier).signOut();
} }
@@ -126,4 +232,20 @@ class ProfileDropdown extends ConsumerWidget {
} }
return name[0].toUpperCase(); return name[0].toUpperCase();
} }
/// Update theme locally and sync to API.
Future<void> _updateTheme(WidgetRef ref, ThemeSetting setting) async {
// Update local theme immediately for instant UI response
await ref.read(themeProvider.notifier).setSetting(setting);
// Sync to backend (fire-and-forget, errors logged not shown)
try {
await ref.read(authProvider.notifier).updatePreferences(
theme: setting.name,
);
} catch (e) {
// Theme still works locally even if API sync fails
developer.log('Failed to sync theme preference: $e', name: 'profile');
}
}
} }
+53
View File
@@ -0,0 +1,53 @@
# Increase buffer size for large headers from Authentik
proxy_buffers 8 16k;
proxy_buffer_size 32k;
# Exclude static assets from forward auth
# These paths bypass auth_request but still proxy to upstream
location ~ ^/(manifest\.json|favicon\.(ico|png)|health|icons|assets) {
auth_request off;
proxy_pass $forward_scheme://$server:$port;
}
# Forward authentication via standalone outpost
auth_request /outpost.goauthentik.io/auth/nginx;
error_page 401 = @goauthentik_proxy_signin;
# Capture auth response headers
auth_request_set $auth_cookie $upstream_http_set_cookie;
auth_request_set $authentik_username $upstream_http_x_authentik_username;
auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
auth_request_set $authentik_email $upstream_http_x_authentik_email;
auth_request_set $authentik_name $upstream_http_x_authentik_name;
auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
# Forward auth headers to application
add_header Set-Cookie $auth_cookie;
proxy_set_header X-authentik-username $authentik_username;
proxy_set_header X-authentik-groups $authentik_groups;
proxy_set_header X-authentik-email $authentik_email;
proxy_set_header X-authentik-name $authentik_name;
proxy_set_header X-authentik-uid $authentik_uid;
# Outpost proxy location
location /outpost.goauthentik.io {
proxy_pass https://localhost:9444/outpost.goauthentik.io;
proxy_set_header Host $host;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
# Signin redirect handler
location @goauthentik_proxy_signin {
internal;
return 302 /outpost.goauthentik.io/start?rd=$request_uri;
}
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.1.8+1 version: 1.1.16+1
environment: environment:
sdk: ^3.10.4 sdk: ^3.10.4
+6 -6
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env dart #!/usr/bin/env dart
// Generates web/health.json from pubspec.yaml // Generates web/health/health.json from pubspec.yaml
// Run: dart run tool/generate_health_json.dart // Run: dart run tool/generate_health_json.dart
// ignore_for_file: avoid_print // ignore_for_file: avoid_print
@@ -37,15 +37,15 @@ void main() {
'fullVersion': '$version+$buildNumber', 'fullVersion': '$version+$buildNumber',
}; };
final webDir = Directory('web'); final healthDir = Directory('web/health');
if (!webDir.existsSync()) { if (!healthDir.existsSync()) {
webDir.createSync(recursive: true); healthDir.createSync(recursive: true);
} }
final healthFile = File('web/health.json'); final healthFile = File('web/health/health.json');
healthFile.writeAsStringSync( healthFile.writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(health), const JsonEncoder.withIndent(' ').convert(health),
); );
print('Generated web/health.json with version $version+$buildNumber'); print('Generated web/health/health.json with version $version+$buildNumber');
} }
+8
View File
@@ -0,0 +1,8 @@
{
"status": "healthy",
"name": "tatlock_ui",
"title": "Tatlock - a Home Lab AI",
"version": "1.0.4",
"buildNumber": 1,
"fullVersion": "1.0.4+1"
}
+1 -1
View File
@@ -13,7 +13,7 @@
<h1 id="status">Loading...</h1> <h1 id="status">Loading...</h1>
<pre id="data"></pre> <pre id="data"></pre>
<script> <script>
fetch('/health.json') fetch('./health.json')
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
document.getElementById('status').textContent = data.status?.toUpperCase() || 'OK'; document.getElementById('status').textContent = data.status?.toUpperCase() || 'OK';
+2 -1
View File
@@ -36,7 +36,8 @@
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
background-color: #1a1a2e; /* Match Flutter's dark theme scaffold background (FlexScheme.aquaBlue) */
background-color: #111111;
} }
</style> </style>
</head> </head>