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>
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OK</title>
|
||||||
|
</head>
|
||||||
|
<body>OK</body>
|
||||||
|
</html>
|
||||||
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 102 KiB |
@@ -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>
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||