Compare commits

..
10 Commits
Author SHA1 Message Date
Jeroen SchweitzerandClaude Opus 4.5 a6c51f757a feat(semantics): add semantic labels for UI automation
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m0s
- Created lib/core/semantics/ with semantic ID constants and helper widget
- Enabled SemanticsBinding on web builds for accessibility tree exposure
- Added semantic IDs to:
  - ProfileDropdown (button, settings, theme options, logout)
  - TopHeaderBar room tabs (frontHall, controlRoom, security, parlor)
  - NavPanel items (nav_item_{id})

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:36:52 +01:00
19 changed files with 1019 additions and 171 deletions
+66
View File
@@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.2.0] - 2026-01-05
### Added
- **Semantic labels for UI automation** (`lib/core/semantics/`)
- `semantic_ids.dart` - Centralized semantic identifier constants
- `semantic_widget.dart` - Helper widget and extension for adding semantics
- Enables browser automation tools (Puppeteer, WebDriver) via accessibility tree
- Semantic IDs added to:
- Profile dropdown button and menu items (theme options, settings, logout)
- Room navigation tabs (Front Hall, Control Room, Security, Parlor)
- NavPanel items (sidebar navigation)
- `SemanticsBinding.instance.ensureSemantics()` enabled on web builds
## [1.1.16] - 2026-01-05
### Fixed
- Theme toggle causing logout due to AuthInterceptor auto-signout on 401
- Removed aggressive `signOut()` call in `AuthInterceptor.onError`
- 401 errors now propagate to calling code for graceful handling
- Preferences API 401 no longer triggers full logout redirect
## [1.1.15] - 2026-01-05
### Fixed
- Theme toggle causing auth issues due to AuthNotifier auto-dispose
- Applied `@persistentRiverpod` annotation to AuthNotifier
- AuthProvider now persists for app lifetime, preventing rebuild on theme change
## [1.1.14] - 2026-01-04
### Fixed
- Theme toggle causing auth issues due to ThemeProvider auto-dispose
- Added `@persistentRiverpod` annotation for providers that need keepAlive
- ThemeProvider now persists for app lifetime
### Added
- `@persistentRiverpod` annotation in `core/providers/annotations.dart`
- Reusable annotation for providers that should not auto-dispose
- Documented in ARCHITECTURE.md
## [1.1.13] - 2026-01-04
### Added
- Settings page with Appearance, Navigation, and Account sections
- Theme toggle in user profile dropdown (System/Light/Dark)
- Theme syncs with API preferences on login
- Default room preference syncs with backend
### Changed
- Theme changes now persist to both local storage and API
## [1.1.12] - 2026-01-04
### Changed
- Control Room navigation reorganized:
- New "Stack" section with Containers and Proxy Hosts
- New "Data Management" section with PostgreSQL, Redis, Qdrant, Neo4j placeholders
- Removed: Networks, Volumes, Images (Portainer) and Redirections, Streams, Certificates (NPM)
## [1.1.11] - 2026-01-04
### Fixed
- API client providers now use `keepAlive: true` to prevent Ref invalidation
- Fixes "DioException [unknown]: null" error on /security/users and other API pages
- AuthInterceptor's stored Ref was becoming invalid when provider auto-disposed
## [1.1.10] - 2026-01-04
### Changed
+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
| 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:tatlock_ui/core/api/api_interceptors.dart';
import 'package:tatlock_ui/core/config/app_config.dart';
import 'package:tatlock_ui/core/providers/annotations.dart';
import 'api_client_native.dart' if (dart.library.html) 'api_client_web.dart'
as platform;
@@ -9,7 +10,7 @@ import 'api_client_native.dart' if (dart.library.html) 'api_client_web.dart'
part 'api_client.g.dart';
/// Provides the Dio instance for Core API.
@riverpod
@persistentRiverpod
Dio coreApiClient(Ref ref) {
final options = BaseOptions(
baseUrl: AppConfig.coreApiUrl,
@@ -33,7 +34,7 @@ Dio coreApiClient(Ref ref) {
}
/// Provides the Dio instance for Tatlock API.
@riverpod
@persistentRiverpod
Dio tatlockApiClient(Ref ref) {
final options = BaseOptions(
baseUrl: AppConfig.tatlockApiUrl,
+3 -10
View File
@@ -38,16 +38,9 @@ class AuthInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// Skip auth error handling for LAN development
if (!AppConfig.requiresAuth) {
handler.next(err);
return;
}
if (err.response?.statusCode == 401) {
// Token expired - trigger re-authentication
_ref.read(authProvider.notifier).signOut();
}
// Don't auto-signout on 401 - let calling code handle auth errors gracefully.
// Auto-signout was causing issues (e.g., theme toggle triggering logout when
// preferences API returned 401).
handler.next(err);
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
import '../providers/annotations.dart';
import 'auth_datasource.dart';
import 'auth_state.dart';
import 'oidc_service.dart';
@@ -24,7 +25,7 @@ part 'auth_provider.g.dart';
///
/// After OIDC authentication, syncs with core-api via POST /auth/sync
/// to get user profile, roles, and preferences.
@riverpod
@persistentRiverpod
class AuthNotifier extends _$AuthNotifier {
// Storage keys
static const _accessTokenKey = 'auth_access_token';
+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);
+114
View File
@@ -0,0 +1,114 @@
/// Semantic identifiers for UI automation and accessibility.
///
/// These IDs are exposed via Flutter's Semantics tree, making widgets
/// discoverable by automation tools (Appium, WebDriver, Puppeteer with
/// accessibility enabled).
///
/// Naming convention: `{area}_{component}_{identifier}`
/// - area: room or feature area (e.g., profile, nav, dataGrid)
/// - component: widget type (e.g., menu, button, row)
/// - identifier: specific item (e.g., light, containers, selectAll)
library;
/// Profile dropdown menu IDs.
abstract class ProfileSemantics {
static const button = 'profile_button';
static const menu = 'profile_menu';
static const settings = 'profile_menu_settings';
static const themeSystem = 'profile_menu_theme_system';
static const themeLight = 'profile_menu_theme_light';
static const themeDark = 'profile_menu_theme_dark';
static const logout = 'profile_menu_logout';
}
/// Room tab navigation IDs.
abstract class RoomTabSemantics {
static const frontHall = 'roomTab_frontHall';
static const controlRoom = 'roomTab_controlRoom';
static const security = 'roomTab_security';
static const parlor = 'roomTab_parlor';
/// Get semantic ID for room index.
static String forIndex(int index) => switch (index) {
0 => frontHall,
1 => controlRoom,
2 => security,
3 => parlor,
_ => 'roomTab_$index',
};
}
/// Navigation panel IDs.
abstract class NavSemantics {
static const panel = 'nav_panel';
static const refresh = 'nav_refresh';
/// Generate ID for a nav item.
static String item(String id) => 'nav_item_$id';
/// Generate ID for a nav section.
static String section(String id) => 'nav_section_$id';
}
/// DataGrid component IDs.
abstract class DataGridSemantics {
static const grid = 'dataGrid';
static const search = 'dataGrid_search';
static const searchClear = 'dataGrid_search_clear';
static const selectAll = 'dataGrid_selectAll';
static const loading = 'dataGrid_loading';
static const empty = 'dataGrid_empty';
static const error = 'dataGrid_error';
static const refresh = 'dataGrid_refresh';
/// Generate ID for a column header.
static String header(String columnId) => 'dataGrid_header_$columnId';
/// Generate ID for a row.
static String row(String itemId) => 'dataGrid_row_$itemId';
/// Generate ID for a row checkbox.
static String rowCheckbox(String itemId) => 'dataGrid_row_${itemId}_checkbox';
/// Generate ID for a row actions menu.
static String rowActions(String itemId) => 'dataGrid_row_${itemId}_actions';
/// Generate ID for a row action.
static String rowAction(String itemId, String actionId) =>
'dataGrid_row_${itemId}_action_$actionId';
/// Generate ID for a bulk action button.
static String bulkAction(String actionId) => 'dataGrid_bulk_$actionId';
static const bulkClear = 'dataGrid_bulk_clear';
}
/// Dialog/modal IDs.
abstract class DialogSemantics {
static const confirm = 'dialog_confirm';
static const cancel = 'dialog_cancel';
static const close = 'dialog_close';
/// Generate ID for a named dialog.
static String named(String name) => 'dialog_$name';
/// Generate ID for a dialog action button.
static String action(String dialogName, String actionId) =>
'dialog_${dialogName}_$actionId';
}
/// Settings page IDs.
abstract class SettingsSemantics {
static const themeDropdown = 'settings_theme';
static const defaultRoomDropdown = 'settings_defaultRoom';
}
/// Loading/state indicator IDs.
abstract class StateSemantics {
static const authLoading = 'state_auth_loading';
static const authError = 'state_auth_error';
static const pageLoading = 'state_page_loading';
/// Generate ID for a snackbar.
static String snackbar(String type) => 'snackbar_$type';
}
+118
View File
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
/// Wraps a widget with semantic information for accessibility and automation.
///
/// Usage:
/// ```dart
/// SemanticWidget(
/// id: ProfileSemantics.button,
/// label: 'Open profile menu',
/// child: IconButton(...),
/// )
/// ```
///
/// For buttons, use `button: true`. For other interactive elements,
/// set the appropriate semantic properties.
class SemanticWidget extends StatelessWidget {
const SemanticWidget({
super.key,
required this.id,
required this.child,
this.label,
this.hint,
this.button = false,
this.link = false,
this.header = false,
this.textField = false,
this.enabled = true,
this.selected,
this.checked,
this.value,
this.excludeSemantics = false,
});
/// Unique identifier for this widget, exposed via [SemanticsProperties.identifier].
final String id;
/// The widget to wrap.
final Widget child;
/// Accessibility label describing the widget.
final String? label;
/// Hint text for screen readers.
final String? hint;
/// Whether this widget represents a button.
final bool button;
/// Whether this widget represents a link.
final bool link;
/// Whether this widget represents a header.
final bool header;
/// Whether this widget represents a text field.
final bool textField;
/// Whether the widget is enabled.
final bool enabled;
/// Whether the widget is selected (for toggle buttons, tabs).
final bool? selected;
/// Whether the widget is checked (for checkboxes).
final bool? checked;
/// Current value (for sliders, progress indicators).
final String? value;
/// Whether to exclude child semantics.
final bool excludeSemantics;
@override
Widget build(BuildContext context) {
return Semantics(
identifier: id,
label: label,
hint: hint,
button: button,
link: link,
header: header,
textField: textField,
enabled: enabled,
selected: selected,
checked: checked,
value: value,
excludeSemantics: excludeSemantics,
child: child,
);
}
}
/// Extension to easily wrap any widget with semantic info.
extension SemanticExtension on Widget {
/// Wraps this widget with a semantic identifier.
Widget withSemantics({
required String id,
String? label,
String? hint,
bool button = false,
bool link = false,
bool enabled = true,
bool? selected,
bool? checked,
}) {
return SemanticWidget(
id: id,
label: label,
hint: hint,
button: button,
link: link,
enabled: enabled,
selected: selected,
checked: checked,
child: this,
);
}
}
+43 -1
View File
@@ -1,6 +1,10 @@
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/providers/annotations.dart';
part 'theme_provider.g.dart';
@@ -17,13 +21,21 @@ enum ThemeSetting {
}
/// Provider for theme setting state.
@riverpod
///
/// Syncs with API preferences when user is authenticated. On login, the theme
/// from API preferences takes precedence over local storage.
@persistentRiverpod
class ThemeNotifier extends _$ThemeNotifier {
static const _prefsKey = 'theme_setting';
@override
ThemeSetting build() {
// Load local setting first for immediate UI
_loadSavedSetting();
// Listen for auth state changes to sync from API preferences
_syncFromAuthPreferences();
return ThemeSetting.system;
}
@@ -39,6 +51,36 @@ class ThemeNotifier extends _$ThemeNotifier {
}
}
/// Listen to auth state and sync theme from API preferences.
void _syncFromAuthPreferences() {
ref.listen(authProvider, (_, next) {
next.whenData((auth) {
final apiTheme = auth.preferences?.theme;
if (apiTheme != null && apiTheme.isNotEmpty) {
try {
final themeSetting = ThemeSetting.values.byName(apiTheme);
if (themeSetting != state) {
developer.log(
'Syncing theme from API: $apiTheme',
name: 'theme',
);
state = themeSetting;
// Also persist to local storage for offline use
_saveToLocalStorage(themeSetting);
}
} catch (_) {
// Invalid theme value from API, keep current
}
}
});
});
}
Future<void> _saveToLocalStorage(ThemeSetting setting) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, setting.name);
}
/// Update theme setting and persist to storage.
Future<void> setSetting(ThemeSetting setting) async {
state = setting;
@@ -69,44 +69,11 @@ class _SectionContent extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
switch (nav) {
case ControlRoomNav.containers:
return const _ContainersSection();
case ControlRoomNav.proxyHosts:
return const ProxyHostsPage();
default:
return _PlaceholderSection(nav: nav);
}
}
}
/// Containers section with optional stack filter.
class _ContainersSection extends ConsumerWidget {
const _ContainersSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedStack = ref.watch(selectedStackProvider);
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
// Stacks filter panel
const _StacksFilterPanel(),
// Divider
VerticalDivider(
width: 1,
thickness: 1,
color: colorScheme.outlineVariant,
),
// Main content - containers list or stack detail
Expanded(
child: selectedStack == null
? const ContainersListPage()
: StackDetailPage(stackId: selectedStack),
),
],
);
return switch (nav) {
ControlRoomNav.containers => const _ContainersSection(),
ControlRoomNav.proxyHosts => const ProxyHostsPage(),
_ => _PlaceholderSection(nav: nav),
};
}
}
@@ -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).
class _StacksFilterPanel extends ConsumerWidget {
const _StacksFilterPanel();
+14 -18
View File
@@ -7,30 +7,26 @@ import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart';
/// Route paths for Control Room.
abstract class ControlRoomRoutes {
static const base = '/control-room';
// Portainer
// Stack
static const containers = '/control-room/containers';
static const networks = '/control-room/networks';
static const volumes = '/control-room/volumes';
static const images = '/control-room/images';
// NPM
static const proxyHosts = '/control-room/proxy-hosts';
static const redirections = '/control-room/redirections';
static const streams = '/control-room/streams';
static const certificates = '/control-room/certificates';
// Data Management
static const postgres = '/control-room/postgres';
static const redis = '/control-room/redis';
static const qdrant = '/control-room/qdrant';
static const neo4j = '/control-room/neo4j';
}
/// Control Room navigation items with section grouping.
enum ControlRoomNav {
// Portainer section
containers('containers', 'Containers', Icons.dns, 'Portainer'),
networks('networks', 'Networks', Icons.hub, 'Portainer'),
volumes('volumes', 'Volumes', Icons.storage, 'Portainer'),
images('images', 'Images', Icons.photo_library, 'Portainer'),
// NPM section
proxyHosts('proxy-hosts', 'Proxy Hosts', Icons.public, 'NPM'),
redirections('redirections', 'Redirections', Icons.alt_route, 'NPM'),
streams('streams', 'Streams', Icons.stream, 'NPM'),
certificates('certificates', 'SSL Certificates', Icons.verified_user, 'NPM');
// Stack Management section - Docker containers and reverse proxy
containers('containers', 'Containers', Icons.dns, 'Stack Management'),
proxyHosts('proxy-hosts', 'Proxy Hosts', Icons.public, 'Stack Management'),
// Data Management section - Database browsers
postgres('postgres', 'PostgreSQL', Icons.table_chart, 'Data Management'),
redis('redis', 'Redis', Icons.memory, 'Data Management'),
qdrant('qdrant', 'Qdrant', Icons.scatter_plot, 'Data Management'),
neo4j('neo4j', 'Neo4j', Icons.hub, 'Data Management');
const ControlRoomNav(this.id, this.label, this.icon, this.section);
@@ -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),
],
),
),
],
),
);
}
}
+9 -4
View File
@@ -1,6 +1,8 @@
import 'dart:developer' as developer;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';
@@ -11,13 +13,16 @@ import 'version.g.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Enable semantics tree on web for accessibility and automation tools.
// This exposes widget identifiers to browser automation (Puppeteer, etc.)
if (kIsWeb) {
SemanticsBinding.instance.ensureSemantics();
}
// Use path-based URLs on web (no-op on mobile/desktop)
configureUrlStrategy();
developer.log(
'${AppVersion.name} v${AppVersion.fullVersion}',
name: 'tatlock_ui',
);
debugPrint('🪣 ${AppVersion.name} v${AppVersion.fullVersion}');
// Initialize auth before starting the app.
// This handles OIDC callback and silent auth on web.
+2 -1
View File
@@ -4,6 +4,7 @@ import 'package:riverpod_annotation/riverpod_annotation.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/features/settings/presentation/pages/settings_page.dart';
import 'package:tatlock_ui/shared/layouts/app_scaffold.dart';
part 'app_router.g.dart';
@@ -52,7 +53,7 @@ GoRouter appRouter(Ref ref) {
path: AppRoutes.settings,
name: 'settings',
pageBuilder: (context, state) =>
noTransitionPage(context, state, const _PlaceholderPage(title: 'Settings')),
noTransitionPage(context, state, const SettingsPage()),
),
],
),
+17 -10
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
import 'panel_header.dart';
@@ -199,17 +200,22 @@ class _NavTile extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Material(
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.4)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onTap,
return Semantics(
identifier: NavSemantics.item(item.id),
label: item.label,
button: true,
selected: isSelected,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Material(
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.4)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: Padding(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
@@ -258,6 +264,7 @@ class _NavTile extends StatelessWidget {
),
),
),
),
);
}
}
+216 -73
View File
@@ -1,7 +1,11 @@
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
import 'package:tatlock_ui/core/theme/theme_provider.dart';
import 'package:tatlock_ui/routing/app_router.dart';
/// Profile dropdown menu in the header.
@@ -13,90 +17,213 @@ class ProfileDropdown extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final currentTheme = ref.watch(themeProvider);
final colorScheme = Theme.of(context).colorScheme;
return authState.when(
data: (auth) => PopupMenuButton<String>(
offset: const Offset(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Tooltip(
message: auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest',
child: CircleAvatar(
radius: 18,
backgroundColor: colorScheme.primaryContainer,
child: auth.isAuthenticated
? Text(
_getInitials(auth.userName),
style: TextStyle(
data: (auth) => Semantics(
identifier: ProfileSemantics.button,
label: 'Profile menu',
button: true,
child: PopupMenuButton<String>(
offset: const Offset(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Tooltip(
message: auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest',
child: CircleAvatar(
radius: 18,
backgroundColor: colorScheme.primaryContainer,
child: auth.isAuthenticated
? Text(
_getInitials(auth.userName),
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w500,
),
)
: Icon(
Icons.person_outline,
size: 20,
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w500,
),
)
: Icon(
Icons.person_outline,
size: 20,
color: colorScheme.onPrimaryContainer,
),
),
),
itemBuilder: (context) => [
// User info header (non-selectable)
PopupMenuItem<String>(
enabled: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest',
style: Theme.of(context).textTheme.titleSmall,
),
if (auth.userEmail != null)
Text(
auth.userEmail!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
const PopupMenuDivider(),
// Settings
const PopupMenuItem<String>(
value: 'settings',
child: Row(
children: [
Icon(Icons.settings_outlined, size: 20),
SizedBox(width: 12),
Text('Settings'),
],
),
),
// Logout (only if authenticated)
if (auth.isAuthenticated)
const PopupMenuItem<String>(
value: 'logout',
child: Row(
itemBuilder: (context) => [
// User info header (non-selectable)
PopupMenuItem<String>(
enabled: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.logout, size: 20),
SizedBox(width: 12),
Text('Logout'),
Text(
auth.isAuthenticated ? auth.userName ?? 'User' : 'Guest',
style: Theme.of(context).textTheme.titleSmall,
),
if (auth.userEmail != null)
Text(
auth.userEmail!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
onSelected: (value) {
switch (value) {
case 'settings':
context.go(AppRoutes.settings);
case 'logout':
ref.read(authProvider.notifier).signOut();
}
},
const PopupMenuDivider(),
// Settings
PopupMenuItem<String>(
value: 'settings',
child: Semantics(
identifier: ProfileSemantics.settings,
child: const Row(
children: [
Icon(Icons.settings_outlined, size: 20),
SizedBox(width: 12),
Text('Settings'),
],
),
),
),
// Theme submenu header
PopupMenuItem<String>(
enabled: false,
height: 32,
child: Text(
'THEME',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
letterSpacing: 0.5,
),
),
),
// Theme: System
PopupMenuItem<String>(
value: 'theme_system',
height: 40,
child: Semantics(
identifier: ProfileSemantics.themeSystem,
child: Row(
children: [
Icon(
Icons.brightness_auto,
size: 18,
color: currentTheme == ThemeSetting.system
? colorScheme.primary
: null,
),
const SizedBox(width: 12),
Text(
'System',
style: currentTheme == ThemeSetting.system
? TextStyle(color: colorScheme.primary)
: null,
),
const Spacer(),
if (currentTheme == ThemeSetting.system)
Icon(Icons.check, size: 16, color: colorScheme.primary),
],
),
),
),
// Theme: Light
PopupMenuItem<String>(
value: 'theme_light',
height: 40,
child: Semantics(
identifier: ProfileSemantics.themeLight,
child: Row(
children: [
Icon(
Icons.light_mode,
size: 18,
color: currentTheme == ThemeSetting.light
? colorScheme.primary
: null,
),
const SizedBox(width: 12),
Text(
'Light',
style: currentTheme == ThemeSetting.light
? TextStyle(color: colorScheme.primary)
: null,
),
const Spacer(),
if (currentTheme == ThemeSetting.light)
Icon(Icons.check, size: 16, color: colorScheme.primary),
],
),
),
),
// Theme: Dark
PopupMenuItem<String>(
value: 'theme_dark',
height: 40,
child: Semantics(
identifier: ProfileSemantics.themeDark,
child: Row(
children: [
Icon(
Icons.dark_mode,
size: 18,
color: currentTheme == ThemeSetting.dark
? colorScheme.primary
: null,
),
const SizedBox(width: 12),
Text(
'Dark',
style: currentTheme == ThemeSetting.dark
? TextStyle(color: colorScheme.primary)
: null,
),
const Spacer(),
if (currentTheme == ThemeSetting.dark)
Icon(Icons.check, size: 16, color: colorScheme.primary),
],
),
),
),
const PopupMenuDivider(),
// Logout (only if authenticated)
if (auth.isAuthenticated)
PopupMenuItem<String>(
value: 'logout',
child: Semantics(
identifier: ProfileSemantics.logout,
child: const Row(
children: [
Icon(Icons.logout, size: 20),
SizedBox(width: 12),
Text('Logout'),
],
),
),
),
],
onSelected: (value) {
switch (value) {
case 'settings':
context.go(AppRoutes.settings);
case 'theme_system':
_updateTheme(ref, ThemeSetting.system);
case 'theme_light':
_updateTheme(ref, ThemeSetting.light);
case 'theme_dark':
_updateTheme(ref, ThemeSetting.dark);
case 'logout':
ref.read(authProvider.notifier).signOut();
}
},
),
),
loading: () => const CircleAvatar(
radius: 18,
@@ -126,4 +253,20 @@ class ProfileDropdown extends ConsumerWidget {
}
return name[0].toUpperCase();
}
/// Update theme locally and sync to API.
Future<void> _updateTheme(WidgetRef ref, ThemeSetting setting) async {
// Update local theme immediately for instant UI response
await ref.read(themeProvider.notifier).setSetting(setting);
// Sync to backend (fire-and-forget, errors logged not shown)
try {
await ref.read(authProvider.notifier).updatePreferences(
theme: setting.name,
);
} catch (e) {
// Theme still works locally even if API sync fails
developer.log('Failed to sync theme preference: $e', name: 'profile');
}
}
}
+18 -11
View File
@@ -1,6 +1,7 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
import 'profile_dropdown.dart';
@@ -127,17 +128,23 @@ class TopHeaderBar extends StatelessWidget {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Tooltip(
message: room.label,
child: IconButton(
icon: Icon(
isSelected ? room.selectedIcon : room.icon,
color: isSelected ? colorScheme.primary : colorScheme.onSurface,
),
onPressed: () => onRoomSelected(index),
style: IconButton.styleFrom(
backgroundColor:
isSelected ? colorScheme.primaryContainer : null,
child: Semantics(
identifier: RoomTabSemantics.forIndex(index),
label: room.label,
button: true,
selected: isSelected,
child: Tooltip(
message: room.label,
child: IconButton(
icon: Icon(
isSelected ? room.selectedIcon : room.icon,
color: isSelected ? colorScheme.primary : colorScheme.onSurface,
),
onPressed: () => onRoomSelected(index),
style: IconButton.styleFrom(
backgroundColor:
isSelected ? colorScheme.primaryContainer : null,
),
),
),
),
+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
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.1.10+1
version: 1.2.0+1
environment:
sdk: ^3.10.4
+2 -1
View File
@@ -36,7 +36,8 @@
body {
margin: 0;
padding: 0;
background-color: #1a1a2e;
/* Match Flutter's dark theme scaffold background (FlexScheme.aquaBlue) */
background-color: #111111;
}
</style>
</head>