chore: release v0.3.3
Build and Push / build (release) Successful in 3m6s

Features:
- Health check endpoint for Portainer monitoring
- Local search filtering in DataGrid
- Container status badges reflect health (green/orange/blue)

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

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

Branding:
- Updated favicon and icons with Tatlock bucket logo

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-01-03 16:12:18 +01:00
co-authored by Claude Opus 4.5
parent 0d15d3dbb5
commit bdced8739c
26 changed files with 499 additions and 136 deletions
+28
View File
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ### Changed
- API defaults now use LAN IPs for local development (no auth required) - API defaults now use LAN IPs for local development (no auth required)
- Auth interceptor skips authentication when using LAN endpoints - Auth interceptor skips authentication when using LAN endpoints
+133
View File
@@ -197,6 +197,139 @@ class ContainerRepositoryImpl implements ContainerRepository {
} }
``` ```
## Data Model Patterns
Models handle conversion between API JSON and domain entities. The pattern depends on whether the feature is **read-only** or **CRUD**.
### Read-Only Models
For data fetched from external systems (Docker, NPM, Portainer) where Flutter doesn't create/update records:
```dart
@freezed
sealed class ContainerModel with _$ContainerModel {
const factory ContainerModel({
required String id,
required String name,
required String status,
}) = _ContainerModel;
const ContainerModel._();
factory ContainerModel.fromJson(Map<String, dynamic> json) =>
_$ContainerModelFromJson(json);
/// Converts API response to domain entity.
Container toEntity() => Container(
id: id,
name: name,
status: ContainerStatus.values.byName(status),
);
}
```
**Only `toEntity()` is needed** - no `fromEntity()` or `toJson()` required.
### CRUD Models (Bidirectional)
For data that Flutter creates, updates, and deletes:
```dart
@freezed
sealed class QuickLinkModel with _$QuickLinkModel {
const factory QuickLinkModel({
@Default(0) int id,
required String title,
required String url,
String? icon,
String? category,
@Default(0) int position,
@JsonKey(name: 'is_visible') @Default(true) bool isVisible,
}) = _QuickLinkModel;
const QuickLinkModel._();
factory QuickLinkModel.fromJson(Map<String, dynamic> json) =>
_$QuickLinkModelFromJson(json);
/// Converts API response to domain entity.
QuickLink toEntity() => QuickLink(
id: id.toString(),
name: title,
url: url,
iconName: icon ?? 'link',
category: category,
sortOrder: position,
isActive: isVisible,
);
/// Creates model from domain entity for API requests.
factory QuickLinkModel.fromEntity(QuickLink entity) => QuickLinkModel(
id: int.tryParse(entity.id) ?? 0,
title: entity.name,
url: entity.url,
icon: entity.iconName,
category: entity.category,
position: entity.sortOrder,
isVisible: entity.isActive,
);
}
```
**Critical rules for CRUD models:**
1. **Always use generated `toJson()`** - Never write custom JSON methods that selectively include fields. The generated `toJson()` from freezed/json_serializable always includes ALL fields, which is the correct behavior.
2. **Never create custom `toCreateJson()` or similar** - This leads to bugs where fields are silently dropped.
3. **Use `fromEntity()` factory** - Maps domain entity fields to API field names.
### Form Update Pattern
When updating existing entities in forms, **always use `copyWith()`** to preserve existing data:
```dart
// ✅ Correct - preserves all existing fields
final updatedLink = existingLink.copyWith(
name: _nameController.text.trim(),
url: _urlController.text.trim(),
category: category.isEmpty ? null : category,
);
await actions.update(updatedLink);
// ❌ Wrong - loses existing data not in form
final newLink = QuickLink(
id: existingLink.id,
name: _nameController.text.trim(),
url: _urlController.text.trim(),
// Missing: sortOrder, other fields...
);
```
### Datasource Usage
```dart
// Create - convert entity to model, use toJson()
Future<QuickLink> createQuickLink(QuickLink link) async {
final model = QuickLinkModel.fromEntity(link);
final response = await _dio.post<Map<String, dynamic>>(
_basePath,
data: model.toJson(), // Always use generated toJson()
);
return QuickLinkModel.fromJson(response.data!).toEntity();
}
// Update - same pattern
Future<QuickLink> updateQuickLink(QuickLink link) async {
final model = QuickLinkModel.fromEntity(link);
final response = await _dio.put<Map<String, dynamic>>(
'$_basePath/${link.id}',
data: model.toJson(), // Always use generated toJson()
);
return QuickLinkModel.fromJson(response.data!).toEntity();
}
```
### Presentation Layer (Flutter + Riverpod) ### Presentation Layer (Flutter + Riverpod)
The presentation layer contains UI code and state management. The presentation layer contains UI code and state management.
+41 -14
View File
@@ -1,6 +1,7 @@
import 'dart:developer' as developer; import 'dart:developer' as developer;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/core/auth/auth_provider.dart'; import 'package:tatlock_ui/core/auth/auth_provider.dart';
import 'package:tatlock_ui/core/config/app_config.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 { class LoggingInterceptor extends Interceptor {
@override @override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) { void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
developer.log( final buffer = StringBuffer()
'${options.method} ${options.uri}', ..writeln('┌── API Request ──────────────────────────────────────')
name: 'api', ..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); handler.next(options);
} }
@override @override
void onResponse(Response response, ResponseInterceptorHandler handler) { void onResponse(Response response, ResponseInterceptorHandler handler) {
developer.log( final message =
' ${response.statusCode} ${response.requestOptions.uri}', ' ${response.statusCode} ${response.requestOptions.method} ${response.requestOptions.path}';
name: 'api', developer.log(message, name: 'API');
); debugPrint(message);
handler.next(response); handler.next(response);
} }
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) { void onError(DioException err, ErrorInterceptorHandler handler) {
developer.log( final buffer = StringBuffer()
'${err.response?.statusCode ?? 'NETWORK'} ${err.requestOptions.uri}: ${err.message}', ..writeln('┌── API Error ────────────────────────────────────────')
name: 'api', ..writeln('${err.requestOptions.method} ${err.requestOptions.path}')
error: err, ..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); handler.next(err);
} }
} }
@@ -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/adapters/core_api_source.dart';
import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.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. /// Container data class for DataGrid display.
class ContainerData { class ContainerData {
ContainerData({ ContainerData({
@@ -17,25 +20,45 @@ class ContainerData {
required this.state, required this.state,
required this.status, required this.status,
required this.ports, required this.ports,
required this.health,
}); });
factory ContainerData.fromJson(Map<String, dynamic> json) { factory ContainerData.fromJson(Map<String, dynamic> json) {
final ports = (json['ports'] as List<dynamic>?) // Docker API uses capitalized keys
final ports = (json['Ports'] as List<dynamic>?)
?.map((p) => ContainerPort.fromJson(p as Map<String, dynamic>)) ?.map((p) => ContainerPort.fromJson(p as Map<String, dynamic>))
.toList() ?? .toList() ??
[]; [];
final fullId = json['Id'] as String? ?? '';
final names = json['Names'] as List<dynamic>? ?? [];
final name = names.isNotEmpty
? (names.first as String).replaceFirst('/', '')
: 'Unknown';
final status = json['Status'] as String? ?? 'Unknown';
return ContainerData( return ContainerData(
id: json['id'] as String, id: fullId.length > 12 ? fullId.substring(0, 12) : fullId,
fullId: json['full_id'] as String? ?? json['id'] as String, fullId: fullId,
name: json['name'] as String, name: name,
image: json['image'] as String, image: json['Image'] as String? ?? 'Unknown',
state: json['state'] as String, state: json['State'] as String? ?? 'unknown',
status: json['status'] as String, status: status,
ports: ports, 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 id;
final String fullId; final String fullId;
final String name; final String name;
@@ -43,6 +66,16 @@ class ContainerData {
final String state; final String state;
final String status; final String status;
final List<ContainerPort> ports; final List<ContainerPort> ports;
final ContainerHealth health;
/// Status string with health info stripped (just shows uptime).
String get displayStatus {
return status
.replaceAll(RegExp(r'\s*\(healthy\)', caseSensitive: false), '')
.replaceAll(RegExp(r'\s*\(unhealthy\)', caseSensitive: false), '')
.replaceAll(RegExp(r'\s*\(health: starting\)', caseSensitive: false), '')
.trim();
}
bool get canStart => state == 'exited' || state == 'created'; bool get canStart => state == 'exited' || state == 'created';
bool get canStop => state == 'running'; bool get canStop => state == 'running';
@@ -53,9 +86,10 @@ class ContainerPort {
ContainerPort({required this.privatePort, this.publicPort, this.type = 'tcp'}); ContainerPort({required this.privatePort, this.publicPort, this.type = 'tcp'});
factory ContainerPort.fromJson(Map<String, dynamic> json) => ContainerPort( factory ContainerPort.fromJson(Map<String, dynamic> json) => ContainerPort(
privatePort: json['private_port'] as int? ?? json['PrivatePort'] as int? ?? 0, // Docker API uses PrivatePort/PublicPort
publicPort: json['public_port'] as int? ?? json['PublicPort'] as int?, privatePort: json['PrivatePort'] as int? ?? 0,
type: json['type'] as String? ?? json['Type'] as String? ?? 'tcp', publicPort: json['PublicPort'] as int?,
type: json['Type'] as String? ?? 'tcp',
); );
final int privatePort; final int privatePort;
@@ -99,7 +133,7 @@ class _ContainersListPageState extends ConsumerState<ContainersListPage> {
columns: [ columns: [
DataGridColumn<ContainerData>( DataGridColumn<ContainerData>(
header: 'Container', header: 'Container',
valueBuilder: (c) => c.name, valueBuilder: (c) => '${c.name} ${c.image}',
sortable: true, sortable: true,
searchable: true, searchable: true,
width: const DataGridColumnWidth.flex(2), width: const DataGridColumnWidth.flex(2),
@@ -113,8 +147,8 @@ class _ContainersListPageState extends ConsumerState<ContainersListPage> {
), ),
DataGridColumn<ContainerData>( DataGridColumn<ContainerData>(
header: 'Status', header: 'Status',
valueBuilder: (c) => c.status, valueBuilder: (c) => c.displayStatus,
width: const DataGridColumnWidth.fixed(140), width: const DataGridColumnWidth.fixed(160),
alignment: DataGridColumnAlignment.end, alignment: DataGridColumnAlignment.end,
), ),
], ],
@@ -242,7 +276,7 @@ class _ContainerCell extends StatelessWidget {
return Row( return Row(
children: [ children: [
ContainerStatusBadge.fromString(container.state), ContainerStatusBadge.fromString(container.state, health: container.health),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Column( child: Column(
@@ -1,48 +1,67 @@
import 'package:flutter/material.dart' hide Container; 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. /// Status badge for container state with optional health indication.
enum ContainerState { class ContainerStatusBadge extends StatelessWidget {
created, const ContainerStatusBadge({
running, super.key,
paused, required this.state,
restarting, this.health,
removing, this.showLabel = true,
exited, });
dead;
/// Parse a string to ContainerState. /// Create badge from a string state value.
static ContainerState fromString(String 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( return ContainerState.values.firstWhere(
(s) => s.name == value.toLowerCase(), (s) => s.name == value.toLowerCase(),
orElse: () => ContainerState.exited, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final (color, icon, label) = _getStateStyle(context); final (color, icon, label) = _getStateStyle(context);
return DecoratedBox( Widget badge = DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: 0.15), color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4), 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) { (Color, IconData, String) _getStateStyle(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return switch (state) { return switch (state) {
ContainerState.running => ( ContainerState.running => _getRunningStyle(colorScheme),
Colors.green,
Icons.play_circle,
'Running',
),
ContainerState.paused => ( ContainerState.paused => (
Colors.orange, Colors.orange,
Icons.pause_circle, 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',
),
};
}
} }
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart' hide Stack; import 'package:flutter/material.dart' hide Stack;
import 'package:flutter_riverpod/flutter_riverpod.dart'; 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/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_logs_viewer.dart';
import 'package:tatlock_ui/features/control_room/containers/presentation/widgets/container_status_badge.dart'; import 'package:tatlock_ui/features/control_room/containers/presentation/widgets/container_status_badge.dart';
@@ -133,7 +135,9 @@ class _StackEditor extends ConsumerWidget {
children: [ children: [
// Header with stack info and actions // Header with stack info and actions
Container( Container(
padding: const EdgeInsets.all(16), height: 56,
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
alignment: Alignment.bottomCenter,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
border: Border( border: Border(
@@ -141,11 +145,13 @@ class _StackEditor extends ConsumerWidget {
), ),
), ),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Icon(Icons.layers, color: colorScheme.primary), Icon(Icons.layers, color: colorScheme.primary),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
@@ -211,7 +217,7 @@ class _CompactContainerList extends ConsumerWidget {
required this.stackId, required this.stackId,
}); });
final List<dynamic> containers; final List<container_entity.Container> containers;
final String stackId; final String stackId;
@override @override
@@ -269,7 +275,10 @@ class _CompactContainerList extends ConsumerWidget {
), ),
child: ListTile( child: ListTile(
dense: true, dense: true,
leading: ContainerStatusBadge(state: container.state), leading: ContainerStatusBadge.withStatus(
state: container.state,
status: container.status,
),
title: Text( title: Text(
container.name, container.name,
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
@@ -52,7 +52,7 @@ class QuickLinksDatasource {
final model = QuickLinkModel.fromEntity(link); final model = QuickLinkModel.fromEntity(link);
final response = await _dio.post<Map<String, dynamic>>( final response = await _dio.post<Map<String, dynamic>>(
_basePath, _basePath,
data: model.toCreateJson(), data: model.toJson(),
); );
final data = response.data; final data = response.data;
@@ -69,7 +69,7 @@ class QuickLinksDatasource {
final id = int.tryParse(link.id) ?? 0; final id = int.tryParse(link.id) ?? 0;
final response = await _dio.put<Map<String, dynamic>>( final response = await _dio.put<Map<String, dynamic>>(
'$_basePath/$id', '$_basePath/$id',
data: model.toCreateJson(), data: model.toJson(),
); );
final data = response.data; final data = response.data;
@@ -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.freezed.dart';
part 'quick_link_model.g.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) /// - title (API) ↔ name (Flutter entity)
/// - icon (API) ↔ iconName (Flutter entity) /// - icon (API) ↔ iconName (Flutter entity)
/// - position (API) ↔ sortOrder (Flutter entity) /// - position (API) ↔ sortOrder (Flutter entity)
/// - is_visible (API) ↔ isActive (Flutter entity) /// - is_visible (API) ↔ isActive (Flutter entity)
/// - link_type (API) ↔ type (Flutter entity)
@freezed @freezed
sealed class QuickLinkModel with _$QuickLinkModel { sealed class QuickLinkModel with _$QuickLinkModel {
const factory QuickLinkModel({ const factory QuickLinkModel({
required int id, @Default(0) int id,
required String title, required String title,
required String url, required String url,
String? icon, String? icon,
@@ -22,6 +23,7 @@ sealed class QuickLinkModel with _$QuickLinkModel {
String? category, String? category,
@Default(0) int position, @Default(0) int position,
@JsonKey(name: 'is_visible') @Default(true) bool isVisible, @JsonKey(name: 'is_visible') @Default(true) bool isVisible,
@JsonKey(name: 'link_type') @Default('iframe') String linkType,
String? color, String? color,
@JsonKey(name: 'background_color') String? backgroundColor, @JsonKey(name: 'background_color') String? backgroundColor,
}) = _QuickLinkModel; }) = _QuickLinkModel;
@@ -39,7 +41,7 @@ sealed class QuickLinkModel with _$QuickLinkModel {
url: url, url: url,
iconName: icon ?? 'link', iconName: icon ?? 'link',
category: category, category: category,
type: QuickLinkType.iframe, type: _parseQuickLinkType(linkType),
sortOrder: position, sortOrder: position,
isActive: isVisible, isActive: isVisible,
); );
@@ -55,21 +57,23 @@ sealed class QuickLinkModel with _$QuickLinkModel {
category: entity.category, category: entity.category,
position: entity.sortOrder, position: entity.sortOrder,
isVisible: entity.isActive, isVisible: entity.isActive,
linkType: _formatQuickLinkType(entity.type),
); );
} }
}
/// Creates a JSON map for creating a new quick link (without id). /// Parses API link_type string to QuickLinkType enum.
Map<String, dynamic> toCreateJson() { QuickLinkType _parseQuickLinkType(String linkType) {
return { return switch (linkType) {
'title': title, 'new_tab' => QuickLinkType.newTab,
'url': url, _ => QuickLinkType.iframe,
if (icon != null) 'icon': icon, };
if (description != null) 'description': description, }
if (category != null) 'category': category,
'position': position, /// Formats QuickLinkType enum to API link_type string.
'is_visible': isVisible, String _formatQuickLinkType(QuickLinkType type) {
if (color != null) 'color': color, return switch (type) {
if (backgroundColor != null) 'background_color': backgroundColor, QuickLinkType.newTab => 'new_tab',
}; QuickLinkType.iframe => 'iframe',
} };
} }
@@ -51,6 +51,7 @@ class FrontHallPage extends ConsumerWidget {
case FrontHallMode.iframe: case FrontHallMode.iframe:
return IframeView( return IframeView(
key: ValueKey(state.activeIframeUrl),
url: state.activeIframeUrl!, url: state.activeIframeUrl!,
title: state.activeIframeTitle ?? 'External Content', title: state.activeIframeTitle ?? 'External Content',
onClose: () => onClose: () =>
@@ -83,7 +83,7 @@ class _IframeViewState extends State<IframeView> {
children: [ children: [
// Header bar // Header bar
Container( Container(
height: 48, height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
@@ -67,24 +67,21 @@ class _QuickLinkFormState extends ConsumerState<QuickLinkForm> {
try { try {
final actions = ref.read(quickLinkActionsProvider.notifier); final actions = ref.read(quickLinkActionsProvider.notifier);
final category = _categoryController.text.trim();
// Generate ID for new links final QuickLink link;
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,
);
if (isCreate) { 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); await actions.create(link);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -95,6 +92,15 @@ class _QuickLinkFormState extends ConsumerState<QuickLinkForm> {
); );
} }
} else { } 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); await actions.update(link);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -106,6 +112,9 @@ class _QuickLinkFormState extends ConsumerState<QuickLinkForm> {
} }
} }
// Refresh the list before exiting
ref.invalidate(quickLinksProvider);
widget.onSaved(); widget.onSaved();
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@@ -53,7 +53,8 @@ class _QuickLinkSettingsContentState
body: QuickLinkForm( body: QuickLinkForm(
mode: EntityPageMode.create, mode: EntityPageMode.create,
onCancel: _stopCreating, onCancel: _stopCreating,
onSaved: _stopCreating, onSaved: () =>
ref.read(frontHallStateProvider.notifier).exitSettings(),
), ),
); );
} }
@@ -131,14 +132,14 @@ class _QuickLinkSettingsContentState
title: Text('Edit: ${link.name}'), title: Text('Edit: ${link.name}'),
), ),
body: QuickLinkForm( body: QuickLinkForm(
key: ValueKey(link.id), // Force recreation when link changes
mode: EntityPageMode.edit, mode: EntityPageMode.edit,
quickLink: link, quickLink: link,
onCancel: () => ref onCancel: () => ref
.read(frontHallStateProvider.notifier) .read(frontHallStateProvider.notifier)
.clearSelectedLink(), .clearSelectedLink(),
onSaved: () => ref onSaved: () =>
.read(frontHallStateProvider.notifier) ref.read(frontHallStateProvider.notifier).exitSettings(),
.clearSelectedLink(),
), ),
), ),
); );
@@ -297,7 +297,10 @@ class _QuickLinksList extends ConsumerWidget {
// In settings mode, select link for editing // In settings mode, select link for editing
ref.read(frontHallStateProvider.notifier).selectLink(link.id); ref.read(frontHallStateProvider.notifier).selectLink(link.id);
} else { } 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) { if (link.isIframe) {
ref ref
.read(frontHallStateProvider.notifier) .read(frontHallStateProvider.notifier)
+12 -2
View File
@@ -103,9 +103,19 @@ class DataGrid<T> extends ConsumerWidget {
DataGridState<T> state, DataGridState<T> state,
DataGridController<T> controller, DataGridController<T> controller,
) { ) {
return Padding( final colorScheme = Theme.of(context).colorScheme;
padding: const EdgeInsets.all(16),
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
border: Border(
bottom: BorderSide(color: colorScheme.outlineVariant),
),
),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
if (config.enableSearch) if (config.enableSearch)
DataGridSearchBar( DataGridSearchBar(
@@ -35,6 +35,9 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
/// Debounce timer for search. /// Debounce timer for search.
Timer? _searchDebounce; Timer? _searchDebounce;
/// All items before local filtering (for local search).
List<T> _allItems = [];
@override @override
void dispose() { void dispose() {
_searchDebounce?.cancel(); _searchDebounce?.cancel();
@@ -57,16 +60,21 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
final (offset, limit) = _getPaginationParams(); final (offset, limit) = _getPaginationParams();
final result = await source.fetch( final result = await source.fetch(
searchQuery: state.searchQuery.isEmpty ? null : state.searchQuery,
sortField: sortField, sortField: sortField,
sortDescending: state.sortDescending, sortDescending: state.sortDescending,
offset: offset, offset: offset,
limit: limit, 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( state = state.copyWith(
items: result.items, items: filteredItems,
totalCount: result.totalCount, totalCount: filteredItems.length,
hasMore: result.hasMore, hasMore: result.hasMore,
isLoading: false, isLoading: false,
isInitialLoad: false, isInitialLoad: false,
@@ -82,6 +90,26 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
} }
} }
/// Applies local filtering based on search query.
List<T> _applyLocalFilter(List<T> items) {
if (state.searchQuery.isEmpty) return items;
final query = state.searchQuery.toLowerCase();
return items.where((item) {
// Check all searchable columns
for (final column in config.columns) {
if (column.searchable) {
final value = column.valueBuilder(item);
if (value.toLowerCase().contains(query)) {
return true;
}
}
}
return false;
}).toList();
}
/// Gets pagination parameters based on data mode. /// Gets pagination parameters based on data mode.
(int?, int?) _getPaginationParams() { (int?, int?) _getPaginationParams() {
return switch (config.dataMode) { return switch (config.dataMode) {
@@ -97,13 +125,13 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
/// Refreshes the grid data. /// Refreshes the grid data.
Future<void> refresh() => _load(refresh: true); Future<void> refresh() => _load(refresh: true);
/// Sets the search query with debouncing. /// Sets the search query with debouncing (filters locally).
void search(String query) { void search(String query) {
_searchDebounce?.cancel(); _searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 300), () { _searchDebounce = Timer(const Duration(milliseconds: 150), () {
if (state.searchQuery != query) { if (state.searchQuery != query) {
state = state.copyWith(searchQuery: query, currentPage: 0); state = state.copyWith(searchQuery: query, currentPage: 0);
_load(refresh: true); _applyFilterAndUpdateState();
} }
}); });
} }
@@ -113,10 +141,19 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
_searchDebounce?.cancel(); _searchDebounce?.cancel();
if (state.searchQuery.isNotEmpty) { if (state.searchQuery.isNotEmpty) {
state = state.copyWith(searchQuery: '', currentPage: 0); 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. /// Sorts by the given column index.
void sortBy(int columnIndex) { void sortBy(int columnIndex) {
final column = config.columns[columnIndex]; final column = config.columns[columnIndex];
@@ -40,14 +40,16 @@ class _DataGridSearchBarState extends State<DataGridSearchBar> {
return SizedBox( return SizedBox(
width: 300, width: 300,
height: 36,
child: TextField( child: TextField(
controller: _controller, controller: _controller,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration( decoration: InputDecoration(
hintText: widget.hintText, hintText: widget.hintText,
prefixIcon: const Icon(Icons.search), prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: _controller.text.isNotEmpty suffixIcon: _controller.text.isNotEmpty
? IconButton( ? IconButton(
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear, size: 18),
onPressed: () { onPressed: () {
_controller.clear(); _controller.clear();
widget.onClear(); widget.onClear();
@@ -56,14 +58,22 @@ class _DataGridSearchBarState extends State<DataGridSearchBar> {
: null, : null,
isDense: true, isDense: true,
filled: true, filled: true,
fillColor: colorScheme.surfaceContainerHighest, fillColor: colorScheme.surface,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8), 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( contentPadding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 12,
vertical: 12, vertical: 8,
), ),
), ),
onChanged: (value) { onChanged: (value) {
+2 -2
View File
@@ -7,7 +7,7 @@ class AppVersion {
static const String name = 'tatlock_ui'; static const String name = 'tatlock_ui';
static const String description = 'Tatlock - a Home Lab AI'; 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 int buildNumber = 1;
static const String fullVersion = '0.3.0+1'; static const String fullVersion = '0.3.3+1';
} }
+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: 0.3.2+1 version: 0.3.3+1
environment: environment:
sdk: ^3.10.4 sdk: ^3.10.4
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 2.6 KiB

+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<title>OK</title>
</head>
<body>OK</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 102 KiB

+3 -3
View File
@@ -18,18 +18,18 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project."> <meta name="description" content="Tatlock Home Dashboard">
<!-- iOS meta tags & icons --> <!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="tatlock_ui"> <meta name="apple-mobile-web-app-title" content="Tatlock">
<link rel="apple-touch-icon" href="icons/Icon-192.png"> <link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/> <link rel="icon" type="image/png" href="favicon.png"/>
<title>tatlock_ui</title> <title>Tatlock</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
</head> </head>
<body> <body>
+5 -5
View File
@@ -1,11 +1,11 @@
{ {
"name": "tatlock_ui", "name": "Tatlock",
"short_name": "tatlock_ui", "short_name": "Tatlock",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#0175C2", "background_color": "#1a1a2e",
"theme_color": "#0175C2", "theme_color": "#1a1a2e",
"description": "A new Flutter project.", "description": "Tatlock Home Dashboard",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"prefer_related_applications": false, "prefer_related_applications": false,
"icons": [ "icons": [