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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/domain/entities/quick_link.dart';
|
||||||
@@ -152,6 +154,9 @@ class _SettingsModeHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// List of quick links grouped by category.
|
/// 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 {
|
class _QuickLinksList extends ConsumerWidget {
|
||||||
const _QuickLinksList({
|
const _QuickLinksList({
|
||||||
required this.links,
|
required this.links,
|
||||||
@@ -187,12 +192,72 @@ class _QuickLinksList extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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(
|
return ListView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
children: _buildLinkList(context, ref),
|
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) {
|
List<Widget> _buildLinkList(BuildContext context, WidgetRef ref) {
|
||||||
final widgets = <Widget>[];
|
final widgets = <Widget>[];
|
||||||
final showHeaders = _showCategoryHeaders;
|
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.
|
/// Category header with left accent bar.
|
||||||
class _CategoryHeader extends StatelessWidget {
|
class _CategoryHeader extends StatelessWidget {
|
||||||
const _CategoryHeader({required this.title});
|
const _CategoryHeader({required this.title});
|
||||||
|
|||||||
Reference in New Issue
Block a user