diff --git a/CHANGELOG.md b/CHANGELOG.md index 539b0e1..e74a286 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 73bb0a2..7103d4a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 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 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 createQuickLink(QuickLink link) async { + final model = QuickLinkModel.fromEntity(link); + final response = await _dio.post>( + _basePath, + data: model.toJson(), // Always use generated toJson() + ); + return QuickLinkModel.fromJson(response.data!).toEntity(); +} + +// Update - same pattern +Future updateQuickLink(QuickLink link) async { + final model = QuickLinkModel.fromEntity(link); + final response = await _dio.put>( + '$_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. diff --git a/lib/core/api/api_interceptors.dart b/lib/core/api/api_interceptors.dart index e7805b9..deaf99c 100644 --- a/lib/core/api/api_interceptors.dart +++ b/lib/core/api/api_interceptors.dart @@ -1,6 +1,7 @@ import 'dart:developer' as developer; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tatlock_ui/core/auth/auth_provider.dart'; import 'package:tatlock_ui/core/config/app_config.dart'; @@ -49,33 +50,59 @@ class AuthInterceptor extends Interceptor { } } -/// 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); } } diff --git a/lib/features/control_room/containers/presentation/pages/containers_list_page.dart b/lib/features/control_room/containers/presentation/pages/containers_list_page.dart index 4bc955d..1a342a1 100644 --- a/lib/features/control_room/containers/presentation/pages/containers_list_page.dart +++ b/lib/features/control_room/containers/presentation/pages/containers_list_page.dart @@ -7,6 +7,9 @@ import 'package:tatlock_ui/features/control_room/containers/presentation/widgets 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({ @@ -17,25 +20,45 @@ class ContainerData { required this.state, required this.status, required this.ports, + required this.health, }); factory ContainerData.fromJson(Map json) { - final ports = (json['ports'] as List?) + // Docker API uses capitalized keys + final ports = (json['Ports'] as List?) ?.map((p) => ContainerPort.fromJson(p as Map)) .toList() ?? []; + final fullId = json['Id'] as String? ?? ''; + final names = json['Names'] as List? ?? []; + final name = names.isNotEmpty + ? (names.first as String).replaceFirst('/', '') + : 'Unknown'; + + final status = json['Status'] as String? ?? 'Unknown'; + return ContainerData( - id: json['id'] as String, - fullId: json['full_id'] as String? ?? json['id'] as String, - name: json['name'] as String, - image: json['image'] as String, - state: json['state'] as String, - status: json['status'] as String, + 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; @@ -43,6 +66,16 @@ class ContainerData { final String state; final String status; final List 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'; @@ -53,9 +86,10 @@ class ContainerPort { ContainerPort({required this.privatePort, this.publicPort, this.type = 'tcp'}); factory ContainerPort.fromJson(Map json) => ContainerPort( - privatePort: json['private_port'] as int? ?? json['PrivatePort'] as int? ?? 0, - publicPort: json['public_port'] as int? ?? json['PublicPort'] as int?, - type: json['type'] as String? ?? json['Type'] as String? ?? 'tcp', + // 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; @@ -99,7 +133,7 @@ class _ContainersListPageState extends ConsumerState { columns: [ DataGridColumn( header: 'Container', - valueBuilder: (c) => c.name, + valueBuilder: (c) => '${c.name} ${c.image}', sortable: true, searchable: true, width: const DataGridColumnWidth.flex(2), @@ -113,8 +147,8 @@ class _ContainersListPageState extends ConsumerState { ), DataGridColumn( header: 'Status', - valueBuilder: (c) => c.status, - width: const DataGridColumnWidth.fixed(140), + valueBuilder: (c) => c.displayStatus, + width: const DataGridColumnWidth.fixed(160), alignment: DataGridColumnAlignment.end, ), ], @@ -242,7 +276,7 @@ class _ContainerCell extends StatelessWidget { return Row( children: [ - ContainerStatusBadge.fromString(container.state), + ContainerStatusBadge.fromString(container.state, health: container.health), const SizedBox(width: 8), Expanded( child: Column( diff --git a/lib/features/control_room/containers/presentation/widgets/container_status_badge.dart b/lib/features/control_room/containers/presentation/widgets/container_status_badge.dart index feadb52..8c3cfc1 100644 --- a/lib/features/control_room/containers/presentation/widgets/container_status_badge.dart +++ b/lib/features/control_room/containers/presentation/widgets/container_status_badge.dart @@ -1,48 +1,67 @@ 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'; -/// Container state enum for status display. -enum ContainerState { - created, - running, - paused, - restarting, - removing, - exited, - dead; +/// 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, + }); - /// Parse a string to ContainerState. - static ContainerState fromString(String value) { + /// 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, ); } -} - -/// Status badge for container state. -class ContainerStatusBadge extends StatelessWidget { - const ContainerStatusBadge({ - super.key, - required this.state, - this.showLabel = true, - }); - - /// Create badge from a string state value. - factory ContainerStatusBadge.fromString(String state, {bool showLabel = true}) { - return ContainerStatusBadge( - state: ContainerState.fromString(state), - showLabel: showLabel, - ); - } - - final ContainerState state; - final bool showLabel; @override Widget build(BuildContext context) { final (color, icon, label) = _getStateStyle(context); - return DecoratedBox( + Widget badge = DecoratedBox( decoration: BoxDecoration( color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), @@ -69,17 +88,22 @@ class ContainerStatusBadge extends StatelessWidget { ), ), ); + + 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 => ( - Colors.green, - Icons.play_circle, - 'Running', - ), + ContainerState.running => _getRunningStyle(colorScheme), ContainerState.paused => ( Colors.orange, Icons.pause_circle, @@ -112,4 +136,30 @@ class ContainerStatusBadge extends StatelessWidget { ), }; } + + /// 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', + ), + }; + } } diff --git a/lib/features/control_room/stacks/presentation/pages/stack_detail_page.dart b/lib/features/control_room/stacks/presentation/pages/stack_detail_page.dart index d1eba71..9bc2034 100644 --- a/lib/features/control_room/stacks/presentation/pages/stack_detail_page.dart +++ b/lib/features/control_room/stacks/presentation/pages/stack_detail_page.dart @@ -1,5 +1,7 @@ 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'; @@ -133,7 +135,9 @@ class _StackEditor extends ConsumerWidget { children: [ // Header with stack info and actions Container( - padding: const EdgeInsets.all(16), + height: 56, + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + alignment: Alignment.bottomCenter, decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, border: Border( @@ -141,11 +145,13 @@ class _StackEditor extends ConsumerWidget { ), ), 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( @@ -211,7 +217,7 @@ class _CompactContainerList extends ConsumerWidget { required this.stackId, }); - final List containers; + final List containers; final String stackId; @override @@ -269,7 +275,10 @@ class _CompactContainerList extends ConsumerWidget { ), child: ListTile( dense: true, - leading: ContainerStatusBadge(state: container.state), + leading: ContainerStatusBadge.withStatus( + state: container.state, + status: container.status, + ), title: Text( container.name, style: const TextStyle(fontSize: 13), diff --git a/lib/features/front_hall/data/datasources/quick_links_datasource.dart b/lib/features/front_hall/data/datasources/quick_links_datasource.dart index 239955b..93ce0ef 100644 --- a/lib/features/front_hall/data/datasources/quick_links_datasource.dart +++ b/lib/features/front_hall/data/datasources/quick_links_datasource.dart @@ -52,7 +52,7 @@ class QuickLinksDatasource { final model = QuickLinkModel.fromEntity(link); final response = await _dio.post>( _basePath, - data: model.toCreateJson(), + data: model.toJson(), ); final data = response.data; @@ -69,7 +69,7 @@ class QuickLinksDatasource { final id = int.tryParse(link.id) ?? 0; final response = await _dio.put>( '$_basePath/$id', - data: model.toCreateJson(), + data: model.toJson(), ); final data = response.data; diff --git a/lib/features/front_hall/data/models/quick_link_model.dart b/lib/features/front_hall/data/models/quick_link_model.dart index b02aeab..f03a9dd 100644 --- a/lib/features/front_hall/data/models/quick_link_model.dart +++ b/lib/features/front_hall/data/models/quick_link_model.dart @@ -4,17 +4,18 @@ 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 for API serialization. +/// Quick link data model matching Core API dashboard/quick-links endpoint. /// -/// Maps to 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({ - required int id, + @Default(0) int id, required String title, required String url, String? icon, @@ -22,6 +23,7 @@ sealed class QuickLinkModel with _$QuickLinkModel { 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; @@ -39,7 +41,7 @@ sealed class QuickLinkModel with _$QuickLinkModel { url: url, iconName: icon ?? 'link', category: category, - type: QuickLinkType.iframe, + type: _parseQuickLinkType(linkType), sortOrder: position, isActive: isVisible, ); @@ -55,21 +57,23 @@ sealed class QuickLinkModel with _$QuickLinkModel { category: entity.category, position: entity.sortOrder, isVisible: entity.isActive, + linkType: _formatQuickLinkType(entity.type), ); } +} - /// Creates a JSON map for creating a new quick link (without id). - Map toCreateJson() { - return { - 'title': title, - 'url': url, - if (icon != null) 'icon': icon, - if (description != null) 'description': description, - if (category != null) 'category': category, - 'position': position, - 'is_visible': isVisible, - if (color != null) 'color': color, - if (backgroundColor != null) 'background_color': backgroundColor, - }; - } +/// 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', + }; } diff --git a/lib/features/front_hall/presentation/pages/front_hall_page.dart b/lib/features/front_hall/presentation/pages/front_hall_page.dart index f7590a9..871ee48 100644 --- a/lib/features/front_hall/presentation/pages/front_hall_page.dart +++ b/lib/features/front_hall/presentation/pages/front_hall_page.dart @@ -51,6 +51,7 @@ class FrontHallPage extends ConsumerWidget { case FrontHallMode.iframe: return IframeView( + key: ValueKey(state.activeIframeUrl), url: state.activeIframeUrl!, title: state.activeIframeTitle ?? 'External Content', onClose: () => diff --git a/lib/features/front_hall/presentation/widgets/iframe_view_web.dart b/lib/features/front_hall/presentation/widgets/iframe_view_web.dart index 7495e11..5f12d7e 100644 --- a/lib/features/front_hall/presentation/widgets/iframe_view_web.dart +++ b/lib/features/front_hall/presentation/widgets/iframe_view_web.dart @@ -83,7 +83,7 @@ class _IframeViewState extends State { children: [ // Header bar Container( - height: 48, + height: 56, padding: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, diff --git a/lib/features/front_hall/presentation/widgets/quick_link_form.dart b/lib/features/front_hall/presentation/widgets/quick_link_form.dart index 96a3409..95ad489 100644 --- a/lib/features/front_hall/presentation/widgets/quick_link_form.dart +++ b/lib/features/front_hall/presentation/widgets/quick_link_form.dart @@ -67,24 +67,21 @@ class _QuickLinkFormState extends ConsumerState { try { final actions = ref.read(quickLinkActionsProvider.notifier); + final category = _categoryController.text.trim(); - // Generate ID for new links - final id = widget.quickLink?.id ?? - _nameController.text.toLowerCase().replaceAll(RegExp(r'\s+'), '-'); - - final link = QuickLink( - id: id, - name: _nameController.text.trim(), - url: _urlController.text.trim(), - iconName: _iconName, - category: - _categoryController.text.trim().isEmpty ? null : _categoryController.text.trim(), - type: _type, - isActive: _isActive, - sortOrder: widget.quickLink?.sortOrder ?? 0, - ); - + 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( @@ -95,6 +92,15 @@ class _QuickLinkFormState extends ConsumerState { ); } } 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( @@ -106,6 +112,9 @@ class _QuickLinkFormState extends ConsumerState { } } + // Refresh the list before exiting + ref.invalidate(quickLinksProvider); + widget.onSaved(); } catch (e) { if (mounted) { diff --git a/lib/features/front_hall/presentation/widgets/quick_link_settings_content.dart b/lib/features/front_hall/presentation/widgets/quick_link_settings_content.dart index a7d5e7e..d29374c 100644 --- a/lib/features/front_hall/presentation/widgets/quick_link_settings_content.dart +++ b/lib/features/front_hall/presentation/widgets/quick_link_settings_content.dart @@ -53,7 +53,8 @@ class _QuickLinkSettingsContentState body: QuickLinkForm( mode: EntityPageMode.create, onCancel: _stopCreating, - onSaved: _stopCreating, + onSaved: () => + ref.read(frontHallStateProvider.notifier).exitSettings(), ), ); } @@ -131,14 +132,14 @@ class _QuickLinkSettingsContentState 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) - .clearSelectedLink(), + onSaved: () => + ref.read(frontHallStateProvider.notifier).exitSettings(), ), ), ); diff --git a/lib/features/front_hall/presentation/widgets/quick_links_panel.dart b/lib/features/front_hall/presentation/widgets/quick_links_panel.dart index 3bf60a9..1c84c75 100644 --- a/lib/features/front_hall/presentation/widgets/quick_links_panel.dart +++ b/lib/features/front_hall/presentation/widgets/quick_links_panel.dart @@ -297,7 +297,10 @@ class _QuickLinksList extends ConsumerWidget { // In settings mode, select link for editing ref.read(frontHallStateProvider.notifier).selectLink(link.id); } else { - // In normal mode, open link + // 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) diff --git a/lib/shared/components/data_grid/data_grid.dart b/lib/shared/components/data_grid/data_grid.dart index 568beca..e6274b8 100644 --- a/lib/shared/components/data_grid/data_grid.dart +++ b/lib/shared/components/data_grid/data_grid.dart @@ -103,9 +103,19 @@ class DataGrid extends ConsumerWidget { DataGridState state, DataGridController controller, ) { - return Padding( - padding: const EdgeInsets.all(16), + 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( diff --git a/lib/shared/components/data_grid/data_grid_provider.dart b/lib/shared/components/data_grid/data_grid_provider.dart index 76b6caa..4a49bc3 100644 --- a/lib/shared/components/data_grid/data_grid_provider.dart +++ b/lib/shared/components/data_grid/data_grid_provider.dart @@ -35,6 +35,9 @@ class DataGridController extends StateNotifier> { /// Debounce timer for search. Timer? _searchDebounce; + /// All items before local filtering (for local search). + List _allItems = []; + @override void dispose() { _searchDebounce?.cancel(); @@ -57,16 +60,21 @@ class DataGridController extends StateNotifier> { final (offset, limit) = _getPaginationParams(); final result = await source.fetch( - searchQuery: state.searchQuery.isEmpty ? null : state.searchQuery, 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: result.items, - totalCount: result.totalCount, + items: filteredItems, + totalCount: filteredItems.length, hasMore: result.hasMore, isLoading: false, isInitialLoad: false, @@ -82,6 +90,26 @@ class DataGridController extends StateNotifier> { } } + /// Applies local filtering based on search query. + List _applyLocalFilter(List 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) { @@ -97,13 +125,13 @@ class DataGridController extends StateNotifier> { /// Refreshes the grid data. Future refresh() => _load(refresh: true); - /// Sets the search query with debouncing. + /// Sets the search query with debouncing (filters locally). void search(String query) { _searchDebounce?.cancel(); - _searchDebounce = Timer(const Duration(milliseconds: 300), () { + _searchDebounce = Timer(const Duration(milliseconds: 150), () { if (state.searchQuery != query) { state = state.copyWith(searchQuery: query, currentPage: 0); - _load(refresh: true); + _applyFilterAndUpdateState(); } }); } @@ -113,10 +141,19 @@ class DataGridController extends StateNotifier> { _searchDebounce?.cancel(); if (state.searchQuery.isNotEmpty) { state = state.copyWith(searchQuery: '', currentPage: 0); - _load(refresh: true); + _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]; diff --git a/lib/shared/components/data_grid/widgets/data_grid_search_bar.dart b/lib/shared/components/data_grid/widgets/data_grid_search_bar.dart index f705e48..37caff7 100644 --- a/lib/shared/components/data_grid/widgets/data_grid_search_bar.dart +++ b/lib/shared/components/data_grid/widgets/data_grid_search_bar.dart @@ -40,14 +40,16 @@ class _DataGridSearchBarState extends State { return SizedBox( width: 300, + height: 36, child: TextField( controller: _controller, + style: const TextStyle(fontSize: 14), decoration: InputDecoration( hintText: widget.hintText, - prefixIcon: const Icon(Icons.search), + prefixIcon: const Icon(Icons.search, size: 20), suffixIcon: _controller.text.isNotEmpty ? IconButton( - icon: const Icon(Icons.clear), + icon: const Icon(Icons.clear, size: 18), onPressed: () { _controller.clear(); widget.onClear(); @@ -56,14 +58,22 @@ class _DataGridSearchBarState extends State { : null, isDense: true, filled: true, - fillColor: colorScheme.surfaceContainerHighest, + fillColor: colorScheme.surface, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, + borderSide: BorderSide(color: colorScheme.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: colorScheme.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: colorScheme.primary), ), contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + horizontal: 12, + vertical: 8, ), ), onChanged: (value) { diff --git a/lib/version.g.dart b/lib/version.g.dart index 19efafc..6f36401 100644 --- a/lib/version.g.dart +++ b/lib/version.g.dart @@ -7,7 +7,7 @@ class AppVersion { static const String name = 'tatlock_ui'; static const String description = 'Tatlock - a Home Lab AI'; - static const String version = '0.3.0'; + static const String version = '0.3.3'; static const int buildNumber = 1; - static const String fullVersion = '0.3.0+1'; + static const String fullVersion = '0.3.3+1'; } diff --git a/pubspec.yaml b/pubspec.yaml index 22af4d6..f18f1ce 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: 0.3.2+1 +version: 0.3.3+1 environment: sdk: ^3.10.4 diff --git a/web/favicon.png b/web/favicon.png index 8aaa46a..90d3127 100644 Binary files a/web/favicon.png and b/web/favicon.png differ diff --git a/web/health.html b/web/health.html new file mode 100644 index 0000000..7cdedf6 --- /dev/null +++ b/web/health.html @@ -0,0 +1,7 @@ + + + + OK + +OK + diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index b749bfe..d2670b8 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index 88cfd48..a9ba1b4 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png index eb9b4d7..d2670b8 100644 Binary files a/web/icons/Icon-maskable-192.png and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png index d69c566..9bba3ae 100644 Binary files a/web/icons/Icon-maskable-512.png and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html index 967e38b..18de1b4 100644 --- a/web/index.html +++ b/web/index.html @@ -18,18 +18,18 @@ - + - + - tatlock_ui + Tatlock diff --git a/web/manifest.json b/web/manifest.json index 422c6ae..52bb850 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,11 +1,11 @@ { - "name": "tatlock_ui", - "short_name": "tatlock_ui", + "name": "Tatlock", + "short_name": "Tatlock", "start_url": ".", "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", + "background_color": "#1a1a2e", + "theme_color": "#1a1a2e", + "description": "Tatlock Home Dashboard", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [