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:
co-authored by
Claude Opus 4.5
parent
9c9ec472ef
commit
327b4a1233
@@ -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
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A widget that displays a grid of Material icons for selection.
|
||||
///
|
||||
/// Shows a button that opens a dialog with available icons.
|
||||
class IconPicker extends StatelessWidget {
|
||||
const IconPicker({
|
||||
super.key,
|
||||
required this.selectedIcon,
|
||||
required this.onChanged,
|
||||
this.label = 'Icon',
|
||||
});
|
||||
|
||||
/// Currently selected icon name.
|
||||
final String selectedIcon;
|
||||
|
||||
/// Callback when an icon is selected.
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
/// Label for the field.
|
||||
final String label;
|
||||
|
||||
@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: 8),
|
||||
InkWell(
|
||||
onTap: () => _showIconPicker(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
getIconData(selectedIcon),
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
selectedIcon,
|
||||
style: textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showIconPicker(BuildContext context) async {
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => IconPickerDialog(
|
||||
selectedIcon: selectedIcon,
|
||||
onSelected: (icon) => Navigator.of(context).pop(icon),
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
onChanged(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog that displays a grid of icons.
|
||||
class IconPickerDialog extends StatefulWidget {
|
||||
const IconPickerDialog({
|
||||
super.key,
|
||||
required this.selectedIcon,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final String selectedIcon;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
@override
|
||||
State<IconPickerDialog> createState() => _IconPickerDialogState();
|
||||
}
|
||||
|
||||
class _IconPickerDialogState extends State<IconPickerDialog> {
|
||||
String _searchQuery = '';
|
||||
|
||||
List<String> get _filteredIcons {
|
||||
if (_searchQuery.isEmpty) {
|
||||
return availableIcons;
|
||||
}
|
||||
return availableIcons
|
||||
.where((icon) => icon.toLowerCase().contains(_searchQuery.toLowerCase()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 500,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Select Icon',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search icons...',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (value) => setState(() => _searchQuery = value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 5,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
),
|
||||
itemCount: _filteredIcons.length,
|
||||
itemBuilder: (context, index) {
|
||||
final iconName = _filteredIcons[index];
|
||||
final isSelected = iconName == widget.selectedIcon;
|
||||
|
||||
return InkWell(
|
||||
onTap: () => widget.onSelected(iconName),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: isSelected
|
||||
? Border.all(color: colorScheme.primary, width: 2)
|
||||
: null,
|
||||
),
|
||||
child: Tooltip(
|
||||
message: iconName,
|
||||
child: Icon(
|
||||
getIconData(iconName),
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Common icons available for selection.
|
||||
const List<String> availableIcons = [
|
||||
'home',
|
||||
'settings',
|
||||
'search',
|
||||
'menu',
|
||||
'add',
|
||||
'remove',
|
||||
'close',
|
||||
'check',
|
||||
'edit',
|
||||
'delete',
|
||||
'favorite',
|
||||
'star',
|
||||
'movie',
|
||||
'music_note',
|
||||
'photo',
|
||||
'videocam',
|
||||
'camera',
|
||||
'mic',
|
||||
'volume_up',
|
||||
'play_arrow',
|
||||
'pause',
|
||||
'stop',
|
||||
'skip_next',
|
||||
'skip_previous',
|
||||
'folder',
|
||||
'file_copy',
|
||||
'cloud',
|
||||
'cloud_download',
|
||||
'cloud_upload',
|
||||
'download',
|
||||
'upload',
|
||||
'share',
|
||||
'link',
|
||||
'public',
|
||||
'language',
|
||||
'dns',
|
||||
'storage',
|
||||
'memory',
|
||||
'code',
|
||||
'terminal',
|
||||
'bug_report',
|
||||
'api',
|
||||
'http',
|
||||
'vpn_key',
|
||||
'lock',
|
||||
'lock_open',
|
||||
'security',
|
||||
'verified_user',
|
||||
'admin_panel_settings',
|
||||
'monitor',
|
||||
'monitoring',
|
||||
'analytics',
|
||||
'dashboard',
|
||||
'speed',
|
||||
'timer',
|
||||
'schedule',
|
||||
'calendar_today',
|
||||
'event',
|
||||
'notifications',
|
||||
'email',
|
||||
'message',
|
||||
'chat',
|
||||
'forum',
|
||||
'person',
|
||||
'people',
|
||||
'group',
|
||||
'account_circle',
|
||||
'shopping_cart',
|
||||
'credit_card',
|
||||
'attach_money',
|
||||
'trending_up',
|
||||
'trending_down',
|
||||
'wifi',
|
||||
'bluetooth',
|
||||
'router',
|
||||
'devices',
|
||||
'computer',
|
||||
'laptop',
|
||||
'phone_android',
|
||||
'tablet',
|
||||
'watch',
|
||||
'tv',
|
||||
'games',
|
||||
'videogame_asset',
|
||||
'sports_esports',
|
||||
'local_cafe',
|
||||
'local_dining',
|
||||
'restaurant',
|
||||
'directions_car',
|
||||
'flight',
|
||||
'hotel',
|
||||
'map',
|
||||
'place',
|
||||
'explore',
|
||||
'navigation',
|
||||
'book',
|
||||
'school',
|
||||
'science',
|
||||
'psychology',
|
||||
'work',
|
||||
'business',
|
||||
'apartment',
|
||||
'location_city',
|
||||
'eco',
|
||||
'water_drop',
|
||||
'air',
|
||||
'thermostat',
|
||||
'bolt',
|
||||
'light_mode',
|
||||
'dark_mode',
|
||||
'brightness_4',
|
||||
'palette',
|
||||
'brush',
|
||||
'format_paint',
|
||||
'extension',
|
||||
'widgets',
|
||||
'view_module',
|
||||
'grid_view',
|
||||
'list',
|
||||
'table_chart',
|
||||
'pie_chart',
|
||||
'bar_chart',
|
||||
'show_chart',
|
||||
'donut_large',
|
||||
'disc_full',
|
||||
];
|
||||
|
||||
/// Convert icon name string to IconData.
|
||||
IconData getIconData(String iconName) {
|
||||
const iconMap = <String, IconData>{
|
||||
'home': Icons.home,
|
||||
'settings': Icons.settings,
|
||||
'search': Icons.search,
|
||||
'menu': Icons.menu,
|
||||
'add': Icons.add,
|
||||
'remove': Icons.remove,
|
||||
'close': Icons.close,
|
||||
'check': Icons.check,
|
||||
'edit': Icons.edit,
|
||||
'delete': Icons.delete,
|
||||
'favorite': Icons.favorite,
|
||||
'star': Icons.star,
|
||||
'movie': Icons.movie,
|
||||
'music_note': Icons.music_note,
|
||||
'photo': Icons.photo,
|
||||
'videocam': Icons.videocam,
|
||||
'camera': Icons.camera,
|
||||
'mic': Icons.mic,
|
||||
'volume_up': Icons.volume_up,
|
||||
'play_arrow': Icons.play_arrow,
|
||||
'pause': Icons.pause,
|
||||
'stop': Icons.stop,
|
||||
'skip_next': Icons.skip_next,
|
||||
'skip_previous': Icons.skip_previous,
|
||||
'folder': Icons.folder,
|
||||
'file_copy': Icons.file_copy,
|
||||
'cloud': Icons.cloud,
|
||||
'cloud_download': Icons.cloud_download,
|
||||
'cloud_upload': Icons.cloud_upload,
|
||||
'download': Icons.download,
|
||||
'upload': Icons.upload,
|
||||
'share': Icons.share,
|
||||
'link': Icons.link,
|
||||
'public': Icons.public,
|
||||
'language': Icons.language,
|
||||
'dns': Icons.dns,
|
||||
'storage': Icons.storage,
|
||||
'memory': Icons.memory,
|
||||
'code': Icons.code,
|
||||
'terminal': Icons.terminal,
|
||||
'bug_report': Icons.bug_report,
|
||||
'api': Icons.api,
|
||||
'http': Icons.http,
|
||||
'vpn_key': Icons.vpn_key,
|
||||
'lock': Icons.lock,
|
||||
'lock_open': Icons.lock_open,
|
||||
'security': Icons.security,
|
||||
'verified_user': Icons.verified_user,
|
||||
'admin_panel_settings': Icons.admin_panel_settings,
|
||||
'monitor': Icons.monitor,
|
||||
'monitoring': Icons.monitor_heart,
|
||||
'analytics': Icons.analytics,
|
||||
'dashboard': Icons.dashboard,
|
||||
'speed': Icons.speed,
|
||||
'timer': Icons.timer,
|
||||
'schedule': Icons.schedule,
|
||||
'calendar_today': Icons.calendar_today,
|
||||
'event': Icons.event,
|
||||
'notifications': Icons.notifications,
|
||||
'email': Icons.email,
|
||||
'message': Icons.message,
|
||||
'chat': Icons.chat,
|
||||
'forum': Icons.forum,
|
||||
'person': Icons.person,
|
||||
'people': Icons.people,
|
||||
'group': Icons.group,
|
||||
'account_circle': Icons.account_circle,
|
||||
'shopping_cart': Icons.shopping_cart,
|
||||
'credit_card': Icons.credit_card,
|
||||
'attach_money': Icons.attach_money,
|
||||
'trending_up': Icons.trending_up,
|
||||
'trending_down': Icons.trending_down,
|
||||
'wifi': Icons.wifi,
|
||||
'bluetooth': Icons.bluetooth,
|
||||
'router': Icons.router,
|
||||
'devices': Icons.devices,
|
||||
'computer': Icons.computer,
|
||||
'laptop': Icons.laptop,
|
||||
'phone_android': Icons.phone_android,
|
||||
'tablet': Icons.tablet,
|
||||
'watch': Icons.watch,
|
||||
'tv': Icons.tv,
|
||||
'games': Icons.games,
|
||||
'videogame_asset': Icons.videogame_asset,
|
||||
'sports_esports': Icons.sports_esports,
|
||||
'local_cafe': Icons.local_cafe,
|
||||
'local_dining': Icons.local_dining,
|
||||
'restaurant': Icons.restaurant,
|
||||
'directions_car': Icons.directions_car,
|
||||
'flight': Icons.flight,
|
||||
'hotel': Icons.hotel,
|
||||
'map': Icons.map,
|
||||
'place': Icons.place,
|
||||
'explore': Icons.explore,
|
||||
'navigation': Icons.navigation,
|
||||
'book': Icons.book,
|
||||
'school': Icons.school,
|
||||
'science': Icons.science,
|
||||
'psychology': Icons.psychology,
|
||||
'work': Icons.work,
|
||||
'business': Icons.business,
|
||||
'apartment': Icons.apartment,
|
||||
'location_city': Icons.location_city,
|
||||
'eco': Icons.eco,
|
||||
'water_drop': Icons.water_drop,
|
||||
'air': Icons.air,
|
||||
'thermostat': Icons.thermostat,
|
||||
'bolt': Icons.bolt,
|
||||
'light_mode': Icons.light_mode,
|
||||
'dark_mode': Icons.dark_mode,
|
||||
'brightness_4': Icons.brightness_4,
|
||||
'palette': Icons.palette,
|
||||
'brush': Icons.brush,
|
||||
'format_paint': Icons.format_paint,
|
||||
'extension': Icons.extension,
|
||||
'widgets': Icons.widgets,
|
||||
'view_module': Icons.view_module,
|
||||
'grid_view': Icons.grid_view,
|
||||
'list': Icons.list,
|
||||
'table_chart': Icons.table_chart,
|
||||
'pie_chart': Icons.pie_chart,
|
||||
'bar_chart': Icons.bar_chart,
|
||||
'show_chart': Icons.show_chart,
|
||||
'donut_large': Icons.donut_large,
|
||||
'disc_full': Icons.disc_full,
|
||||
};
|
||||
|
||||
return iconMap[iconName] ?? Icons.help_outline;
|
||||
}
|
||||
@@ -3,4 +3,5 @@ library;
|
||||
|
||||
export 'air_quality_widget.dart';
|
||||
export 'gauge_widget.dart';
|
||||
export 'icon_picker.dart';
|
||||
export 'weather_widget.dart';
|
||||
|
||||
Reference in New Issue
Block a user