diff --git a/lib/core/widget/adaptive_menu.dart b/lib/core/widget/adaptive_menu.dart new file mode 100644 index 00000000..b67435c9 --- /dev/null +++ b/lib/core/widget/adaptive_menu.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hiddify/utils/platform_utils.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:wolt_modal_sheet/wolt_modal_sheet.dart'; + +typedef AdaptiveMenuBuilder = Widget Function( + BuildContext context, + void Function() toggleVisibility, + Widget? child, +); + +class AdaptiveMenuItem { + AdaptiveMenuItem({ + required this.title, + this.icon, + this.onTap, + this.isSelected, + this.subItems, + }); + + final String title; + final IconData? icon; + final T Function()? onTap; + final bool? isSelected; + final List? subItems; + + (String, IconData?, T Function()?, bool?, List?) + _equality() => (title, icon, onTap, isSelected, subItems); + + @override + bool operator ==(covariant AdaptiveMenuItem other) { + if (identical(this, other)) return true; + return other._equality() == _equality(); + } + + @override + int get hashCode => _equality().hashCode; +} + +class AdaptiveMenu extends HookConsumerWidget { + const AdaptiveMenu({ + super.key, + required this.items, + required this.builder, + required this.child, + }); + + final Iterable items; + final AdaptiveMenuBuilder builder; + final Widget? child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (PlatformUtils.isDesktop) { + List buildMenuItems(Iterable scopeItems) { + final menuItems = []; + for (final item in scopeItems) { + if (item.subItems != null) { + final subItems = buildMenuItems(item.subItems!); + menuItems.add( + SubmenuButton( + menuChildren: subItems, + leadingIcon: item.icon != null ? Icon(item.icon) : null, + child: Text(item.title), + ), + ); + } else { + menuItems.add( + MenuItemButton( + leadingIcon: item.icon != null ? Icon(item.icon) : null, + onPressed: item.onTap, + child: Text(item.title), + ), + ); + } + } + return menuItems; + } + + return MenuAnchor( + builder: (context, controller, child) => builder( + context, + () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child, + ), + menuChildren: buildMenuItems(items), + child: child, + ); + } + + final pageIndexNotifier = useValueNotifier(0); + final nestedSheets = []; + int pageIndex = 0; + + void popSheets() { + if (context.mounted) { + Navigator.pop(context); + } + Future.delayed(const Duration(milliseconds: 200)) + .then((_) => pageIndexNotifier.value = 0); + } + + List buildSheetItems( + Iterable menuItems, + int index, + ) { + final sheetItems = []; + for (final item in menuItems) { + if (item.subItems != null) { + final subItems = buildSheetItems(item.subItems!, index + 1); + final subSheetIndex = ++pageIndex; + sheetItems.add( + ListTile( + title: Text(item.title), + leading: item.icon != null ? Icon(item.icon) : null, + trailing: const Icon(Icons.chevron_right), + onTap: () { + pageIndexNotifier.value = subSheetIndex; + }, + ), + ); + nestedSheets.add( + SliverWoltModalSheetPage( + hasTopBarLayer: false, + isTopBarLayerAlwaysVisible: true, + topBarTitle: Text(item.title), + mainContentSlivers: [ + SliverList.list(children: subItems), + ], + ), + ); + } else { + sheetItems.add( + ListTile( + title: Text(item.title), + leading: item.icon != null ? Icon(item.icon) : null, + onTap: () async { + popSheets(); + await item.onTap!(); + }, + ), + ); + } + } + return sheetItems; + } + + return builder( + context, + () async { + await WoltModalSheet.show( + context: context, + pageIndexNotifier: pageIndexNotifier, + onModalDismissedWithDrag: popSheets, + onModalDismissedWithBarrierTap: popSheets, + useSafeArea: true, + showDragHandle: false, + pageListBuilder: (context) => [ + SliverWoltModalSheetPage( + hasTopBarLayer: false, + mainContentSlivers: [ + SliverList.list(children: buildSheetItems(items, 0)), + ], + ), + ...nestedSheets, + ], + ); + }, + child, + ); + } +} diff --git a/lib/features/profile/widget/profile_tile.dart b/lib/features/profile/widget/profile_tile.dart index 6d7f6c4b..50215660 100644 --- a/lib/features/profile/widget/profile_tile.dart +++ b/lib/features/profile/widget/profile_tile.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import 'package:hiddify/core/localization/translations.dart'; import 'package:hiddify/core/model/failures.dart'; import 'package:hiddify/core/router/router.dart'; +import 'package:hiddify/core/widget/adaptive_menu.dart'; import 'package:hiddify/features/common/confirmation_dialogs.dart'; import 'package:hiddify/features/common/qr_code_dialog.dart'; import 'package:hiddify/features/profile/model/profile_entity.dart'; @@ -202,19 +203,13 @@ class ProfileActionButton extends HookConsumerWidget { } return ProfileActionsMenu( profile, - (context, controller, child) { + (context, toggleVisibility, _) { return Semantics( button: true, child: Tooltip( message: MaterialLocalizations.of(context).showMenuTooltip, child: InkWell( - onTap: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, + onTap: toggleVisibility, child: const Icon(Icons.more_vert), ), ), @@ -228,7 +223,7 @@ class ProfileActionsMenu extends HookConsumerWidget { const ProfileActionsMenu(this.profile, this.builder, {super.key, this.child}); final ProfileEntity profile; - final MenuAnchorChildBuilder builder; + final AdaptiveMenuBuilder builder; final Widget? child; @override @@ -249,97 +244,99 @@ class ProfileActionsMenu extends HookConsumerWidget { }, ); - return MenuAnchor( - builder: builder, - menuChildren: [ - if (profile case RemoteProfileEntity()) - MenuItemButton( - leadingIcon: const Icon(Icons.update), - child: Text(t.profile.update.buttonTxt), - onPressed: () { - if (ref.read(updateProfileProvider(profile.id)).isLoading) { - return; - } - ref - .read(updateProfileProvider(profile.id).notifier) - .updateProfile(profile as RemoteProfileEntity); - }, - ), - SubmenuButton( - menuChildren: [ - if (profile case RemoteProfileEntity(:final url, :final name)) ...[ - MenuItemButton( - child: Text(t.profile.share.exportSubLinkToClipboard), - onPressed: () async { - final link = LinkParser.generateSubShareLink(url, name); - if (link.isNotEmpty) { - await Clipboard.setData(ClipboardData(text: link)); - if (context.mounted) { - CustomToast(t.profile.share.exportToClipboardSuccess) - .show(context); - } + final menuItems = [ + if (profile case RemoteProfileEntity()) + AdaptiveMenuItem( + title: t.profile.update.buttonTxt, + icon: Icons.update, + onTap: () { + if (ref.read(updateProfileProvider(profile.id)).isLoading) { + return; + } + ref + .read(updateProfileProvider(profile.id).notifier) + .updateProfile(profile as RemoteProfileEntity); + }, + ), + AdaptiveMenuItem( + title: t.profile.share.buttonText, + icon: Icons.share, + subItems: [ + if (profile case RemoteProfileEntity(:final url, :final name)) ...[ + AdaptiveMenuItem( + title: t.profile.share.exportSubLinkToClipboard, + onTap: () async { + final link = LinkParser.generateSubShareLink(url, name); + if (link.isNotEmpty) { + await Clipboard.setData(ClipboardData(text: link)); + if (context.mounted) { + CustomToast(t.profile.share.exportToClipboardSuccess) + .show(context); } - }, - ), - MenuItemButton( - child: Text(t.profile.share.subLinkQrCode), - onPressed: () async { - final link = LinkParser.generateSubShareLink(url, name); - if (link.isNotEmpty) { - await QrCodeDialog( - link, - message: name, - ).show(context); - } - }, - ), - ], - MenuItemButton( - child: Text(t.profile.share.exportConfigToClipboard), - onPressed: () async { - if (exportConfigMutation.state.isInProgress) { - return; } - exportConfigMutation.setFuture( - ref - .read(profilesOverviewNotifierProvider.notifier) - .exportConfigToClipboard(profile), - ); + }, + ), + AdaptiveMenuItem( + title: t.profile.share.subLinkQrCode, + onTap: () async { + final link = LinkParser.generateSubShareLink(url, name); + if (link.isNotEmpty) { + await QrCodeDialog( + link, + message: name, + ).show(context); + } }, ), ], - leadingIcon: const Icon(Icons.share), - child: Text(t.profile.share.buttonText), - ), - MenuItemButton( - leadingIcon: const Icon(Icons.edit), - child: Text(t.profile.edit.buttonTxt), - onPressed: () async { - await ProfileDetailsRoute(profile.id).push(context); - }, - ), - MenuItemButton( - leadingIcon: const Icon(Icons.delete), - child: Text(t.profile.delete.buttonTxt), - onPressed: () async { - if (deleteProfileMutation.state.isInProgress) { - return; - } - final deleteConfirmed = await showConfirmationDialog( - context, - title: t.profile.delete.buttonTxt, - message: t.profile.delete.confirmationMsg, - ); - if (deleteConfirmed) { - deleteProfileMutation.setFuture( + AdaptiveMenuItem( + title: t.profile.share.exportConfigToClipboard, + onTap: () async { + if (exportConfigMutation.state.isInProgress) { + return; + } + exportConfigMutation.setFuture( ref .read(profilesOverviewNotifierProvider.notifier) - .deleteProfile(profile), + .exportConfigToClipboard(profile), ); - } - }, - ), - ], + }, + ), + ], + ), + AdaptiveMenuItem( + icon: Icons.edit, + title: t.profile.edit.buttonTxt, + onTap: () async { + await ProfileDetailsRoute(profile.id).push(context); + }, + ), + AdaptiveMenuItem( + icon: Icons.delete, + title: t.profile.delete.buttonTxt, + onTap: () async { + if (deleteProfileMutation.state.isInProgress) { + return; + } + final deleteConfirmed = await showConfirmationDialog( + context, + title: t.profile.delete.buttonTxt, + message: t.profile.delete.confirmationMsg, + ); + if (deleteConfirmed) { + deleteProfileMutation.setFuture( + ref + .read(profilesOverviewNotifierProvider.notifier) + .deleteProfile(profile), + ); + } + }, + ), + ]; + + return AdaptiveMenu( + builder: builder, + items: menuItems, child: child, ); } diff --git a/pubspec.lock b/pubspec.lock index cc8e7dbb..5b62fb7d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -486,6 +486,54 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.4" + flutter_keyboard_visibility: + dependency: transitive + description: + name: flutter_keyboard_visibility + sha256: "98664be7be0e3ffca00de50f7f6a287ab62c763fc8c762e0a21584584a3ff4f8" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_keyboard_visibility_linux: + dependency: transitive + description: + name: flutter_keyboard_visibility_linux + sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_macos: + dependency: transitive + description: + name: flutter_keyboard_visibility_macos + sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_platform_interface: + dependency: transitive + description: + name: flutter_keyboard_visibility_platform_interface + sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_web: + dependency: transitive + description: + name: flutter_keyboard_visibility_web + sha256: d3771a2e752880c79203f8d80658401d0c998e4183edca05a149f5098ce6e3d1 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_windows: + dependency: transitive + description: + name: flutter_keyboard_visibility_windows + sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73 + url: "https://pub.dev" + source: hosted + version: "1.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -1642,6 +1690,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.7" + wolt_modal_sheet: + dependency: "direct main" + description: + name: wolt_modal_sheet + sha256: "5dcf57ac13bf2614a38ea3e51ed9ca1883bc2368d0e19b8c0cd53bb1467ffeea" + url: "https://pub.dev" + source: hosted + version: "0.3.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8464dc79..9805262f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -70,6 +70,7 @@ dependencies: flutter_loggy_dio: ^3.0.1 dio_smart_retry: ^6.0.0 cupertino_http: ^1.2.0 + wolt_modal_sheet: ^0.3.0 dev_dependencies: flutter_test: