feat(front-hall): add link management with form and icon picker

- Create IconPicker widget with searchable grid of Material icons
- Create QuickLinkForm with full CRUD support (create, edit, delete)
- Update QuickLinkSettingsContent to use real form instead of placeholder
- Update QuickLinksPanel to use shared getIconData function
- Form includes name, URL, category, icon, type, and active toggle
- Validation for required fields and URL format

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-01-02 23:19:03 +01:00
co-authored by Claude Opus 4.5
parent 9c9ec472ef
commit 327b4a1233
5 changed files with 859 additions and 144 deletions
@@ -0,0 +1,334 @@
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/quick_links_provider.dart';
import 'package:tatlock_ui/shared/widgets/entity_page.dart';
import 'package:tatlock_ui/shared/widgets/icon_picker.dart';
/// Form for creating and editing Quick Links.
///
/// Handles validation, submission, and state management for link data.
class QuickLinkForm extends ConsumerStatefulWidget {
const QuickLinkForm({
super.key,
required this.mode,
this.quickLink,
required this.onCancel,
required this.onSaved,
});
final EntityPageMode mode;
final QuickLink? quickLink;
final VoidCallback onCancel;
final VoidCallback onSaved;
@override
ConsumerState<QuickLinkForm> createState() => _QuickLinkFormState();
}
class _QuickLinkFormState extends ConsumerState<QuickLinkForm> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _urlController;
late final TextEditingController _categoryController;
late String _iconName;
late QuickLinkType _type;
late bool _isActive;
bool _isSubmitting = false;
@override
void initState() {
super.initState();
final link = widget.quickLink;
_nameController = TextEditingController(text: link?.name ?? '');
_urlController = TextEditingController(text: link?.url ?? '');
_categoryController = TextEditingController(text: link?.category ?? '');
_iconName = link?.iconName ?? 'link';
_type = link?.type ?? QuickLinkType.iframe;
_isActive = link?.isActive ?? true;
}
@override
void dispose() {
_nameController.dispose();
_urlController.dispose();
_categoryController.dispose();
super.dispose();
}
bool get isCreate => widget.mode == EntityPageMode.create;
Future<void> _handleSubmit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isSubmitting = true);
try {
final actions = ref.read(quickLinkActionsProvider.notifier);
// 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,
);
if (isCreate) {
await actions.create(link);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Link created successfully'),
behavior: SnackBarBehavior.floating,
),
);
}
} else {
await actions.update(link);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Link updated successfully'),
behavior: SnackBarBehavior.floating,
),
);
}
}
widget.onSaved();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
behavior: SnackBarBehavior.floating,
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
}
}
}
Future<void> _handleDelete() async {
if (widget.quickLink == null) return;
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Link'),
content: Text('Are you sure you want to delete "${widget.quickLink!.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Delete'),
),
],
),
);
if (confirm == true) {
setState(() => _isSubmitting = true);
try {
await ref.read(quickLinkActionsProvider.notifier).delete(widget.quickLink!.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Link deleted'),
behavior: SnackBarBehavior.floating,
),
);
widget.onSaved();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error deleting: $e'),
behavior: SnackBarBehavior.floating,
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
}
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24),
children: [
// Name field
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
hintText: 'Enter link name',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Name is required';
}
return null;
},
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
// URL field
TextFormField(
controller: _urlController,
decoration: const InputDecoration(
labelText: 'URL',
hintText: 'https://example.com',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'URL is required';
}
final uri = Uri.tryParse(value);
if (uri == null || !uri.hasScheme) {
return 'Enter a valid URL with scheme (http/https)';
}
return null;
},
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
// Category field
TextFormField(
controller: _categoryController,
decoration: const InputDecoration(
labelText: 'Category',
hintText: 'e.g., Home, Infrastructure, Coding',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.done,
),
const SizedBox(height: 24),
// Icon picker
IconPicker(
selectedIcon: _iconName,
onChanged: (icon) => setState(() => _iconName = icon),
),
const SizedBox(height: 24),
// Link type
Text(
'Link Type',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
SegmentedButton<QuickLinkType>(
segments: const [
ButtonSegment(
value: QuickLinkType.iframe,
icon: Icon(Icons.web),
label: Text('Iframe'),
),
ButtonSegment(
value: QuickLinkType.newTab,
icon: Icon(Icons.open_in_new),
label: Text('New Tab'),
),
],
selected: {_type},
onSelectionChanged: (selected) {
setState(() => _type = selected.first);
},
),
const SizedBox(height: 8),
Text(
_type == QuickLinkType.iframe
? 'Opens embedded in the dashboard'
: 'Opens in a new browser tab',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.outline,
),
),
const SizedBox(height: 24),
// Active toggle
SwitchListTile(
title: const Text('Active'),
subtitle: const Text('Show this link in the panel'),
value: _isActive,
onChanged: (value) => setState(() => _isActive = value),
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 32),
// Action buttons
Row(
children: [
if (!isCreate) ...[
OutlinedButton.icon(
onPressed: _isSubmitting ? null : _handleDelete,
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
),
icon: const Icon(Icons.delete),
label: const Text('Delete'),
),
const Spacer(),
] else
const Spacer(),
TextButton(
onPressed: _isSubmitting ? null : widget.onCancel,
child: const Text('Cancel'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _isSubmitting ? null : _handleSubmit,
child: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(isCreate ? 'Create' : 'Save'),
),
],
),
],
),
);
}
}
@@ -2,24 +2,62 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/features/front_hall/presentation/widgets/quick_link_form.dart';
import 'package:tatlock_ui/shared/widgets/entity_page.dart';
/// Settings content for Quick Link management.
///
/// Shows either:
/// - Link editor form (when a link is selected)
/// - Create form (when isCreating state is set)
/// - Edit form (when a link is selected)
/// - Empty state with "Add New Link" prompt (when no link selected)
///
/// Full implementation in Phase 4.
class QuickLinkSettingsContent extends ConsumerWidget {
class QuickLinkSettingsContent extends ConsumerStatefulWidget {
const QuickLinkSettingsContent({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<QuickLinkSettingsContent> createState() =>
_QuickLinkSettingsContentState();
}
class _QuickLinkSettingsContentState
extends ConsumerState<QuickLinkSettingsContent> {
bool _isCreating = false;
void _startCreating() {
setState(() => _isCreating = true);
// Clear any selected link when creating new
ref.read(frontHallStateProvider.notifier).clearSelectedLink();
}
void _stopCreating() {
setState(() => _isCreating = false);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final frontHallState = ref.watch(frontHallStateProvider);
final selectedLinkId = frontHallState.selectedLinkId;
// Create mode - show create form
if (_isCreating) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _stopCreating,
),
title: const Text('Create New Link'),
),
body: QuickLinkForm(
mode: EntityPageMode.create,
onCancel: _stopCreating,
onSaved: _stopCreating,
),
);
}
// No link selected - show empty state
if (selectedLinkId == null) {
return Center(
@@ -42,7 +80,7 @@ class QuickLinkSettingsContent extends ConsumerWidget {
),
const SizedBox(height: 8),
Text(
'Select a link from the panel to edit, or click "Add New Link" to create one.',
'Select a link from the panel to edit, or create a new one.',
textAlign: TextAlign.center,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.outline,
@@ -50,15 +88,7 @@ class QuickLinkSettingsContent extends ConsumerWidget {
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () {
// TODO: Create new link - will show form in Phase 4
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Link editor coming in Phase 4'),
behavior: SnackBarBehavior.floating,
),
);
},
onPressed: _startCreating,
icon: const Icon(Icons.add),
label: const Text('Create New Link'),
),
@@ -68,7 +98,7 @@ class QuickLinkSettingsContent extends ConsumerWidget {
);
}
// Link selected - show placeholder for editor (Phase 4)
// Link selected - show edit form
final linkAsync = ref.watch(quickLinkProvider(selectedLinkId));
return linkAsync.when(
@@ -82,7 +112,8 @@ class QuickLinkSettingsContent extends ConsumerWidget {
Text('Error loading link: $error'),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () => ref.invalidate(quickLinkProvider(selectedLinkId)),
onPressed: () =>
ref.invalidate(quickLinkProvider(selectedLinkId)),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
@@ -98,110 +129,18 @@ class QuickLinkSettingsContent extends ConsumerWidget {
.clearSelectedLink(),
),
title: Text('Edit: ${link.name}'),
actions: [
TextButton(
onPressed: () {
// TODO: Save link - Phase 4
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Save functionality coming in Phase 4'),
behavior: SnackBarBehavior.floating,
),
);
},
child: const Text('Save'),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Placeholder form fields (Phase 4)
_PlaceholderField(label: 'Name', value: link.name),
const SizedBox(height: 16),
_PlaceholderField(label: 'URL', value: link.url),
const SizedBox(height: 16),
_PlaceholderField(
label: 'Category',
value: link.category ?? 'None',
),
const SizedBox(height: 16),
_PlaceholderField(
label: 'Type',
value: link.isIframe ? 'Iframe' : 'New Tab',
),
const SizedBox(height: 16),
_PlaceholderField(label: 'Icon', value: link.iconName),
const SizedBox(height: 32),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.info_outline, color: colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Text(
'Full link editor form will be implemented in Phase 4.',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
],
),
body: QuickLinkForm(
mode: EntityPageMode.edit,
quickLink: link,
onCancel: () => ref
.read(frontHallStateProvider.notifier)
.clearSelectedLink(),
onSaved: () => ref
.read(frontHallStateProvider.notifier)
.clearSelectedLink(),
),
),
);
}
}
class _PlaceholderField extends StatelessWidget {
const _PlaceholderField({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Text(
value,
style: textTheme.bodyLarge,
),
),
],
);
}
}
@@ -4,6 +4,7 @@ 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.
@@ -332,31 +333,6 @@ class _QuickLinkTile extends StatelessWidget {
final VoidCallback? onEdit;
final VoidCallback? onDelete;
IconData _getIconData(String iconName) {
// Map common icon names to Material icons
return switch (iconName.toLowerCase()) {
'home' => Icons.home,
'movie' => Icons.movie,
'chat' => Icons.chat,
'search' => Icons.search,
'videogame_asset' => Icons.videogame_asset,
'code' => Icons.code,
'terminal' => Icons.terminal,
'monitoring' => Icons.show_chart,
'dns' => Icons.dns,
'public' => Icons.public,
'api' => Icons.api,
'settings' => Icons.settings,
'storage' => Icons.storage,
'cloud' => Icons.cloud,
'security' => Icons.security,
'person' => Icons.person,
'folder' => Icons.folder,
'link' => Icons.link,
_ => Icons.link,
};
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -377,7 +353,7 @@ class _QuickLinkTile extends StatelessWidget {
child: Row(
children: [
Icon(
_getIconData(link.iconName),
getIconData(link.iconName),
size: 20,
color: isSelected
? colorScheme.primary