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>
684 lines
21 KiB
Dart
684 lines
21 KiB
Dart
import 'dart:ui' show lerpDouble;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:tatlock_ui/features/front_hall/domain/entities/quick_link.dart';
|
|
import 'package:tatlock_ui/features/front_hall/presentation/providers/front_hall_state_provider.dart';
|
|
import 'package:tatlock_ui/features/front_hall/presentation/providers/quick_links_provider.dart';
|
|
import 'package:tatlock_ui/shared/layouts/widgets/panel_header.dart';
|
|
import 'package:tatlock_ui/shared/widgets/icon_picker.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
/// Left-side panel for quick link navigation in Front Hall.
|
|
///
|
|
/// Shows categorized links that can open in iframe or new tab.
|
|
/// Adapts behavior based on current mode (normal vs settings).
|
|
class QuickLinksPanel extends ConsumerWidget {
|
|
const QuickLinksPanel({
|
|
super.key,
|
|
this.width = 280,
|
|
});
|
|
|
|
final double width;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final frontHallState = ref.watch(frontHallStateProvider);
|
|
final quickLinksAsync = ref.watch(quickLinksProvider);
|
|
final isSettingsMode = frontHallState.mode == FrontHallMode.settings;
|
|
|
|
return SizedBox(
|
|
width: width,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Header changes based on mode
|
|
if (isSettingsMode)
|
|
_SettingsModeHeader(
|
|
onBack: () =>
|
|
ref.read(frontHallStateProvider.notifier).exitSettings(),
|
|
)
|
|
else
|
|
PanelHeader(
|
|
title: 'Quick Links',
|
|
icon: Icons.link,
|
|
dockToBottom: true,
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh, size: 20),
|
|
tooltip: 'Refresh',
|
|
onPressed: () => ref.invalidate(quickLinksProvider),
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
),
|
|
],
|
|
),
|
|
|
|
// Add new link button (settings mode only)
|
|
if (isSettingsMode) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: FilledButton.icon(
|
|
onPressed: () => ref
|
|
.read(frontHallStateProvider.notifier)
|
|
.clearSelectedLink(),
|
|
icon: const Icon(Icons.add, size: 18),
|
|
label: const Text('Add New Link'),
|
|
),
|
|
),
|
|
Divider(height: 1, color: colorScheme.outlineVariant),
|
|
],
|
|
|
|
// Links list
|
|
Expanded(
|
|
child: quickLinksAsync.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (error, _) => _ErrorState(
|
|
error: error,
|
|
onRetry: () => ref.invalidate(quickLinksProvider),
|
|
),
|
|
data: (links) {
|
|
// Use default links if empty
|
|
final displayLinks =
|
|
links.isEmpty ? getDefaultQuickLinks() : links;
|
|
return _QuickLinksList(
|
|
links: displayLinks,
|
|
selectedLinkId: frontHallState.selectedLinkId,
|
|
isSettingsMode: isSettingsMode,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
// Settings button (normal mode only)
|
|
if (!isSettingsMode) ...[
|
|
Divider(height: 1, color: colorScheme.outlineVariant),
|
|
Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: TextButton.icon(
|
|
onPressed: () => ref
|
|
.read(frontHallStateProvider.notifier)
|
|
.enterSettings(),
|
|
icon: const Icon(Icons.settings, size: 18),
|
|
label: const Text('Settings'),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Header for settings mode with back button.
|
|
class _SettingsModeHeader extends StatelessWidget {
|
|
const _SettingsModeHeader({required this.onBack});
|
|
|
|
final VoidCallback onBack;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
return Container(
|
|
height: 56,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
border: Border(
|
|
bottom: BorderSide(color: colorScheme.outlineVariant),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
tooltip: 'Back to Dashboard',
|
|
onPressed: onBack,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Icon(Icons.link, size: 20, color: colorScheme.primary),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Quick Links',
|
|
style: textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// List of quick links grouped by category.
|
|
///
|
|
/// In settings mode, shows a flat reorderable list.
|
|
/// In normal mode, shows grouped list with category headers.
|
|
class _QuickLinksList extends ConsumerWidget {
|
|
const _QuickLinksList({
|
|
required this.links,
|
|
required this.selectedLinkId,
|
|
required this.isSettingsMode,
|
|
});
|
|
|
|
final List<QuickLink> links;
|
|
final String? selectedLinkId;
|
|
final bool isSettingsMode;
|
|
|
|
/// Groups links by category, preserving order.
|
|
List<(String?, List<QuickLink>)> get _groupedLinks {
|
|
final groups = <String?, List<QuickLink>>{};
|
|
final order = <String?>[];
|
|
|
|
for (final link in links) {
|
|
if (!groups.containsKey(link.category)) {
|
|
groups[link.category] = [];
|
|
order.add(link.category);
|
|
}
|
|
groups[link.category]!.add(link);
|
|
}
|
|
|
|
return order.map((category) => (category, groups[category]!)).toList();
|
|
}
|
|
|
|
bool get _showCategoryHeaders {
|
|
final categories =
|
|
links.map((l) => l.category).where((c) => c != null).toSet();
|
|
return categories.length > 1;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
// In settings mode, show reorderable list
|
|
if (isSettingsMode) {
|
|
return _buildReorderableList(context, ref);
|
|
}
|
|
|
|
// In normal mode, show grouped list
|
|
return ListView(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
children: _buildLinkList(context, ref),
|
|
);
|
|
}
|
|
|
|
Widget _buildReorderableList(BuildContext context, WidgetRef ref) {
|
|
return ReorderableListView.builder(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
itemCount: links.length,
|
|
onReorder: (oldIndex, newIndex) => _handleReorder(ref, oldIndex, newIndex),
|
|
proxyDecorator: (child, index, animation) {
|
|
return AnimatedBuilder(
|
|
animation: animation,
|
|
builder: (context, child) {
|
|
final elevation = lerpDouble(0, 8, animation.value) ?? 0;
|
|
return Material(
|
|
elevation: elevation,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: child,
|
|
);
|
|
},
|
|
child: child,
|
|
);
|
|
},
|
|
itemBuilder: (context, index) {
|
|
final link = links[index];
|
|
return _ReorderableLinkTile(
|
|
key: ValueKey(link.id),
|
|
link: link,
|
|
index: index,
|
|
isSelected: link.id == selectedLinkId,
|
|
onTap: () => _handleLinkTap(ref, link),
|
|
onEdit: () => ref
|
|
.read(frontHallStateProvider.notifier)
|
|
.selectLink(link.id),
|
|
onDelete: () => _handleDelete(context, ref, link),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _handleReorder(WidgetRef ref, int oldIndex, int newIndex) {
|
|
// Adjust for the removal
|
|
if (newIndex > oldIndex) {
|
|
newIndex -= 1;
|
|
}
|
|
|
|
// Create new ordered list
|
|
final reorderedLinks = List<QuickLink>.from(links);
|
|
final item = reorderedLinks.removeAt(oldIndex);
|
|
reorderedLinks.insert(newIndex, item);
|
|
|
|
// Extract IDs in new order
|
|
final orderedIds = reorderedLinks.map((l) => l.id).toList();
|
|
|
|
// Call reorder API
|
|
ref.read(quickLinkActionsProvider.notifier).reorder(orderedIds);
|
|
}
|
|
|
|
List<Widget> _buildLinkList(BuildContext context, WidgetRef ref) {
|
|
final widgets = <Widget>[];
|
|
final showHeaders = _showCategoryHeaders;
|
|
|
|
for (final (category, categoryLinks) in _groupedLinks) {
|
|
// Add category header if multiple categories exist
|
|
if (showHeaders && category != null) {
|
|
widgets.add(_CategoryHeader(title: category));
|
|
}
|
|
|
|
// Add links
|
|
for (final link in categoryLinks) {
|
|
widgets.add(
|
|
_QuickLinkTile(
|
|
link: link,
|
|
isSelected: link.id == selectedLinkId,
|
|
isSettingsMode: isSettingsMode,
|
|
onTap: () => _handleLinkTap(ref, link),
|
|
onEdit: isSettingsMode
|
|
? () => ref
|
|
.read(frontHallStateProvider.notifier)
|
|
.selectLink(link.id)
|
|
: null,
|
|
onDelete: isSettingsMode
|
|
? () => _handleDelete(context, ref, link)
|
|
: null,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return widgets;
|
|
}
|
|
|
|
void _handleLinkTap(WidgetRef ref, QuickLink link) {
|
|
if (isSettingsMode) {
|
|
// In settings mode, select link for editing
|
|
ref.read(frontHallStateProvider.notifier).selectLink(link.id);
|
|
} else {
|
|
// 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)
|
|
.showIframe(link.url, link.name);
|
|
} else {
|
|
// Open in new tab
|
|
_openInNewTab(link.url);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _openInNewTab(String url) async {
|
|
final uri = Uri.tryParse(url);
|
|
if (uri != null && await canLaunchUrl(uri)) {
|
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
}
|
|
}
|
|
|
|
Future<void> _handleDelete(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
QuickLink link,
|
|
) async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Delete Link'),
|
|
content: Text('Delete "${link.name}"?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: const Text('Delete'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed == true) {
|
|
await ref.read(quickLinkActionsProvider.notifier).delete(link.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reorderable link tile with drag handle for settings mode.
|
|
class _ReorderableLinkTile extends StatelessWidget {
|
|
const _ReorderableLinkTile({
|
|
super.key,
|
|
required this.link,
|
|
required this.index,
|
|
required this.isSelected,
|
|
required this.onTap,
|
|
required this.onEdit,
|
|
required this.onDelete,
|
|
});
|
|
|
|
final QuickLink link;
|
|
final int index;
|
|
final bool isSelected;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onEdit;
|
|
final VoidCallback onDelete;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
child: Material(
|
|
color: isSelected
|
|
? colorScheme.primaryContainer.withValues(alpha: 0.4)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
|
child: Row(
|
|
children: [
|
|
// Drag handle
|
|
ReorderableDragStartListener(
|
|
index: index,
|
|
child: MouseRegion(
|
|
cursor: SystemMouseCursors.grab,
|
|
child: Icon(
|
|
Icons.drag_indicator,
|
|
size: 20,
|
|
color: colorScheme.outline,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
// Icon
|
|
Icon(
|
|
getIconData(link.iconName),
|
|
size: 20,
|
|
color: isSelected
|
|
? colorScheme.primary
|
|
: colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 12),
|
|
// Name and subtitle
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
link.name,
|
|
style: textTheme.bodyMedium?.copyWith(
|
|
color: isSelected
|
|
? colorScheme.primary
|
|
: colorScheme.onSurface,
|
|
fontWeight: isSelected ? FontWeight.w600 : null,
|
|
),
|
|
),
|
|
Text(
|
|
link.category ?? 'No category',
|
|
style: textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.outline,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Overflow menu
|
|
PopupMenuButton<String>(
|
|
icon: Icon(
|
|
Icons.more_vert,
|
|
size: 18,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
padding: EdgeInsets.zero,
|
|
itemBuilder: (context) => [
|
|
const PopupMenuItem(
|
|
value: 'edit',
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.edit, size: 18),
|
|
SizedBox(width: 8),
|
|
Text('Edit'),
|
|
],
|
|
),
|
|
),
|
|
const PopupMenuItem(
|
|
value: 'delete',
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.delete, size: 18),
|
|
SizedBox(width: 8),
|
|
Text('Delete'),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
onSelected: (value) {
|
|
switch (value) {
|
|
case 'edit':
|
|
onEdit();
|
|
case 'delete':
|
|
onDelete();
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Category header with left accent bar.
|
|
class _CategoryHeader extends StatelessWidget {
|
|
const _CategoryHeader({required this.title});
|
|
|
|
final String title;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.only(top: 8, bottom: 4),
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHigh,
|
|
border: Border(
|
|
left: BorderSide(
|
|
color: colorScheme.primary,
|
|
width: 2,
|
|
),
|
|
),
|
|
),
|
|
child: Text(
|
|
title.toUpperCase(),
|
|
style: textTheme.labelSmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
letterSpacing: 0.5,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Individual quick link tile.
|
|
class _QuickLinkTile extends StatelessWidget {
|
|
const _QuickLinkTile({
|
|
required this.link,
|
|
required this.isSelected,
|
|
required this.isSettingsMode,
|
|
required this.onTap,
|
|
this.onEdit,
|
|
this.onDelete,
|
|
});
|
|
|
|
final QuickLink link;
|
|
final bool isSelected;
|
|
final bool isSettingsMode;
|
|
final VoidCallback onTap;
|
|
final VoidCallback? onEdit;
|
|
final VoidCallback? onDelete;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
child: Material(
|
|
color: isSelected
|
|
? colorScheme.primaryContainer.withValues(alpha: 0.4)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
getIconData(link.iconName),
|
|
size: 20,
|
|
color: isSelected
|
|
? colorScheme.primary
|
|
: colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
link.name,
|
|
style: textTheme.bodyMedium?.copyWith(
|
|
color:
|
|
isSelected ? colorScheme.primary : colorScheme.onSurface,
|
|
fontWeight: isSelected ? FontWeight.w600 : null,
|
|
),
|
|
),
|
|
if (isSettingsMode)
|
|
Text(
|
|
link.isNewTab ? 'Opens in new tab' : 'Opens in iframe',
|
|
style: textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.outline,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// New tab indicator (normal mode)
|
|
if (!isSettingsMode && link.isNewTab)
|
|
Icon(
|
|
Icons.open_in_new,
|
|
size: 14,
|
|
color: colorScheme.outline,
|
|
),
|
|
// Overflow menu (settings mode)
|
|
if (isSettingsMode)
|
|
PopupMenuButton<String>(
|
|
icon: Icon(
|
|
Icons.more_vert,
|
|
size: 18,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
padding: EdgeInsets.zero,
|
|
itemBuilder: (context) => [
|
|
const PopupMenuItem(
|
|
value: 'edit',
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.edit, size: 18),
|
|
SizedBox(width: 8),
|
|
Text('Edit'),
|
|
],
|
|
),
|
|
),
|
|
const PopupMenuItem(
|
|
value: 'delete',
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.delete, size: 18),
|
|
SizedBox(width: 8),
|
|
Text('Delete'),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
onSelected: (value) {
|
|
switch (value) {
|
|
case 'edit':
|
|
onEdit?.call();
|
|
case 'delete':
|
|
onDelete?.call();
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Error state widget.
|
|
class _ErrorState extends StatelessWidget {
|
|
const _ErrorState({
|
|
required this.error,
|
|
required this.onRetry,
|
|
});
|
|
|
|
final Object error;
|
|
final VoidCallback onRetry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.error_outline,
|
|
color: colorScheme.error,
|
|
size: 32,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Failed to load links',
|
|
style: TextStyle(color: colorScheme.error),
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextButton.icon(
|
|
onPressed: onRetry,
|
|
icon: const Icon(Icons.refresh, size: 16),
|
|
label: const Text('Retry'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|