diff --git a/scribe/lib/scribe.dart b/scribe/lib/scribe.dart index fd54766cb..a9fed6417 100644 --- a/scribe/lib/scribe.dart +++ b/scribe/lib/scribe.dart @@ -46,3 +46,6 @@ export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_suc export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_list_actions.dart'; export 'scribe/ai/presentation/widgets/overlay/ai_selection_overlay.dart'; export 'scribe/ai/presentation/widgets/search/ai_scribe_bar.dart'; +export 'scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_bottom_sheet.dart'; +export 'scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_item.dart'; +export 'scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_suggestion_bottom_sheet.dart'; diff --git a/scribe/lib/scribe/ai/presentation/styles/ai_scribe_styles.dart b/scribe/lib/scribe/ai/presentation/styles/ai_scribe_styles.dart index 17b5bb6b0..3ec323d3a 100644 --- a/scribe/lib/scribe/ai/presentation/styles/ai_scribe_styles.dart +++ b/scribe/lib/scribe/ai/presentation/styles/ai_scribe_styles.dart @@ -181,4 +181,7 @@ abstract final class AIScribeSizes { static const EdgeInsetsGeometry sendIconPadding = EdgeInsetsDirectional.all(8); + + static const EdgeInsetsGeometry backIconPadding = + EdgeInsetsDirectional.only(end: 8.0, top: 8.0, bottom: 8.0); } diff --git a/scribe/lib/scribe/ai/presentation/utils/modal/ai_scribe_modal_manager.dart b/scribe/lib/scribe/ai/presentation/utils/modal/ai_scribe_modal_manager.dart index f17cd35be..f00891855 100644 --- a/scribe/lib/scribe/ai/presentation/utils/modal/ai_scribe_modal_manager.dart +++ b/scribe/lib/scribe/ai/presentation/utils/modal/ai_scribe_modal_manager.dart @@ -1,4 +1,5 @@ import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/utils/platform_info.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:scribe/scribe.dart'; @@ -16,21 +17,30 @@ class AiScribeModalManager { ModalPlacement? preferredPlacement, ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center, }) async { - final ContextSubmenuController submenuController = ContextSubmenuController(); + final AIAction? aiAction; - final aiAction = await Get.dialog( - AiScribeModalWidget( + if (PlatformInfo.isMobile) { + aiAction = await showMobileAIScribeMenuModal( imagePaths: imagePaths, - content: content, availableCategories: availableCategories, - buttonPosition: buttonPosition, - buttonSize: buttonSize, - preferredPlacement: preferredPlacement, - crossAxisAlignment: crossAxisAlignment, - submenuController: submenuController, - ), - barrierColor: AIScribeColors.dialogBarrier, - ).whenComplete(submenuController.dispose); + ); + } else { + final ContextSubmenuController submenuController = ContextSubmenuController(); + + aiAction = await Get.dialog( + AiScribeModalWidget( + imagePaths: imagePaths, + content: content, + availableCategories: availableCategories, + buttonPosition: buttonPosition, + buttonSize: buttonSize, + preferredPlacement: preferredPlacement, + crossAxisAlignment: crossAxisAlignment, + submenuController: submenuController, + ), + barrierColor: AIScribeColors.dialogBarrier, + ).whenComplete(submenuController.dispose); + } if (aiAction != null) { await showAIScribeSuggestionModal( @@ -56,18 +66,70 @@ class AiScribeModalManager { ModalPlacement? preferredPlacement, ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center, }) async { - await Get.dialog( - AiScribeSuggestionWidget( + if (PlatformInfo.isMobile) { + await showMobileAIScribeSuggestionModal( aiAction: aiAction, imagePaths: imagePaths, content: content, - buttonPosition: buttonPosition, - buttonSize: buttonSize, - preferredPlacement: preferredPlacement, - crossAxisAlignment: crossAxisAlignment, onSelectAiScribeSuggestionAction: onSelectAiScribeSuggestionAction, + ); + } else { + await Get.dialog( + AiScribeSuggestionWidget( + aiAction: aiAction, + imagePaths: imagePaths, + content: content, + buttonPosition: buttonPosition, + buttonSize: buttonSize, + preferredPlacement: preferredPlacement, + crossAxisAlignment: crossAxisAlignment, + onSelectAiScribeSuggestionAction: onSelectAiScribeSuggestionAction, + ), + barrierColor: AIScribeColors.dialogBarrier, + ); + } + } + + static Future showMobileAIScribeMenuModal({ + required ImagePaths imagePaths, + required List availableCategories, + }) async { + final context = Get.context; + + if (context == null) return null; + + return await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => AiScribeMobileActionsBottomSheet( + imagePaths: imagePaths, + availableCategories: availableCategories, + ), + ); + } + + static Future showMobileAIScribeSuggestionModal({ + required AIAction aiAction, + required ImagePaths imagePaths, + required OnSelectAiScribeSuggestionAction onSelectAiScribeSuggestionAction, + String? content, + }) async { + final context = Get.context; + + if (context == null) return; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + isDismissible: true, + builder: (context) => AiScribeMobileSuggestionBottomSheet( + aiAction: aiAction, + imagePaths: imagePaths, + content: content, + onSelectAction: onSelectAiScribeSuggestionAction, ), - barrierColor: AIScribeColors.dialogBarrier, ); } } diff --git a/scribe/lib/scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_bottom_sheet.dart b/scribe/lib/scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_bottom_sheet.dart new file mode 100644 index 000000000..c0a9dbaad --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_bottom_sheet.dart @@ -0,0 +1,206 @@ +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:flutter/material.dart'; +import 'package:scribe/scribe.dart'; + +class AiScribeMobileActionsBottomSheet extends StatefulWidget { + final ImagePaths imagePaths; + final List availableCategories; + + const AiScribeMobileActionsBottomSheet({ + super.key, + required this.imagePaths, + required this.availableCategories, + }); + + @override + State createState() => + _AiScribeMobileActionsBottomSheetState(); +} + +class _AiScribeMobileActionsBottomSheetState + extends State { + final ValueNotifier _selectedCategory = + ValueNotifier(null); + + void _onActionSelected(AiScribeContextMenuAction menuAction) { + if (menuAction is AiScribeActionContextMenuAction) { + Navigator.of(context).pop(PredefinedAction(menuAction.action)); + } + } + + void _onCustomPromptSubmit(String prompt) { + Navigator.of(context).pop(CustomPromptAction(prompt)); + } + + void _onCategorySelected(AiScribeCategoryContextMenuAction category) { + _selectedCategory.value = category; + } + + void _goBackToCategories() { + _selectedCategory.value = null; + } + + Widget _buildHeader(BuildContext context, ScribeLocalizations localizations) { + return Container( + padding: AIScribeSizes.suggestionHeaderPadding, + child: Row( + children: [ + ValueListenableBuilder( + valueListenable: _selectedCategory, + builder: (context, selectedCategory, _) { + return selectedCategory != null + ? GestureDetector( + onTap: _goBackToCategories, + child: Padding( + padding: AIScribeSizes.backIconPadding, + child: Icon( + Icons.chevron_left, + size: AIScribeSizes.aiAssistantIcon, + color: AppColor.gray424244.withValues(alpha: 0.72), + ), + ), + ) + : const SizedBox.shrink(); + }, + ), + Expanded( + child: ValueListenableBuilder( + valueListenable: _selectedCategory, + builder: (context, selectedCategory, _) { + return Text( + selectedCategory?.actionName ?? localizations.aiAssistant, + style: AIScribeTextStyles.suggestionTitle, + ); + }, + ), + ), + IconButton( + icon: const Icon( + Icons.close, + size: AIScribeSizes.aiAssistantIcon, + ), + color: AppColor.gray424244.withValues(alpha: 0.72), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + } + + Widget _buildMenuListView(List menuActions) { + return ListView.builder( + physics: const ClampingScrollPhysics(), + shrinkWrap: true, + itemCount: menuActions.length, + itemBuilder: (context, index) { + final menuAction = menuActions[index]; + return AiScribeMobileActionsItem( + menuAction: menuAction, + imagePaths: widget.imagePaths, + onCategorySelected: _onCategorySelected, + onActionSelected: _onActionSelected, + ); + }, + ); + } + + Widget _buildSubmenuListView() { + return ValueListenableBuilder( + valueListenable: _selectedCategory, + builder: (context, selectedCategory, _) { + return ListView.builder( + physics: const ClampingScrollPhysics(), + shrinkWrap: true, + itemCount: selectedCategory?.submenuActions?.length ?? 0, + itemBuilder: (context, index) { + final submenuAction = selectedCategory!.submenuActions![index]; + return AiScribeSubmenuItem( + menuAction: submenuAction, + onSelectAction: _onActionSelected, + ); + }, + ); + }, + ); + } + + Widget _buildBottomBar(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Divider( + height: 1, + thickness: 1, + color: AppColor.colorDividerComposer, + ), + Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 8.0, + left: 16.0, + right: 16.0, + top: 8.0, + ), + child: AIScribeBar( + onCustomPrompt: _onCustomPromptSubmit, + imagePaths: widget.imagePaths, + boxShadow: const [], + ), + ), + ], + ); + } + + @override + void dispose() { + _selectedCategory.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final localizations = ScribeLocalizations.of(context); + final menuActions = widget.availableCategories + .map((category) => AiScribeCategoryContextMenuAction( + category, + localizations, + widget.imagePaths, + )) + .toList(); + + return Container( + height: double.infinity, + decoration: const BoxDecoration( + color: AIScribeColors.background, + ), + child: SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(context, localizations), + Flexible( + child: ValueListenableBuilder( + valueListenable: _selectedCategory, + builder: (context, selectedCategory, _) { + return selectedCategory == null + ? _buildMenuListView(menuActions) + : _buildSubmenuListView(); + }, + ), + ), + ], + ), + ), + _buildBottomBar(context), + ], + ), + ), + ); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_item.dart b/scribe/lib/scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_item.dart new file mode 100644 index 000000000..2579f32c4 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_item.dart @@ -0,0 +1,52 @@ +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:flutter/material.dart'; +import 'package:scribe/scribe.dart'; + +class AiScribeMobileActionsItem extends StatelessWidget { + final AiScribeContextMenuAction menuAction; + final ImagePaths imagePaths; + final ValueChanged? onCategorySelected; + final ValueChanged onActionSelected; + + const AiScribeMobileActionsItem({ + super.key, + required this.menuAction, + required this.imagePaths, + this.onCategorySelected, + required this.onActionSelected, + }); + + @override + Widget build(BuildContext context) { + // When category + if (menuAction.hasSubmenu) { + return AiScribeMenuItem( + menuAction: menuAction, + imagePaths: imagePaths, + onSelectAction: (menuAction) { + if (menuAction is AiScribeCategoryContextMenuAction) { + onCategorySelected?.call(menuAction); + } else { + onActionSelected.call(menuAction); + } + } + ); + } + + // When action alongside category + final submenuActions = menuAction.submenuActions; + if (submenuActions?.length == 1) { + return AiScribeSubmenuItem( + menuAction: submenuActions!.first, + onSelectAction: onActionSelected, + ); + } + + // When action inside category + return AiScribeMenuItem( + menuAction: menuAction, + imagePaths: imagePaths, + onSelectAction: onActionSelected, + ); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_suggestion_bottom_sheet.dart b/scribe/lib/scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_suggestion_bottom_sheet.dart new file mode 100644 index 000000000..7a3a3528a --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_suggestion_bottom_sheet.dart @@ -0,0 +1,96 @@ +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:flutter/material.dart'; +import 'package:scribe/scribe.dart'; + +class AiScribeMobileSuggestionBottomSheet extends StatefulWidget { + final AIAction aiAction; + final ImagePaths imagePaths; + final String? content; + final OnSelectAiScribeSuggestionAction onSelectAction; + + const AiScribeMobileSuggestionBottomSheet({ + super.key, + required this.aiAction, + required this.imagePaths, + required this.onSelectAction, + this.content, + }); + + @override + State createState() => + _AiScribeMobileSuggestionBottomSheetState(); +} + +class _AiScribeMobileSuggestionBottomSheetState + extends State + with AiScribeSuggestionStateMixin { + @override + AIAction get aiAction => widget.aiAction; + + @override + String? get content => widget.content; + + @override + ImagePaths get imagePaths => widget.imagePaths; + + @override + OnSelectAiScribeSuggestionAction get onSelectAction => + widget.onSelectAction; + + @override + Widget build(BuildContext context) { + final localizations = ScribeLocalizations.of(context); + + return Container( + height: double.infinity, + decoration: const BoxDecoration( + color: AIScribeColors.background, + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: AIScribeSizes.suggestionHeaderPadding, + child: AiScribeSuggestionHeader( + title: aiAction.getLabel(localizations), + imagePaths: imagePaths, + ), + ), + Flexible( + child: buildStateContent(context), + ), + ], + ), + ), + ); + } + + @override + Widget buildLoadingState() { + return Padding( + padding: AIScribeSizes.suggestionContentPadding, + child: super.buildLoadingState(), + ); + } + + @override + Widget buildErrorState() { + return Padding( + padding: AIScribeSizes.suggestionContentPadding, + child: super.buildErrorState(), + ); + } + + @override + Widget buildSuccessState( + String suggestionText, + bool hasContent, + ) { + return Padding( + padding: AIScribeSizes.suggestionContentPadding, + child: super.buildSuccessState(suggestionText, hasContent), + ); + } +}