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:
co-authored by
Claude Opus 4.5
parent
0d15d3dbb5
commit
bdced8739c
@@ -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(
|
||||
|
||||
+87
-37
@@ -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)
|
||||
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user