feat(ui): Refactor context menu with platform-specific styling
feat(navigation): migrate to super_drag_and_drop for folder drag and drop feat(ui): Add context menu preview builders for chat and notes refactor(ui): Remove preview builders and simplify note card rendering
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:conduit/core/providers/app_providers.dart';
|
||||
import 'package:conduit/l10n/app_localizations.dart';
|
||||
@@ -8,9 +8,18 @@ import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:super_context_menu/super_context_menu.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:super_context_menu/src/scaffold/mobile/menu_widget_builder.dart'
|
||||
as mobile;
|
||||
|
||||
import 'package:conduit/features/chat/providers/chat_providers.dart' as chat;
|
||||
|
||||
/// Re-export super_context_menu types for convenience.
|
||||
export 'package:super_context_menu/super_context_menu.dart'
|
||||
show ContextMenuWidget, Menu, MenuAction, MenuSeparator;
|
||||
|
||||
/// Defines an action for use in Conduit context menus.
|
||||
class ConduitContextMenuAction {
|
||||
final IconData cupertinoIcon;
|
||||
final IconData materialIcon;
|
||||
@@ -29,89 +38,326 @@ class ConduitContextMenuAction {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> showConduitContextMenu({
|
||||
required BuildContext context,
|
||||
required List<ConduitContextMenuAction> actions,
|
||||
Offset? position,
|
||||
}) async {
|
||||
if (actions.isEmpty) return;
|
||||
/// A context menu widget that provides native iOS appearance and a beautiful
|
||||
/// Material 3 styled menu on Android.
|
||||
///
|
||||
/// On iOS, this uses the native context menu provided by super_context_menu.
|
||||
/// On Android, it displays a custom Material 3 styled menu that matches the
|
||||
/// app's theme.
|
||||
class ConduitContextMenu extends StatelessWidget {
|
||||
final List<ConduitContextMenuAction> actions;
|
||||
final Widget child;
|
||||
|
||||
final theme = context.conduitTheme;
|
||||
final RenderBox? overlay =
|
||||
Overlay.of(context).context.findRenderObject() as RenderBox?;
|
||||
const ConduitContextMenu({
|
||||
super.key,
|
||||
required this.actions,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
if (overlay == null) return;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// iOS: Use native context menu
|
||||
if (Platform.isIOS) {
|
||||
return ContextMenuWidget(
|
||||
menuProvider: (_) => buildConduitMenu(actions),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
// Determine menu position
|
||||
final Offset menuPosition = position ?? _getDefaultMenuPosition(context);
|
||||
// Android: Use ContextMenuWidget with custom Material 3 styling
|
||||
return ContextMenuWidget(
|
||||
menuProvider: (_) => buildConduitMenu(actions),
|
||||
mobileMenuWidgetBuilder: _ConduitMobileMenuBuilder(
|
||||
theme: context.conduitTheme,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final result = await showMenu<ConduitContextMenuAction>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(
|
||||
menuPosition.dx,
|
||||
menuPosition.dy,
|
||||
overlay.size.width - menuPosition.dx,
|
||||
overlay.size.height - menuPosition.dy,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.small),
|
||||
),
|
||||
color: theme.surfaceBackground,
|
||||
elevation: 4,
|
||||
items: actions.map((action) {
|
||||
return PopupMenuItem<ConduitContextMenuAction>(
|
||||
value: action,
|
||||
/// Custom Material 3 styled menu builder for super_context_menu on Android.
|
||||
class _ConduitMobileMenuBuilder extends mobile.MobileMenuWidgetBuilder {
|
||||
final ConduitThemeExtension theme;
|
||||
|
||||
const _ConduitMobileMenuBuilder({required this.theme});
|
||||
|
||||
@override
|
||||
Widget buildMenuContainer(
|
||||
BuildContext context,
|
||||
mobile.MobileMenuInfo menuInfo,
|
||||
Widget child,
|
||||
) {
|
||||
// Use pre-blended shadow color for Impeller compatibility
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
||||
boxShadow: theme.popoverShadows,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildMenuContainerInner(
|
||||
BuildContext context,
|
||||
mobile.MobileMenuInfo menuInfo,
|
||||
Widget child,
|
||||
) {
|
||||
// Use pre-blended border color for Impeller compatibility
|
||||
final borderColor = Color.lerp(
|
||||
theme.surfaces.popover,
|
||||
theme.surfaces.border,
|
||||
0.15,
|
||||
)!;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.surfaces.popover,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
||||
border: Border.all(color: borderColor, width: 0.5),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildMenu(
|
||||
BuildContext context,
|
||||
mobile.MobileMenuInfo menuInfo,
|
||||
Widget child,
|
||||
) {
|
||||
return child;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildMenuItemsContainer(
|
||||
BuildContext context,
|
||||
mobile.MobileMenuInfo menuInfo,
|
||||
Widget child,
|
||||
) {
|
||||
return child;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildMenuHeader(
|
||||
BuildContext context,
|
||||
mobile.MobileMenuInfo menuInfo,
|
||||
mobile.MobileMenuButtonState state,
|
||||
) {
|
||||
// No header needed for simple menus
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildInactiveMenuVeil(
|
||||
BuildContext context,
|
||||
mobile.MobileMenuInfo menuInfo,
|
||||
) {
|
||||
// Use pre-blended solid color for Impeller compatibility
|
||||
final veilColor = theme.isDark
|
||||
? const Color(0x4D000000) // ~30% black
|
||||
: const Color(0x4D424242); // ~30% grey
|
||||
return SizedBox.expand(
|
||||
child: ColoredBox(color: veilColor),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildMenuItem(
|
||||
BuildContext context,
|
||||
mobile.MobileMenuInfo menuInfo,
|
||||
mobile.MobileMenuButtonState state,
|
||||
MenuElement element,
|
||||
) {
|
||||
if (element is MenuAction) {
|
||||
final isDestructive = element.attributes.destructive;
|
||||
final textColor = isDestructive ? theme.error : theme.textPrimary;
|
||||
final iconColor = isDestructive ? theme.error : theme.iconPrimary;
|
||||
final imageWidget = element.image?.asWidget(menuInfo.iconTheme);
|
||||
|
||||
// Use ColoredBox for pressed state to avoid Impeller opacity issues
|
||||
Widget content = Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.sm,
|
||||
vertical: Spacing.xxs,
|
||||
horizontal: Spacing.md,
|
||||
vertical: Spacing.sm + 2,
|
||||
),
|
||||
height: 36,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Platform.isIOS ? action.cupertinoIcon : action.materialIcon,
|
||||
color: action.destructive ? Colors.red : theme.iconPrimary,
|
||||
size: IconSize.xs,
|
||||
),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
if (imageWidget != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: Spacing.md),
|
||||
child: IconTheme(
|
||||
data: IconThemeData(
|
||||
color: iconColor,
|
||||
size: IconSize.medium,
|
||||
),
|
||||
child: imageWidget,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
action.label,
|
||||
style: AppTypography.standard.copyWith(
|
||||
color: action.destructive ? Colors.red : theme.textPrimary,
|
||||
element.title ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: AppTypography.bodyMedium,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
color: textColor,
|
||||
decoration: TextDecoration.none,
|
||||
fontFamily: theme.typography.primaryFont.isEmpty
|
||||
? null
|
||||
: theme.typography.primaryFont,
|
||||
fontFamilyFallback: theme.typography.primaryFallback.isEmpty
|
||||
? null
|
||||
: theme.typography.primaryFallback,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (state.pressed) {
|
||||
content = ColoredBox(
|
||||
color: theme.surfaceContainer,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
if (element is MenuSeparator) {
|
||||
// Use pre-blended color for Impeller compatibility
|
||||
final separatorColor = Color.lerp(
|
||||
theme.surfaces.popover,
|
||||
theme.dividerColor,
|
||||
0.4,
|
||||
)!;
|
||||
return Divider(
|
||||
height: 1,
|
||||
thickness: 0.5,
|
||||
indent: Spacing.md,
|
||||
endIndent: Spacing.md,
|
||||
color: separatorColor,
|
||||
);
|
||||
}
|
||||
|
||||
// For submenus or other elements, show a simple row
|
||||
if (element is Menu) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.md,
|
||||
vertical: Spacing.sm + 2,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
element.title ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: AppTypography.bodyMedium,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.textPrimary,
|
||||
decoration: TextDecoration.none,
|
||||
fontFamily: theme.typography.primaryFont.isEmpty
|
||||
? null
|
||||
: theme.typography.primaryFont,
|
||||
fontFamilyFallback: theme.typography.primaryFallback.isEmpty
|
||||
? null
|
||||
: theme.typography.primaryFallback,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
size: IconSize.small,
|
||||
color: theme.iconSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildOverlayBackground(BuildContext context, double opacity) {
|
||||
// Use pre-computed hex colors for Impeller compatibility
|
||||
// These are solid colors at different opacities (0x80 = 50%, 0x66 = 40%)
|
||||
final overlayColor = Color.lerp(
|
||||
const Color(0x00000000),
|
||||
theme.isDark ? const Color(0x80000000) : const Color(0x66000000),
|
||||
opacity,
|
||||
)!;
|
||||
// GestureDetector with opaque behavior ensures hit testing works
|
||||
// even when the overlay is visually transparent
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox.expand(
|
||||
child: ColoredBox(color: overlayColor),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildMenuPreviewContainer(BuildContext context, Widget child) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
boxShadow: theme.popoverShadows,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a [Menu] from a list of [ConduitContextMenuAction]s.
|
||||
///
|
||||
/// Use this with [ContextMenuWidget.menuProvider]:
|
||||
/// ```dart
|
||||
/// ContextMenuWidget(
|
||||
/// menuProvider: (_) => buildConduitMenu(actions),
|
||||
/// child: MyWidget(),
|
||||
/// )
|
||||
/// ```
|
||||
Menu buildConduitMenu(List<ConduitContextMenuAction> actions) {
|
||||
return Menu(
|
||||
children: actions.map((action) {
|
||||
return MenuAction(
|
||||
title: action.label,
|
||||
callback: () {
|
||||
HapticFeedback.selectionClick();
|
||||
action.onBeforeClose?.call();
|
||||
action.onSelected();
|
||||
},
|
||||
attributes: MenuActionAttributes(destructive: action.destructive),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
result.onBeforeClose?.call();
|
||||
await Future.microtask(result.onSelected);
|
||||
}
|
||||
}
|
||||
|
||||
Offset _getDefaultMenuPosition(BuildContext context) {
|
||||
final RenderBox? renderBox = context.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) {
|
||||
return Offset.zero;
|
||||
}
|
||||
final position = renderBox.localToGlobal(Offset.zero);
|
||||
final size = renderBox.size;
|
||||
return Offset(position.dx + size.width, position.dy);
|
||||
}
|
||||
|
||||
Future<void> showConversationContextMenu({
|
||||
/// Builds a list of actions for conversation context menus.
|
||||
///
|
||||
/// Use with [ConduitContextMenu]:
|
||||
/// ```dart
|
||||
/// ConduitContextMenu(
|
||||
/// actions: buildConversationActions(context: context, ref: ref, conversation: conv),
|
||||
/// child: MyWidget(),
|
||||
/// )
|
||||
/// ```
|
||||
List<ConduitContextMenuAction> buildConversationActions({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required dynamic conversation,
|
||||
}) async {
|
||||
if (conversation == null) return;
|
||||
}) {
|
||||
if (conversation == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final bool isPinned = conversation.pinned == true;
|
||||
@@ -150,48 +396,58 @@ Future<void> showConversationContextMenu({
|
||||
await _confirmAndDeleteConversation(context, ref, conversation.id);
|
||||
}
|
||||
|
||||
HapticFeedback.selectionClick();
|
||||
await showConduitContextMenu(
|
||||
context: context,
|
||||
actions: [
|
||||
ConduitContextMenuAction(
|
||||
cupertinoIcon: isPinned
|
||||
? CupertinoIcons.pin_slash
|
||||
: CupertinoIcons.pin_fill,
|
||||
materialIcon: isPinned
|
||||
? Icons.push_pin_outlined
|
||||
: Icons.push_pin_rounded,
|
||||
label: isPinned ? l10n.unpin : l10n.pin,
|
||||
onBeforeClose: () => HapticFeedback.lightImpact(),
|
||||
onSelected: togglePin,
|
||||
),
|
||||
ConduitContextMenuAction(
|
||||
cupertinoIcon: isArchived
|
||||
? CupertinoIcons.archivebox_fill
|
||||
: CupertinoIcons.archivebox,
|
||||
materialIcon: isArchived
|
||||
? Icons.unarchive_rounded
|
||||
: Icons.archive_rounded,
|
||||
label: isArchived ? l10n.unarchive : l10n.archive,
|
||||
onBeforeClose: () => HapticFeedback.lightImpact(),
|
||||
onSelected: toggleArchive,
|
||||
),
|
||||
ConduitContextMenuAction(
|
||||
cupertinoIcon: CupertinoIcons.pencil,
|
||||
materialIcon: Icons.edit_rounded,
|
||||
label: l10n.rename,
|
||||
onBeforeClose: () => HapticFeedback.selectionClick(),
|
||||
onSelected: rename,
|
||||
),
|
||||
ConduitContextMenuAction(
|
||||
cupertinoIcon: CupertinoIcons.delete,
|
||||
materialIcon: Icons.delete_rounded,
|
||||
label: l10n.delete,
|
||||
destructive: true,
|
||||
onBeforeClose: () => HapticFeedback.mediumImpact(),
|
||||
onSelected: deleteConversation,
|
||||
),
|
||||
],
|
||||
return [
|
||||
ConduitContextMenuAction(
|
||||
cupertinoIcon:
|
||||
isPinned ? CupertinoIcons.pin_slash : CupertinoIcons.pin_fill,
|
||||
materialIcon:
|
||||
isPinned ? Icons.push_pin_outlined : Icons.push_pin_rounded,
|
||||
label: isPinned ? l10n.unpin : l10n.pin,
|
||||
onBeforeClose: () => HapticFeedback.lightImpact(),
|
||||
onSelected: togglePin,
|
||||
),
|
||||
ConduitContextMenuAction(
|
||||
cupertinoIcon: isArchived
|
||||
? CupertinoIcons.archivebox_fill
|
||||
: CupertinoIcons.archivebox,
|
||||
materialIcon:
|
||||
isArchived ? Icons.unarchive_rounded : Icons.archive_rounded,
|
||||
label: isArchived ? l10n.unarchive : l10n.archive,
|
||||
onBeforeClose: () => HapticFeedback.lightImpact(),
|
||||
onSelected: toggleArchive,
|
||||
),
|
||||
ConduitContextMenuAction(
|
||||
cupertinoIcon: CupertinoIcons.pencil,
|
||||
materialIcon: Icons.edit_rounded,
|
||||
label: l10n.rename,
|
||||
onBeforeClose: () => HapticFeedback.selectionClick(),
|
||||
onSelected: rename,
|
||||
),
|
||||
ConduitContextMenuAction(
|
||||
cupertinoIcon: CupertinoIcons.delete,
|
||||
materialIcon: Icons.delete_rounded,
|
||||
label: l10n.delete,
|
||||
destructive: true,
|
||||
onBeforeClose: () => HapticFeedback.mediumImpact(),
|
||||
onSelected: deleteConversation,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Builds a [Menu] for conversation context actions.
|
||||
///
|
||||
/// Use with [ContextMenuWidget.menuProvider].
|
||||
Menu buildConversationMenu({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required dynamic conversation,
|
||||
}) {
|
||||
return buildConduitMenu(
|
||||
buildConversationActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
conversation: conversation,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -221,9 +477,7 @@ Future<void> _renameConversation(
|
||||
if (api == null) throw Exception('No API service');
|
||||
await api.updateConversation(conversationId, title: newName);
|
||||
HapticFeedback.selectionClick();
|
||||
ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.updateConversation(
|
||||
ref.read(conversationsProvider.notifier).updateConversation(
|
||||
conversationId,
|
||||
(conversation) =>
|
||||
conversation.copyWith(title: newName, updatedAt: DateTime.now()),
|
||||
|
||||
@@ -164,26 +164,6 @@ class IOSEnhancements {
|
||||
);
|
||||
}
|
||||
|
||||
/// Create iOS-style context menu
|
||||
static Widget createContextMenu({
|
||||
required Widget child,
|
||||
required List<ContextMenuAction> actions,
|
||||
}) {
|
||||
return CupertinoContextMenu(
|
||||
actions: actions
|
||||
.map(
|
||||
(action) => CupertinoContextMenuAction(
|
||||
onPressed: action.onPressed,
|
||||
isDefaultAction: action.isDefault,
|
||||
isDestructiveAction: action.isDestructive,
|
||||
child: Text(action.title),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create iOS-style action sheet
|
||||
static void showActionSheet({
|
||||
required BuildContext context,
|
||||
@@ -459,20 +439,6 @@ enum ButtonType { filled, outlined, text }
|
||||
|
||||
enum CardType { filled, outlined, elevated }
|
||||
|
||||
class ContextMenuAction {
|
||||
final String title;
|
||||
final VoidCallback onPressed;
|
||||
final bool isDefault;
|
||||
final bool isDestructive;
|
||||
|
||||
const ContextMenuAction({
|
||||
required this.title,
|
||||
required this.onPressed,
|
||||
this.isDefault = false,
|
||||
this.isDestructive = false,
|
||||
});
|
||||
}
|
||||
|
||||
class ActionSheetAction {
|
||||
final String title;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
Reference in New Issue
Block a user