feat(front-hall): add drag-to-reorder for quick links in settings mode
- Add ReorderableListView in settings mode for drag-to-reorder support - Create _ReorderableLinkTile with drag handle indicator - Show flat list with categories as subtitles when reordering - Call reorder API when items are dragged to new position - Keep grouped view with category headers in normal mode 🤖 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
327b4a1233
commit
1d68402067
@@ -1,3 +1,5 @@
|
||||
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';
|
||||
@@ -152,6 +154,9 @@ class _SettingsModeHeader extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -187,12 +192,72 @@ class _QuickLinksList extends ConsumerWidget {
|
||||
|
||||
@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;
|
||||
@@ -280,6 +345,138 @@ class _QuickLinksList extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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});
|
||||
|
||||
Reference in New Issue
Block a user