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:
cogwheel
2025-12-20 18:26:03 +05:30
parent 622dcf9142
commit 97ace86b12
13 changed files with 1032 additions and 724 deletions

View File

@@ -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()),

View File

@@ -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;