Add Scribe mobile

Same features than Scribe desktop but as a full screen modal instead of  a context menu.

Reuse menu and submenu components.
Reuse suggestion components.
Reuse 99% of Scribe desktop style.
This commit is contained in:
Théo Poizat
2026-01-12 08:55:10 +01:00
committed by Dat H. Pham
parent 245dfc714d
commit 14f2e557fa
6 changed files with 441 additions and 19 deletions
+3
View File
@@ -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/modal/suggestion/ai_scribe_suggestion_success_list_actions.dart';
export 'scribe/ai/presentation/widgets/overlay/ai_selection_overlay.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/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';
@@ -181,4 +181,7 @@ abstract final class AIScribeSizes {
static const EdgeInsetsGeometry sendIconPadding = static const EdgeInsetsGeometry sendIconPadding =
EdgeInsetsDirectional.all(8); EdgeInsetsDirectional.all(8);
static const EdgeInsetsGeometry backIconPadding =
EdgeInsetsDirectional.only(end: 8.0, top: 8.0, bottom: 8.0);
} }
@@ -1,4 +1,5 @@
import 'package:core/presentation/resources/image_paths.dart'; import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/utils/platform_info.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:scribe/scribe.dart'; import 'package:scribe/scribe.dart';
@@ -16,21 +17,30 @@ class AiScribeModalManager {
ModalPlacement? preferredPlacement, ModalPlacement? preferredPlacement,
ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center, ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center,
}) async { }) async {
final ContextSubmenuController submenuController = ContextSubmenuController(); final AIAction? aiAction;
final aiAction = await Get.dialog<AIAction>( if (PlatformInfo.isMobile) {
AiScribeModalWidget( aiAction = await showMobileAIScribeMenuModal(
imagePaths: imagePaths, imagePaths: imagePaths,
content: content,
availableCategories: availableCategories, availableCategories: availableCategories,
buttonPosition: buttonPosition, );
buttonSize: buttonSize, } else {
preferredPlacement: preferredPlacement, final ContextSubmenuController submenuController = ContextSubmenuController();
crossAxisAlignment: crossAxisAlignment,
submenuController: submenuController, aiAction = await Get.dialog<AIAction>(
), AiScribeModalWidget(
barrierColor: AIScribeColors.dialogBarrier, imagePaths: imagePaths,
).whenComplete(submenuController.dispose); content: content,
availableCategories: availableCategories,
buttonPosition: buttonPosition,
buttonSize: buttonSize,
preferredPlacement: preferredPlacement,
crossAxisAlignment: crossAxisAlignment,
submenuController: submenuController,
),
barrierColor: AIScribeColors.dialogBarrier,
).whenComplete(submenuController.dispose);
}
if (aiAction != null) { if (aiAction != null) {
await showAIScribeSuggestionModal( await showAIScribeSuggestionModal(
@@ -56,18 +66,70 @@ class AiScribeModalManager {
ModalPlacement? preferredPlacement, ModalPlacement? preferredPlacement,
ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center, ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center,
}) async { }) async {
await Get.dialog<String?>( if (PlatformInfo.isMobile) {
AiScribeSuggestionWidget( await showMobileAIScribeSuggestionModal(
aiAction: aiAction, aiAction: aiAction,
imagePaths: imagePaths, imagePaths: imagePaths,
content: content, content: content,
buttonPosition: buttonPosition,
buttonSize: buttonSize,
preferredPlacement: preferredPlacement,
crossAxisAlignment: crossAxisAlignment,
onSelectAiScribeSuggestionAction: onSelectAiScribeSuggestionAction, onSelectAiScribeSuggestionAction: onSelectAiScribeSuggestionAction,
);
} else {
await Get.dialog<String?>(
AiScribeSuggestionWidget(
aiAction: aiAction,
imagePaths: imagePaths,
content: content,
buttonPosition: buttonPosition,
buttonSize: buttonSize,
preferredPlacement: preferredPlacement,
crossAxisAlignment: crossAxisAlignment,
onSelectAiScribeSuggestionAction: onSelectAiScribeSuggestionAction,
),
barrierColor: AIScribeColors.dialogBarrier,
);
}
}
static Future<AIAction?> showMobileAIScribeMenuModal({
required ImagePaths imagePaths,
required List<AIScribeMenuCategory> availableCategories,
}) async {
final context = Get.context;
if (context == null) return null;
return await showModalBottomSheet<AIAction>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => AiScribeMobileActionsBottomSheet(
imagePaths: imagePaths,
availableCategories: availableCategories,
),
);
}
static Future<void> 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,
); );
} }
} }
@@ -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<AIScribeMenuCategory> availableCategories;
const AiScribeMobileActionsBottomSheet({
super.key,
required this.imagePaths,
required this.availableCategories,
});
@override
State<AiScribeMobileActionsBottomSheet> createState() =>
_AiScribeMobileActionsBottomSheetState();
}
class _AiScribeMobileActionsBottomSheetState
extends State<AiScribeMobileActionsBottomSheet> {
final ValueNotifier<AiScribeCategoryContextMenuAction?> _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<AiScribeCategoryContextMenuAction?>(
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<AiScribeCategoryContextMenuAction?>(
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<AiScribeCategoryContextMenuAction> 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<AiScribeCategoryContextMenuAction?>(
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<AiScribeCategoryContextMenuAction?>(
valueListenable: _selectedCategory,
builder: (context, selectedCategory, _) {
return selectedCategory == null
? _buildMenuListView(menuActions)
: _buildSubmenuListView();
},
),
),
],
),
),
_buildBottomBar(context),
],
),
),
);
}
}
@@ -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<AiScribeCategoryContextMenuAction>? onCategorySelected;
final ValueChanged<AiScribeContextMenuAction> 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,
);
}
}
@@ -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<AiScribeMobileSuggestionBottomSheet> createState() =>
_AiScribeMobileSuggestionBottomSheetState();
}
class _AiScribeMobileSuggestionBottomSheetState
extends State<AiScribeMobileSuggestionBottomSheet>
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),
);
}
}