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]
## [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
+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)
The presentation layer contains UI code and state management.
+41 -14
View File
@@ -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);
}
}
@@ -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<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>))
.toList() ??
[];
final fullId = json['Id'] as String? ?? '';
final names = json['Names'] as List<dynamic>? ?? [];
final name = names.isNotEmpty
? (names.first as String).replaceFirst('/', '')
: 'Unknown';
final status = json['Status'] as String? ?? 'Unknown';
return ContainerData(
id: 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<ContainerPort> ports;
final ContainerHealth health;
/// Status string with health info stripped (just shows uptime).
String get displayStatus {
return status
.replaceAll(RegExp(r'\s*\(healthy\)', caseSensitive: false), '')
.replaceAll(RegExp(r'\s*\(unhealthy\)', caseSensitive: false), '')
.replaceAll(RegExp(r'\s*\(health: starting\)', caseSensitive: false), '')
.trim();
}
bool get canStart => state == 'exited' || state == 'created';
bool get canStop => state == 'running';
@@ -53,9 +86,10 @@ class ContainerPort {
ContainerPort({required this.privatePort, this.publicPort, this.type = 'tcp'});
factory ContainerPort.fromJson(Map<String, dynamic> 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<ContainersListPage> {
columns: [
DataGridColumn<ContainerData>(
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<ContainersListPage> {
),
DataGridColumn<ContainerData>(
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(
@@ -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',
),
};
}
}
@@ -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<dynamic> containers;
final List<container_entity.Container> 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),
@@ -52,7 +52,7 @@ class QuickLinksDatasource {
final model = QuickLinkModel.fromEntity(link);
final response = await _dio.post<Map<String, dynamic>>(
_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<Map<String, dynamic>>(
'$_basePath/$id',
data: model.toCreateJson(),
data: model.toJson(),
);
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.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<String, dynamic> 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',
};
}
@@ -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: () =>
@@ -83,7 +83,7 @@ class _IframeViewState extends State<IframeView> {
children: [
// Header bar
Container(
height: 48,
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
@@ -67,24 +67,21 @@ class _QuickLinkFormState extends ConsumerState<QuickLinkForm> {
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<QuickLinkForm> {
);
}
} 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<QuickLinkForm> {
}
}
// Refresh the list before exiting
ref.invalidate(quickLinksProvider);
widget.onSaved();
} catch (e) {
if (mounted) {
@@ -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(),
),
),
);
@@ -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)
+12 -2
View File
@@ -103,9 +103,19 @@ class DataGrid<T> extends ConsumerWidget {
DataGridState<T> state,
DataGridController<T> 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(
@@ -35,6 +35,9 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
/// Debounce timer for search.
Timer? _searchDebounce;
/// All items before local filtering (for local search).
List<T> _allItems = [];
@override
void dispose() {
_searchDebounce?.cancel();
@@ -57,16 +60,21 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
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<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.
(int?, int?) _getPaginationParams() {
return switch (config.dataMode) {
@@ -97,13 +125,13 @@ class DataGridController<T> extends StateNotifier<DataGridState<T>> {
/// Refreshes the grid data.
Future<void> 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<T> extends StateNotifier<DataGridState<T>> {
_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];
@@ -40,14 +40,16 @@ class _DataGridSearchBarState extends State<DataGridSearchBar> {
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<DataGridSearchBar> {
: 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) {
+2 -2
View File
@@ -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';
}
+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: 0.3.2+1
version: 0.3.3+1
environment:
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 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 -->
<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-title" content="tatlock_ui">
<meta name="apple-mobile-web-app-title" content="Tatlock">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>tatlock_ui</title>
<title>Tatlock</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
+5 -5
View File
@@ -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": [