feat(ai-scribe): reorganize abstractions and update dialog modal UI

This commit is contained in:
dab246
2025-12-18 02:51:43 +07:00
committed by Dat H. Pham
parent 7ddccf2e1c
commit 582b1ebe97
70 changed files with 2449 additions and 1352 deletions
+17 -9
View File
@@ -1,14 +1,22 @@
library scribe;
export 'scribe/ai/domain/usecases/generate_ai_text_interactor.dart';
export 'scribe/ai/domain/repository/ai_scribe_repository.dart';
export 'scribe/ai/domain/model/ai_response.dart';
export 'scribe/ai/domain/state/generate_ai_text_state.dart';
export 'scribe/ai/data/repository/ai_repository_impl.dart';
export 'scribe/ai/data/datasource/ai_datasource.dart';
export 'scribe/ai/data/repository/ai_repository_impl.dart';
export 'scribe/ai/domain/model/ai_response.dart';
export 'scribe/ai/domain/repository/ai_scribe_repository.dart';
export 'scribe/ai/domain/state/generate_ai_text_state.dart';
export 'scribe/ai/domain/usecases/generate_ai_text_interactor.dart';
export 'scribe/ai/presentation/model/ai_action.dart';
export 'scribe/ai/presentation/model/ai_scribe_menu_action.dart';
export 'scribe/ai/presentation/model/ai_capability.dart';
export 'scribe/ai/presentation/utils/ai_scribe_constants.dart';
export 'scribe/ai/presentation/model/ai_scribe_menu_action.dart';
export 'scribe/ai/presentation/model/modal/anchored_modal_layout_input.dart';
export 'scribe/ai/presentation/model/modal/anchored_modal_layout_result.dart';
export 'scribe/ai/presentation/model/modal/modal_placement.dart';
export 'scribe/ai/presentation/model/text_selection_model.dart';
export 'scribe/ai/presentation/utils/ai_scribe_constants.dart';
export 'scribe/ai/presentation/utils/modal/ai_scribe_modal_manager.dart';
export 'scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart';
export 'scribe/ai/presentation/widgets/button/ai_assistant_button.dart';
export 'scribe/ai/presentation/widgets/button/inline_ai_assist_button.dart';
export 'scribe/ai/presentation/widgets/context_menu/ai_scribe_context_menu.dart';
export 'scribe/ai/presentation/widgets/overlay/ai_selection_overlay.dart';
@@ -1,4 +1,5 @@
import 'package:core/core.dart';
import 'package:core/presentation/state/failure.dart';
import 'package:core/presentation/state/success.dart';
import 'package:dartz/dartz.dart';
import 'package:scribe/scribe/ai/domain/constants/ai_prompts.dart';
import 'package:scribe/scribe/ai/domain/repository/ai_scribe_repository.dart';
+25 -1
View File
@@ -1,5 +1,5 @@
{
"@@last_modified": "2025-12-16T18:07:47.039649",
"@@last_modified": "2025-12-18T02:49:48.638213",
"categoryCorrectGrammar": "Correct grammar",
"@categoryCorrectGrammar": {
"type": "text",
@@ -113,5 +113,29 @@
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"aiAssistant": "AI assistant",
"@aiAssistant": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"generatingResponse": "Generating response",
"@generatingResponse": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"replace": "Replace",
"@replace": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"insert": "Insert",
"@insert": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
}
}
@@ -3,13 +3,13 @@ import 'package:intl/intl.dart';
import 'package:scribe/scribe/ai/l10n/messages_all.dart';
class ScribeLocalizations {
static ScribeLocalizations? of(BuildContext context) {
return Localizations.of<ScribeLocalizations>(context, ScribeLocalizations);
static ScribeLocalizations of(BuildContext context) {
return Localizations.of<ScribeLocalizations>(context, ScribeLocalizations)!;
}
static Future<ScribeLocalizations> load(Locale locale) async {
final name = locale.countryCode == null ? locale.languageCode : locale.toString();
final name =
locale.countryCode == null ? locale.languageCode : locale.toString();
final localeName = Intl.canonicalizedLocale(name);
@@ -19,118 +19,188 @@ class ScribeLocalizations {
});
}
static const LocalizationsDelegate<ScribeLocalizations> delegate = _ScribeLocalizationsDelegate();
static const LocalizationsDelegate<ScribeLocalizations> delegate =
_ScribeLocalizationsDelegate();
// Menu Categories
String get categoryCorrectGrammar {
return Intl.message('Correct grammar',
name: 'categoryCorrectGrammar');
return Intl.message(
'Correct grammar',
name: 'categoryCorrectGrammar',
);
}
String get categoryImprove {
return Intl.message('Improve',
name: 'categoryImprove');
return Intl.message(
'Improve',
name: 'categoryImprove',
);
}
String get categoryChangeTone {
return Intl.message('Change tone',
name: 'categoryChangeTone');
return Intl.message(
'Change tone',
name: 'categoryChangeTone',
);
}
String get categoryTranslate {
return Intl.message('Translate',
name: 'categoryTranslate');
return Intl.message(
'Translate',
name: 'categoryTranslate',
);
}
// Menu Actions - Improve
String get actionMakeShorter {
return Intl.message('Make it shorter',
name: 'actionMakeShorter');
return Intl.message(
'Make it shorter',
name: 'actionMakeShorter',
);
}
String get actionExpandContext {
return Intl.message('Expand context',
name: 'actionExpandContext');
return Intl.message(
'Expand context',
name: 'actionExpandContext',
);
}
String get actionEmojify {
return Intl.message('Emojify',
name: 'actionEmojify');
return Intl.message(
'Emojify',
name: 'actionEmojify',
);
}
String get actionTransformToBullets {
return Intl.message('Transform to bullets',
name: 'actionTransformToBullets');
return Intl.message(
'Transform to bullets',
name: 'actionTransformToBullets',
);
}
// Menu Actions - Change Tone
String get actionMoreProfessional {
return Intl.message('More professional',
name: 'actionMoreProfessional');
return Intl.message(
'More professional',
name: 'actionMoreProfessional',
);
}
String get actionMoreCasual {
return Intl.message('More casual',
name: 'actionMoreCasual');
return Intl.message(
'More casual',
name: 'actionMoreCasual',
);
}
String get actionMorePolite {
return Intl.message('More polite',
name: 'actionMorePolite');
return Intl.message(
'More polite',
name: 'actionMorePolite',
);
}
// Menu Actions - Translate
String get languageFrench {
return Intl.message('French',
name: 'languageFrench');
return Intl.message(
'French',
name: 'languageFrench',
);
}
String get languageEnglish {
return Intl.message('English',
name: 'languageEnglish');
return Intl.message(
'English',
name: 'languageEnglish',
);
}
String get languageRussian {
return Intl.message('Russian',
name: 'languageRussian');
return Intl.message(
'Russian',
name: 'languageRussian',
);
}
String get languageVietnamese {
return Intl.message('Vietnamese',
name: 'languageVietnamese');
return Intl.message(
'Vietnamese',
name: 'languageVietnamese',
);
}
// Input Bar
String get inputPlaceholder {
return Intl.message('Help me write',
name: 'inputPlaceholder');
return Intl.message(
'Help me write',
name: 'inputPlaceholder',
);
}
String get customPromptAction {
return Intl.message('Help me write',
name: 'customPromptAction');
return Intl.message(
'Help me write',
name: 'customPromptAction',
);
}
// Suggestion Dialog
String get failedToGenerate {
return Intl.message('Failed to generate AI response',
name: 'failedToGenerate');
return Intl.message(
'Failed to generate AI response',
name: 'failedToGenerate',
);
}
String get insertButton {
return Intl.message('Insert',
name: 'insertButton');
return Intl.message(
'Insert',
name: 'insertButton',
);
}
String get aiAssistant {
return Intl.message(
'AI assistant',
name: 'aiAssistant',
);
}
String get generatingResponse {
return Intl.message(
'Generating response',
name: 'generatingResponse',
);
}
String get replace {
return Intl.message(
'Replace',
name: 'replace',
);
}
String get insert {
return Intl.message(
'Insert',
name: 'insert',
);
}
}
class _ScribeLocalizationsDelegate extends LocalizationsDelegate<ScribeLocalizations> {
class _ScribeLocalizationsDelegate
extends LocalizationsDelegate<ScribeLocalizations> {
const _ScribeLocalizationsDelegate();
@override
bool isSupported(Locale locale) => ['en', 'fr', 'ru', 'vi'].contains(locale.languageCode);
bool isSupported(Locale locale) =>
['en', 'fr', 'ru', 'vi'].contains(locale.languageCode);
@override
Future<ScribeLocalizations> load(Locale locale) => ScribeLocalizations.load(locale);
Future<ScribeLocalizations> load(Locale locale) =>
ScribeLocalizations.load(locale);
@override
bool shouldReload(_ScribeLocalizationsDelegate old) => false;
@@ -1,11 +1,10 @@
import 'package:flutter/material.dart';
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
sealed class AIAction {
const AIAction();
String getLabel(BuildContext context);
String getLabel(ScribeLocalizations localizations);
}
class PredefinedAction extends AIAction {
@@ -14,7 +13,8 @@ class PredefinedAction extends AIAction {
const PredefinedAction(this.action);
@override
String getLabel(BuildContext context) => action.getFullLabel(context);
String getLabel(ScribeLocalizations localizations) =>
action.getFullLabel(localizations);
}
class CustomPromptAction extends AIAction {
@@ -23,7 +23,7 @@ class CustomPromptAction extends AIAction {
const CustomPromptAction(this.prompt);
@override
String getLabel(BuildContext context) {
return ScribeLocalizations.of(context)?.customPromptAction ?? 'Help me write';
String getLabel(ScribeLocalizations localizations) {
return localizations.customPromptAction;
}
}
@@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
enum AIScribeMenuAction {
@@ -15,8 +15,7 @@ enum AIScribeMenuAction {
translateRussian,
translateVietnamese;
String getLabel(BuildContext context) {
final localizations = ScribeLocalizations.of(context)!;
String getLabel(ScribeLocalizations localizations) {
switch (this) {
case AIScribeMenuAction.correctGrammar:
return localizations.categoryCorrectGrammar;
@@ -66,24 +65,44 @@ enum AIScribeMenuAction {
}
}
String getFullLabel(BuildContext context) {
final categoryLabel = category.getLabel(context);
String getFullLabel(ScribeLocalizations localizations) {
final categoryLabel = category.getLabel(localizations);
if (category.hasSubmenu) {
return '$categoryLabel > ${getLabel(context)}';
return '$categoryLabel > ${getLabel(localizations)}';
} else {
return getLabel(context);
return getLabel(localizations);
}
}
String? getIcon(ImagePaths imagePaths) {
switch (this) {
case AIScribeMenuAction.improveMakeShorter:
return imagePaths.icAiShorter;
case AIScribeMenuAction.improveExpandContext:
return imagePaths.icAiMoreDetail;
case AIScribeMenuAction.improveEmojify:
return imagePaths.icAiEmojify;
case AIScribeMenuAction.improveTransformToBullets:
return imagePaths.icAiBullets;
case AIScribeMenuAction.changeToneProfessional:
return imagePaths.icAiMoreProfessional;
case AIScribeMenuAction.changeToneCasual:
return imagePaths.icAiMoreCasual;
case AIScribeMenuAction.changeTonePolite:
return imagePaths.icAiMorePolite;
default:
return null;
}
}
}
enum AIScribeMenuCategory {
correctGrammar,
improve,
translate,
changeTone,
translate;
improve;
String getLabel(BuildContext context) {
final localizations = ScribeLocalizations.of(context)!;
String getLabel(ScribeLocalizations localizations) {
switch (this) {
case AIScribeMenuCategory.correctGrammar:
return localizations.categoryCorrectGrammar;
@@ -96,6 +115,19 @@ enum AIScribeMenuCategory {
}
}
String getIcon(ImagePaths imagePaths) {
switch (this) {
case AIScribeMenuCategory.correctGrammar:
return imagePaths.icAiGrammar;
case AIScribeMenuCategory.improve:
return imagePaths.icAiImprove;
case AIScribeMenuCategory.changeTone:
return imagePaths.icAiChangeTons;
case AIScribeMenuCategory.translate:
return imagePaths.icAiTranslate;
}
}
List<AIScribeMenuAction> get actions {
switch (this) {
case AIScribeMenuCategory.correctGrammar:
@@ -0,0 +1,28 @@
import 'package:core/presentation/resources/image_paths.dart';
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart';
class AiScribeActionContextMenuAction
extends AiScribeContextMenuAction<AIScribeMenuAction> {
AiScribeActionContextMenuAction(
super.action,
this.localizations,
this.imagePaths,
);
final ScribeLocalizations localizations;
final ImagePaths imagePaths;
@override
String get actionName => action.getLabel(localizations);
@override
String? get actionIcon => action.getIcon(imagePaths);
@override
bool get hasSubmenu => false;
@override
List<AiScribeContextMenuAction>? get submenuActions => null;
}
@@ -0,0 +1,35 @@
import 'package:core/presentation/resources/image_paths.dart';
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_action_context_menu_action.dart';
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart';
class AiScribeCategoryContextMenuAction
extends AiScribeContextMenuAction<AIScribeMenuCategory> {
AiScribeCategoryContextMenuAction(
super.action,
this.localizations,
this.imagePaths,
);
final ScribeLocalizations localizations;
final ImagePaths imagePaths;
@override
String get actionName => action.getLabel(localizations);
@override
String? get actionIcon => action.getIcon(imagePaths);
@override
bool get hasSubmenu => action.hasSubmenu;
@override
List<AiScribeContextMenuAction>? get submenuActions => action.actions
.map((action) => AiScribeActionContextMenuAction(
action,
localizations,
imagePaths,
))
.toList();
}
@@ -0,0 +1,13 @@
abstract class AiScribeContextMenuAction<T> {
final T action;
AiScribeContextMenuAction(this.action);
String get actionName;
String? get actionIcon;
bool get hasSubmenu;
List<AiScribeContextMenuAction>? get submenuActions;
}
@@ -0,0 +1,21 @@
import 'package:flutter/animation.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/modal_cross_axis_alignment.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/modal_placement.dart';
class AnchoredModalLayoutInput {
final Size screenSize;
final Offset anchorPosition;
final Size anchorSize;
final Size menuSize;
final ModalPlacement? preferredPlacement;
final ModalCrossAxisAlignment crossAxisAlignment;
const AnchoredModalLayoutInput({
required this.screenSize,
required this.anchorPosition,
required this.anchorSize,
required this.menuSize,
this.preferredPlacement,
this.crossAxisAlignment = ModalCrossAxisAlignment.center,
});
}
@@ -0,0 +1,12 @@
import 'package:flutter/animation.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/modal_placement.dart';
class AnchoredModalLayoutResult {
final Offset position;
final ModalPlacement placement;
const AnchoredModalLayoutResult({
required this.position,
required this.placement,
});
}
@@ -0,0 +1,5 @@
enum ModalCrossAxisAlignment {
start,
center,
end,
}
@@ -0,0 +1,6 @@
enum ModalPlacement {
right,
bottom,
top,
left
}
@@ -0,0 +1,13 @@
import 'package:flutter/cupertino.dart';
class TextSelectionModel {
final String? selectedText;
final Offset? coordinates;
const TextSelectionModel({
this.selectedText,
this.coordinates,
});
bool get hasSelection => selectedText != null && selectedText!.isNotEmpty;
}
@@ -1,82 +1,181 @@
import 'package:flutter/material.dart';
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/utils/theme_utils.dart';
import 'package:flutter/material.dart';
class AIScribeColors {
static const textPrimary = AppColor.textPrimary;
static const background = Colors.white;
abstract final class AIScribeColors {
// Backgrounds
static const Color background = Colors.white;
static const Color mainActionButtonBackground = Color(0xFFD2E9FF);
static const Color sendPromptBackground = AppColor.blue700;
static const Color sendPromptBackgroundDisabled = Color(0xFFD2E9FF);
// Icons
static const Color scribeIcon = AppColor.primaryMain;
static const Color aiAssistantIcon = AppColor.primaryMain;
// Overlays
static final Color dialogBarrier = Colors.black.withValues(alpha: 0.12);
}
class AIScribeShadows {
static List<BoxShadow> get elevation8 => [
abstract final class AIScribeShadows {
static final List<BoxShadow> sparkleIcon = [
BoxShadow(
color: Colors.black.withValues(alpha: 0.16),
blurRadius: 12,
offset: const Offset(0, 2),
color: AppColor.gray424244.withValues(alpha: 0.08),
blurRadius: 3,
offset: const Offset(0, 1.5),
),
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 4,
offset: const Offset(0, 1),
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 12,
offset: const Offset(0, 3),
),
];
static final List<BoxShadow> modal = [
BoxShadow(
color: AppColor.gray424244.withValues(alpha: 0.12),
spreadRadius: 0.5,
),
BoxShadow(
color: AppColor.gray424244.withValues(alpha: 0.11),
spreadRadius: 2,
blurRadius: 26,
offset: const Offset(0, 6),
),
];
}
class AIScribeTextStyles {
static TextStyle menuItem = ThemeUtils.textStyleBodyBody3(color: AIScribeColors.textPrimary);
static TextStyle menuHint = ThemeUtils.textStyleBodyBody3(color: AIScribeColors.textPrimary.withValues(alpha: 0.6));
static TextStyle suggestionTitle = ThemeUtils.textStyleInter700();
static TextStyle suggestionContent = ThemeUtils.textStyleBodyBody3(color: AIScribeColors.textPrimary);
abstract final class AIScribeTextStyles {
static final TextStyle menuItem = ThemeUtils.textStyleInter400.copyWith(
fontSize: 14,
height: 21.01 / 14,
letterSpacing: -0.15,
color: AppColor.gray424244.withValues(alpha: 0.9),
);
static final TextStyle searchBarHint =
ThemeUtils.textStyleInter500().copyWith(
fontSize: 14,
height: 22 / 14,
letterSpacing: 0.4,
color: AppColor.gray9B9B9B.withValues(alpha: 0.85),
);
static final TextStyle searchBar = ThemeUtils.textStyleInter400.copyWith(
fontSize: 14,
height: 24 / 14,
letterSpacing: 0.4,
color: Colors.black.withValues(alpha: 0.85),
);
static final TextStyle suggestionTitle = ThemeUtils.textStyleInter700().copyWith(
fontSize: 14,
height: 22 / 14,
letterSpacing: 0.4,
color: AppColor.black1A1A1A.withValues(alpha: 0.85),
);
static final TextStyle suggestionLoading = ThemeUtils.textStyleInter400.copyWith(
fontSize: 14,
height: 22 / 14,
letterSpacing: 0.4,
color: AppColor.black1A1A1A.withValues(alpha: 0.85),
);
static final TextStyle suggestionContent = ThemeUtils.textStyleInter400.copyWith(
fontSize: 14,
height: 22 / 14,
letterSpacing: 0.4,
color: Colors.black.withValues(alpha: 0.85),
);
static final TextStyle mainActionButton =
ThemeUtils.textStyleInter500().copyWith(
color: AppColor.blue700,
);
}
class AIScribeButtonStyles {
static TextStyle mainActionButtonText = ThemeUtils.textStyleInter500().copyWith(color: AppColor.blue700);
static const Color mainActionButtonBackgroundColor = Color(0xFFD2E9FF);
static const EdgeInsetsGeometry mainActionButtonPadding = EdgeInsetsDirectional.symmetric(vertical: 8, horizontal: 16);
static const Color sendCustomPromptBackgroundColor = AppColor.blue700;
static const Color sendCustomPromptBackgroundColorDisabled = Color(0xFFD2E9FF);
}
class AIScribeSizes {
abstract final class AIScribeSizes {
// Border radius
static const double menuBorderRadius = 12.0;
static const double menuItemBorderRadius = 6.0;
static const double scribeButtonBorderRadius = 100.0;
static const double menuRadius = 6;
static const double menuItemRadius = 6;
static const double searchBarRadius = 10;
static const double scribeButtonRadius = 100;
static const double aiAssistantIconRadius = 8;
// Dialog dimensions
static const double menuWidth = 200.0;
static const double barWidth = 440.0;
static const double modalMaxHeight = 400.0;
static const double modalMaxWidthLargeScreen = 500.0;
static const double mobileWidthPercentage = 0.9;
static const double mobileBreakpoint = 600.0;
static const double infoHeight = 120.0;
// Width / height
static const double menuItemHeight = 40;
static const double searchBarMinHeight = 48;
static const double searchBarMaxHeight = 100;
static const double searchBarWidth = 405;
// Heights
static const double menuItemHeight = 40.0;
static const double barHeight = 48.0;
static const double submenuMaxHeight = 300.0;
static const double submenuWidth = 191;
static const double submenuMaxHeight = 352;
static const double contextMenuWidth = 191;
static const double contextMenuHeight = 352;
static const double modalMaxHeight = 256;
static const double modalMaxWidth = 405;
static const double suggestionModalMaxWidth = 482;
static const double suggestionModalMinHeight = 96;
static const double suggestionModalMaxHeight = 587;
static const double infoHeight = 120;
// Breakpoints
static const double mobileBreakpoint = 600;
static const double mobileFactor = 0.9;
// Spacing
static const double screenEdgePadding = 16.0;
static const double submenuSpacing = 6.0;
static const double fieldSpacing = 8.0;
static const double screenEdgePadding = 16;
static const double fieldSpacing = 8;
static const double submenuSpacing = 6;
static const double modalSpacing = 26;
static const double modalWithoutContentSpacing = 12;
// Elevation
static const double dialogElevation = 8.0;
static const double dialogElevation = 8;
// Icon sizes
static const double iconSize = 18.0;
static const double sendIconSize = 16.0;
static const double scribeIconSize = 12.0;
static const double icon = 18;
static const double sendIcon = 16;
static const double scribeIcon = 12;
static const double aiAssistantIcon = 24;
// Padding (using EdgeInsets)
static const EdgeInsetsGeometry menuItemPadding = EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 10);
static const EdgeInsetsGeometry barPadding = EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 8);
static const EdgeInsetsGeometry suggestionContentPadding = EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 8);
static const EdgeInsetsGeometry suggestionInfoPadding = EdgeInsets.only(bottom: 16);
static const EdgeInsetsGeometry suggestionHeaderPadding = EdgeInsets.fromLTRB(16, 8, 8, 8);
static const EdgeInsetsGeometry suggestionFooterPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 16);
static const EdgeInsetsGeometry scribeButtonPadding = EdgeInsets.all(6);
// Padding
static const EdgeInsetsGeometry menuItemPadding =
EdgeInsetsDirectional.only(start: 16, end: 10);
static const EdgeInsetsGeometry menuCategoryItemPadding =
EdgeInsetsDirectional.symmetric(horizontal: 14);
static const EdgeInsetsGeometry searchBarPadding =
EdgeInsetsDirectional.symmetric(horizontal: 16);
static const EdgeInsetsGeometry suggestionContentPadding =
EdgeInsetsDirectional.all(16);
static const EdgeInsetsGeometry suggestionInfoPadding =
EdgeInsetsGeometry.only(bottom: 16);
static const EdgeInsetsGeometry suggestionHeaderPadding =
EdgeInsetsGeometry.fromLTRB(16, 8, 8, 8);
static const EdgeInsetsGeometry suggestionFooterPadding =
EdgeInsetsGeometry.symmetric(horizontal: 16, vertical: 16);
static const EdgeInsetsGeometry scribeButtonPadding =
EdgeInsetsGeometry.all(6);
static const EdgeInsetsGeometry mainActionButtonPadding =
EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 8);
static const EdgeInsetsGeometry contextMenuPadding =
EdgeInsetsDirectional.symmetric(vertical: 8);
static const EdgeInsetsGeometry aiAssistantIconPadding =
EdgeInsetsDirectional.all(5);
static const EdgeInsetsGeometry sendIconPadding =
EdgeInsetsDirectional.all(8);
}
@@ -0,0 +1,44 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
typedef OnHoverShowSubmenu = void Function(GlobalKey key);
class HoverSubmenuController {
HoverSubmenuController({
this.exitDelay = const Duration(milliseconds: 120),
});
final Duration exitDelay;
final ValueNotifier<bool> isHovering = ValueNotifier(false);
int _hoverRefCount = 0;
Timer? _exitTimer;
void enter() {
_exitTimer?.cancel();
_hoverRefCount++;
if (!isHovering.value) {
isHovering.value = true;
}
}
void exit() {
_hoverRefCount = (_hoverRefCount - 1).clamp(0, 999);
if (_hoverRefCount == 0) {
_exitTimer?.cancel();
_exitTimer = Timer(exitDelay, () {
if (_hoverRefCount == 0) {
isHovering.value = false;
}
});
}
}
void dispose() {
_exitTimer?.cancel();
isHovering.dispose();
}
}
@@ -0,0 +1,90 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
enum SubmenuDirection { left, right, auto }
class PopupSubmenuController {
OverlayEntry? _submenuEntry;
bool get isShowing => _submenuEntry != null;
void show({
required BuildContext context,
required Rect anchor,
required Rect anchorMenu,
required Widget submenu,
SubmenuDirection direction = SubmenuDirection.auto,
double submenuWidth = 191,
double submenuMaxHeight = 352,
double offset = 4,
double menuFieldSpacing = 8,
}) {
hide();
final overlayState = Overlay.maybeOf(context, rootOverlay: true);
if (overlayState == null) return;
final mediaSize = MediaQuery.sizeOf(context);
final screenWidth = mediaSize.width;
final screenHeight = mediaSize.height;
final rightPosition = anchor.right + offset;
final leftPosition = anchor.left - submenuWidth - offset;
bool canShowRight = rightPosition + submenuWidth <= screenWidth;
bool canShowLeft = leftPosition >= 0;
double? finalLeft;
if (direction == SubmenuDirection.right) {
finalLeft = rightPosition;
} else if (direction == SubmenuDirection.left) {
finalLeft = leftPosition;
} else {
if (canShowRight) {
finalLeft = rightPosition;
} else if (canShowLeft) {
finalLeft = leftPosition;
} else {
finalLeft = rightPosition;
}
}
final bottom = screenHeight - anchorMenu.bottom + menuFieldSpacing;
final clampedLeft = finalLeft
.clamp(0.0, math.max(0.0, screenWidth - submenuWidth))
.toDouble();
final availableHeight = math.max(0.0, screenHeight - anchor.top);
final finalHeight = math.min(submenuMaxHeight, availableHeight);
_submenuEntry = OverlayEntry(
builder: (_) {
return PositionedDirectional(
start: clampedLeft,
bottom: bottom,
child: MouseRegion(
onExit: (_) => hide(),
child: Container(
width: submenuWidth,
constraints: BoxConstraints(maxHeight: finalHeight),
child: submenu,
),
),
);
},
);
overlayState.insert(_submenuEntry!);
}
void hide() {
_submenuEntry?.remove();
_submenuEntry = null;
}
void dispose() {
hide();
}
}
@@ -0,0 +1,72 @@
import 'package:core/presentation/resources/image_paths.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:scribe/scribe/ai/presentation/model/ai_action.dart';
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/modal_cross_axis_alignment.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/modal_placement.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
import 'package:scribe/scribe/ai/presentation/utils/context_menu/popup_submenu_controller.dart';
import 'package:scribe/scribe/ai/presentation/widgets/modal/ai_scribe_modal_widget.dart';
import 'package:scribe/scribe/ai/presentation/widgets/modal/ai_scribe_suggestion_widget.dart';
class AiScribeModalManager {
AiScribeModalManager._();
static Future<String?> showAIScribeMenuModal({
required ImagePaths imagePaths,
required List<AIScribeMenuCategory> availableCategories,
String? content,
Offset? buttonPosition,
Size? buttonSize,
ModalPlacement? preferredPlacement,
ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center,
}) async {
final PopupSubmenuController submenuController = PopupSubmenuController();
final aiAction = await Get.dialog<AIAction>(
AiScribeModalWidget(
imagePaths: imagePaths,
content: content,
availableCategories: availableCategories,
buttonPosition: buttonPosition,
buttonSize: buttonSize,
preferredPlacement: preferredPlacement,
crossAxisAlignment: crossAxisAlignment,
submenuController: submenuController,
),
barrierColor: AIScribeColors.dialogBarrier,
).whenComplete(submenuController.hide);
if (aiAction != null) {
return await showAIScribeSuggestionModal(
aiAction: aiAction,
imagePaths: imagePaths,
content: content,
buttonPosition: buttonPosition,
buttonSize: buttonSize,
);
}
return null;
}
static Future<String?> showAIScribeSuggestionModal({
required AIAction aiAction,
required ImagePaths imagePaths,
String? content,
Offset? buttonPosition,
Size? buttonSize,
}) {
return Get.dialog<String?>(
AiScribeSuggestionWidget(
aiAction: aiAction,
imagePaths: imagePaths,
content: content,
buttonPosition: buttonPosition,
buttonSize: buttonSize,
),
barrierColor: AIScribeColors.dialogBarrier,
);
}
}
@@ -0,0 +1,144 @@
import 'package:flutter/animation.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/anchored_modal_layout_input.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/anchored_modal_layout_result.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/modal_cross_axis_alignment.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/modal_placement.dart';
class AnchoredModalLayoutCalculator {
static AnchoredModalLayoutResult calculate({
required AnchoredModalLayoutInput input,
double padding = 8,
double gap = 8,
double verticalOffset = 6,
}) {
final screen = input.screenSize;
final anchor = input.anchorPosition;
final size = input.anchorSize;
final menu = input.menuSize;
final fallbackOrder = [
ModalPlacement.right,
ModalPlacement.bottom,
ModalPlacement.top,
ModalPlacement.left,
];
late ModalPlacement placement;
if (input.preferredPlacement != null &&
_canPlace(
placement: input.preferredPlacement!,
screen: screen,
anchor: anchor,
anchorSize: size,
menu: menu,
gap: gap,
)) {
placement = input.preferredPlacement!;
} else {
placement = fallbackOrder.firstWhere(
(p) => _canPlace(
placement: p,
screen: screen,
anchor: anchor,
anchorSize: size,
menu: menu,
gap: gap,
),
orElse: () => ModalPlacement.left,
);
}
late double left;
late double top;
switch (placement) {
case ModalPlacement.right:
left = anchor.dx + size.width + gap;
top = _resolveAlignedPosition(
alignment: input.crossAxisAlignment,
anchorStart: anchor.dy,
anchorSize: size.height,
menuSize: menu.height,
) +
verticalOffset;
break;
case ModalPlacement.bottom:
left = _resolveAlignedPosition(
alignment: input.crossAxisAlignment,
anchorStart: anchor.dx,
anchorSize: size.width,
menuSize: menu.width,
);
top = anchor.dy + size.height + gap + verticalOffset;
break;
case ModalPlacement.top:
left = _resolveAlignedPosition(
alignment: input.crossAxisAlignment,
anchorStart: anchor.dx,
anchorSize: size.width,
menuSize: menu.width,
);
top = anchor.dy - menu.height - gap + verticalOffset;
break;
case ModalPlacement.left:
left = anchor.dx - menu.width - gap;
top = _resolveAlignedPosition(
alignment: input.crossAxisAlignment,
anchorStart: anchor.dy,
anchorSize: size.height,
menuSize: menu.height,
) +
verticalOffset;
break;
}
left = left.clamp(padding, screen.width - menu.width - padding);
top = top.clamp(padding, screen.height - menu.height - padding);
return AnchoredModalLayoutResult(
position: Offset(left, top),
placement: placement,
);
}
static bool _canPlace({
required ModalPlacement placement,
required Size screen,
required Offset anchor,
required Size anchorSize,
required Size menu,
required double gap,
}) {
switch (placement) {
case ModalPlacement.right:
return screen.width - (anchor.dx + anchorSize.width) >=
menu.width + gap;
case ModalPlacement.left:
return anchor.dx >= menu.width + gap;
case ModalPlacement.bottom:
return screen.height - (anchor.dy + anchorSize.height) >=
menu.height + gap;
case ModalPlacement.top:
return anchor.dy >= menu.height + gap;
}
}
static double _resolveAlignedPosition({
required ModalCrossAxisAlignment alignment,
required double anchorStart,
required double anchorSize,
required double menuSize,
}) {
switch (alignment) {
case ModalCrossAxisAlignment.start:
return anchorStart;
case ModalCrossAxisAlignment.center:
return anchorStart + anchorSize / 2 - menuSize / 2;
case ModalCrossAxisAlignment.end:
return anchorStart + anchorSize - menuSize;
}
}
}
@@ -1,228 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pointer_interceptor/pointer_interceptor.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/utils/app_logger.dart';
import 'package:scribe/scribe/ai/domain/state/generate_ai_text_state.dart';
import 'package:scribe/scribe/ai/domain/usecases/generate_ai_text_interactor.dart';
import 'package:scribe/scribe/ai/presentation/model/ai_action.dart';
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe_bar.dart';
import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe_menu.dart';
import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe_suggestion.dart';
typedef AIScribeResultCallback = void Function(String result);
Future<void> showAIScribeDialog({
required BuildContext context,
required ImagePaths imagePaths,
required String content,
required AIScribeResultCallback onInsertText,
required GenerateAITextInteractor interactor,
List<AIScribeMenuCategory>? availableCategories,
Offset? buttonPosition,
}) async {
final hasContent = content.isNotEmpty;
final selectedAction = await showDialog<AIAction>(
context: context,
barrierDismissible: true,
builder: (context) {
final dialogContent = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hasContent) ...[
Material(
color: Colors.white,
elevation: AIScribeSizes.dialogElevation,
borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius),
child: SizedBox(
width: AIScribeSizes.menuWidth,
child: AIScribeMenu(
onActionSelected: (action) {
Navigator.of(context).pop(PredefinedAction(action));
},
availableCategories: availableCategories,
),
),
),
const SizedBox(height: AIScribeSizes.fieldSpacing),
],
Material(
color: Colors.white,
elevation: AIScribeSizes.dialogElevation,
borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius),
child: SizedBox(
width: AIScribeSizes.barWidth,
child: AIScribeBar(
onCustomPrompt: (customPrompt) {
Navigator.of(context).pop(CustomPromptAction(customPrompt));
},
imagePaths: imagePaths
),
),
),
],
);
if (buttonPosition != null) {
final categories = availableCategories ?? AIScribeMenuCategory.values;
final modalHeight = hasContent
? (categories.length * AIScribeSizes.menuItemHeight) + AIScribeSizes.fieldSpacing + AIScribeSizes.barHeight
: AIScribeSizes.barHeight;
final position = calculateModalPosition(
context: context,
buttonPosition: buttonPosition,
modalWidth: AIScribeSizes.barWidth,
modalHeight: modalHeight,
);
return PointerInterceptor(
child: GestureDetector(
onTap: () {
Navigator.of(context).pop();
},
behavior: HitTestBehavior.opaque,
child: Stack(
children: [
Positioned(
left: position.left,
bottom: position.bottom,
child: dialogContent,
),
],
),
),
);
}
return Center(child: dialogContent);
},
);
if (selectedAction == null) {
log('showAIScribeDialog: No action selected');
return;
}
if (!context.mounted) return;
await showDialog(
context: context,
barrierDismissible: true,
builder: (context) {
final suggestionFuture = _executeAIRequest(interactor, selectedAction, content);
final title = selectedAction.getLabel(context);
final modalContent = AIScribeSuggestion(
title: title,
suggestionFuture: suggestionFuture,
onClose: () => Navigator.of(context).pop(),
onInsert: (result) {
onInsertText(result);
Navigator.of(context).pop();
},
imagePaths: imagePaths,
);
if (buttonPosition != null) {
final screenSize = MediaQuery.of(context).size;
final modalWidth = screenSize.width < AIScribeSizes.mobileBreakpoint
? screenSize.width * AIScribeSizes.mobileWidthPercentage
: AIScribeSizes.modalMaxWidthLargeScreen;
final position = calculateModalPosition(
context: context,
buttonPosition: buttonPosition,
modalWidth: modalWidth,
modalHeight: AIScribeSizes.modalMaxHeight,
);
return PointerInterceptor(
child: GestureDetector(
onTap: () {
Navigator.of(context).pop();
},
behavior: HitTestBehavior.opaque,
child: Stack(
children: [
Positioned(
left: position.left,
bottom: position.bottom,
child: modalContent,
),
],
),
),
);
}
return Center(child: modalContent);
},
);
}
double calculateMenuDialogHeight({
required bool hasContent,
required int categoryCount,
}) {
return hasContent
? (categoryCount * AIScribeSizes.menuItemHeight) + AIScribeSizes.fieldSpacing + AIScribeSizes.barHeight
: AIScribeSizes.barHeight;
}
({double left, double bottom}) calculateModalPosition({
required BuildContext context,
required Offset buttonPosition,
required double modalWidth,
double? modalHeight,
}) {
final screenSize = MediaQuery.of(context).size;
// Calculate position above the button
double left = buttonPosition.dx;
double bottom = screenSize.height - buttonPosition.dy + AIScribeSizes.fieldSpacing;
// Ensure modal doesn't go off screen horizontally
if (left + modalWidth > screenSize.width) {
left = screenSize.width - modalWidth - AIScribeSizes.screenEdgePadding;
}
if (left < AIScribeSizes.screenEdgePadding) {
left = AIScribeSizes.screenEdgePadding;
}
// Ensure modal doesn't go off screen vertically (if height is provided)
if (modalHeight != null) {
if (bottom + modalHeight > screenSize.height) {
bottom = screenSize.height - modalHeight - AIScribeSizes.screenEdgePadding;
}
if (bottom < AIScribeSizes.screenEdgePadding) {
bottom = AIScribeSizes.screenEdgePadding;
}
}
return (left: left, bottom: bottom);
}
Future<String> _executeAIRequest(
GenerateAITextInteractor interactor,
AIAction action,
String content,
) async {
final result = await interactor.execute(action, content);
return result.fold(
(failure) {
throw Exception('Failed to generate AI response: $failure');
},
(success) {
if (success is GenerateAITextSuccess) {
return success.response.result;
} else {
throw Exception('Unexpected success state: $success');
}
},
);
}
@@ -1,28 +0,0 @@
import 'package:flutter/material.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/views/button/tmail_button_widget.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
class AIScribeButton extends StatelessWidget {
final ImagePaths imagePaths;
final VoidCallback onTap;
const AIScribeButton({
super.key,
required this.imagePaths,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return TMailButtonWidget.fromIcon(
icon: imagePaths.icSparkle,
padding: AIScribeSizes.scribeButtonPadding,
backgroundColor: Colors.white,
iconSize: AIScribeSizes.scribeIconSize,
borderRadius: AIScribeSizes.scribeButtonBorderRadius,
onTapActionCallback: onTap,
boxShadow: AIScribeShadows.elevation8,
);
}
}
@@ -1,269 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:pointer_interceptor/pointer_interceptor.dart';
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
class AIScribeMenu extends StatefulWidget {
final Function(AIScribeMenuAction) onActionSelected;
final bool useSubmenuItemStyle;
final List<AIScribeMenuCategory>? availableCategories;
const AIScribeMenu({
super.key,
required this.onActionSelected,
this.useSubmenuItemStyle = true,
this.availableCategories,
});
@override
State<AIScribeMenu> createState() => _AIScribeMenuContentState();
}
class _AIScribeMenuContentState extends State<AIScribeMenu> {
AIScribeMenuCategory? _hoveredCategory;
final GlobalKey _menuKey = GlobalKey();
OverlayEntry? _submenuOverlay;
Timer? _closeTimer;
@override
void dispose() {
_closeTimer?.cancel();
_removeSubmenu();
super.dispose();
}
void _removeSubmenu() {
_submenuOverlay?.remove();
_submenuOverlay = null;
}
void _showSubmenu(AIScribeMenuCategory category) {
if (!category.hasSubmenu) return;
_removeSubmenu();
// Get the entire menu's bounds
final menuRenderBox = _menuKey.currentContext?.findRenderObject() as RenderBox?;
if(menuRenderBox == null) return;
final menuPosition = menuRenderBox.localToGlobal(Offset.zero);
final menuSize = menuRenderBox.size;
_submenuOverlay = OverlayEntry(
builder: (context) => _SubmenuPanel(
category: category,
position: menuPosition,
parentSize: menuSize,
onActionSelected: (action) {
_closeTimer?.cancel();
_removeSubmenu();
widget.onActionSelected(action);
},
onHover: _cancelClose,
onDismiss: () {
setState(() {
_hoveredCategory = null;
});
_removeSubmenu();
},
),
);
Overlay.of(context).insert(_submenuOverlay!);
}
void _handleCategoryHover(AIScribeMenuCategory category, bool isHovering) {
if (isHovering) {
_closeTimer?.cancel();
_closeTimer = null;
setState(() {
_hoveredCategory = category;
_showSubmenu(category);
});
} else {
// Delay closing to allow mouse to reach submenu
_closeTimer?.cancel();
_closeTimer = Timer(const Duration(milliseconds: 150), () {
if (mounted && _hoveredCategory == category) {
setState(() {
_hoveredCategory = null;
_removeSubmenu();
});
}
});
}
}
void _cancelClose() {
_closeTimer?.cancel();
_closeTimer = null;
}
@override
Widget build(BuildContext context) {
final categories = widget.availableCategories ?? AIScribeMenuCategory.values;
return Column(
key: _menuKey,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: categories.map(_buildCategoryItem).toList(),
);
}
Widget _buildCategoryItem(AIScribeMenuCategory category) {
if (category.hasSubmenu) {
return Container(
child: _buildMenuItem(
label: category.getLabel(context),
hasSubmenu: true,
isHovered: _hoveredCategory == category,
onHover: (isHovering) => _handleCategoryHover(category, isHovering),
),
);
} else {
// For categories without submenu (like Correct Grammar)
return _buildMenuItem(
label: category.getLabel(context),
onTap: () {
if (category.actions.isNotEmpty) {
widget.onActionSelected(category.actions.first);
}
},
);
}
}
Widget _buildMenuItem({
required String label,
VoidCallback? onTap,
void Function(bool)? onHover,
bool hasSubmenu = false,
bool isHovered = false
}) {
return SizedBox(
height: AIScribeSizes.menuItemHeight,
child: MouseRegion(
onEnter: onHover != null ? (_) => onHover(true) : null,
onExit: onHover != null ? (_) => onHover(false) : null,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AIScribeSizes.menuItemBorderRadius),
child: Padding(
padding: AIScribeSizes.menuItemPadding,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
label,
style: AIScribeTextStyles.menuItem,
),
),
if (hasSubmenu)
const Icon(
Icons.chevron_right,
size: AIScribeSizes.iconSize,
color: AIScribeColors.textPrimary,
),
],
),
),
),
),
);
}
}
/// Submenu panel that appears to the right of the main menu
class _SubmenuPanel extends StatefulWidget {
final AIScribeMenuCategory category;
final Offset position;
final Size parentSize;
final Function(AIScribeMenuAction) onActionSelected;
final VoidCallback onHover;
final VoidCallback onDismiss;
const _SubmenuPanel({
required this.category,
required this.position,
required this.parentSize,
required this.onActionSelected,
required this.onHover,
required this.onDismiss,
});
@override
State<_SubmenuPanel> createState() => _SubmenuPanelState();
}
class _SubmenuPanelState extends State<_SubmenuPanel> {
@override
Widget build(BuildContext context) {
final submenuHeight = widget.category.actions.length * AIScribeSizes.menuItemHeight;
// Position submenu to the right of the parent item
final left = widget.position.dx + widget.parentSize.width + AIScribeSizes.submenuSpacing;
final top = widget.position.dy + widget.parentSize.height - submenuHeight;
final screenSize = MediaQuery.of(context).size;
// If we exceed screen to the right, we position left instead of right
final adjustedLeft = (left + AIScribeSizes.menuWidth > screenSize.width)
? widget.position.dx - AIScribeSizes.menuWidth - AIScribeSizes.submenuSpacing
: left;
// If we exceed screen to the top, we position at top
final adjustedTop = (top < 0.0)
? 0.0
: top;
return Stack(
children: [
Positioned(
left: adjustedLeft,
top: adjustedTop,
child: MouseRegion(
onEnter: (_) => widget.onHover(),
onExit: (_) => widget.onDismiss(),
child: PointerInterceptor(
child: Material(
color: Colors.white,
elevation: AIScribeSizes.dialogElevation,
borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius),
child: Container(
width: AIScribeSizes.menuWidth,
constraints: const BoxConstraints(maxHeight: AIScribeSizes.submenuMaxHeight),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: widget.category.actions.map((action) {
return SizedBox(
height: AIScribeSizes.menuItemHeight,
child: InkWell(
onTap: () => widget.onActionSelected(action),
borderRadius: BorderRadius.circular(AIScribeSizes.menuItemBorderRadius),
child: Padding(
padding: AIScribeSizes.menuItemPadding,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
action.getLabel(context),
style: AIScribeTextStyles.menuItem,
),
),
),
),
);
}).toList(),
),
),
),
),
),
),
],
);
}
}
@@ -1,198 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pointer_interceptor/pointer_interceptor.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/views/button/tmail_button_widget.dart';
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
typedef OnInsertTextCallback = void Function(String text);
class AIScribeSuggestion extends StatefulWidget {
final String title;
final Future<String> suggestionFuture;
final VoidCallback onClose;
final OnInsertTextCallback onInsert;
final ImagePaths imagePaths;
const AIScribeSuggestion({
super.key,
required this.title,
required this.suggestionFuture,
required this.onClose,
required this.onInsert,
required this.imagePaths,
});
@override
State<AIScribeSuggestion> createState() => _AIScribeSuggestionModalState();
}
class _AIScribeSuggestionModalState extends State<AIScribeSuggestion> {
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final modalWidth = screenWidth < AIScribeSizes.mobileBreakpoint
? screenWidth * AIScribeSizes.mobileWidthPercentage
: AIScribeSizes.modalMaxWidthLargeScreen;
return PointerInterceptor(
child: Material(
borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius),
child: Container(
width: modalWidth,
constraints: const BoxConstraints(
maxHeight: AIScribeSizes.modalMaxHeight,
),
decoration: BoxDecoration(
color: AIScribeColors.background,
borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius),
),
child: FutureBuilder<String>(
future: widget.suggestionFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return _buildLoadingState();
} else if (snapshot.hasError) {
return _buildErrorState();
} else if (snapshot.hasData) {
return _buildSuccessState(snapshot.data!);
} else {
return _buildErrorState();
}
},
),
),
),
);
}
Widget _buildLoadingState() {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(),
Flexible(
fit: FlexFit.loose,
child: Container(
height: AIScribeSizes.infoHeight,
padding: AIScribeSizes.suggestionInfoPadding,
child: const Center(
child: CircularProgressIndicator(),
),
),
),
],
);
}
Widget _buildSuccessState(String suggestion) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(),
Flexible(
fit: FlexFit.loose,
child: Container(
padding: AIScribeSizes.suggestionContentPadding,
child: SingleChildScrollView(
child: SelectableText(
suggestion,
style: AIScribeTextStyles.suggestionContent,
),
),
),
),
_buildFooter(suggestion),
],
);
}
Widget _buildErrorState() {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(),
Flexible(
fit: FlexFit.loose,
child: Container(
height: AIScribeSizes.infoHeight,
padding: AIScribeSizes.suggestionInfoPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: AIScribeSizes.iconSize,
color: Colors.red,
),
const SizedBox(height: AIScribeSizes.fieldSpacing),
Text(
ScribeLocalizations.of(context)!.failedToGenerate,
style: AIScribeTextStyles.suggestionContent,
textAlign: TextAlign.center,
)
],
),
),
),
],
);
}
Widget _buildHeader() {
return Container(
padding: AIScribeSizes.suggestionHeaderPadding,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.title,
style: AIScribeTextStyles.suggestionTitle
),
IconButton(
icon: const Icon(
Icons.close,
size: AIScribeSizes.iconSize,
),
iconSize: AIScribeSizes.iconSize,
color: Colors.grey[600],
onPressed: widget.onClose,
),
],
),
);
}
Widget _buildFooter(String suggestion) {
return Container(
padding: AIScribeSizes.suggestionFooterPadding,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(
Icons.content_copy,
size: AIScribeSizes.iconSize,
),
color: Colors.grey[600],
onPressed: () {
Clipboard.setData(ClipboardData(text: suggestion));
},
),
TMailButtonWidget(
text: ScribeLocalizations.of(context)!.insertButton,
textStyle: AIScribeButtonStyles.mainActionButtonText,
padding: AIScribeButtonStyles.mainActionButtonPadding,
backgroundColor: AIScribeButtonStyles.mainActionButtonBackgroundColor,
onTapActionCallback: () => widget.onInsert(suggestion),
)
],
),
);
}
}
@@ -0,0 +1,54 @@
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/views/button/tmail_button_widget.dart';
import 'package:flutter/material.dart';
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
typedef OnOpenAiAssistantModal = void Function(
Offset? position,
Size? size,
);
class AiAssistantButton extends StatelessWidget {
final ImagePaths imagePaths;
final Color? iconColor;
final EdgeInsetsGeometry? margin;
final OnOpenAiAssistantModal onOpenAiAssistantModal;
const AiAssistantButton({
super.key,
required this.imagePaths,
required this.onOpenAiAssistantModal,
this.iconColor,
this.margin,
});
@override
Widget build(BuildContext context) {
return TMailButtonWidget.fromIcon(
icon: imagePaths.icGradientSparkle,
padding: AIScribeSizes.aiAssistantIconPadding,
backgroundColor: Colors.transparent,
iconSize: AIScribeSizes.aiAssistantIcon,
iconColor: iconColor,
margin: margin,
borderRadius: AIScribeSizes.aiAssistantIconRadius,
tooltipMessage: ScribeLocalizations.of(context).aiAssistant,
onTapActionCallback: () => _onTapActionCallback(context),
);
}
void _onTapActionCallback(BuildContext context) {
final renderBox = context.findRenderObject();
Offset? position;
Size? size;
if (renderBox != null && renderBox is RenderBox) {
position = renderBox.localToGlobal(Offset.zero);
size = renderBox.size;
}
onOpenAiAssistantModal(position, size);
}
}
@@ -0,0 +1,58 @@
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/views/button/tmail_button_widget.dart';
import 'package:flutter/material.dart';
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
import 'package:scribe/scribe/ai/presentation/utils/modal/ai_scribe_modal_manager.dart';
import 'package:scribe/scribe/ai/presentation/widgets/modal/ai_scribe_suggestion_widget.dart';
class InlineAiAssistButton extends StatelessWidget {
final ImagePaths imagePaths;
final String? selectedText;
final OnAiScribeResultCallback onAiScribeResultCallback;
const InlineAiAssistButton({
super.key,
required this.imagePaths,
required this.onAiScribeResultCallback,
this.selectedText,
});
@override
Widget build(BuildContext context) {
return TMailButtonWidget.fromIcon(
icon: imagePaths.icSparkle,
padding: AIScribeSizes.scribeButtonPadding,
backgroundColor: AIScribeColors.background,
iconSize: AIScribeSizes.scribeIcon,
iconColor: AIScribeColors.scribeIcon,
borderRadius: AIScribeSizes.scribeButtonRadius,
boxShadow: AIScribeShadows.sparkleIcon,
onTapActionCallback: () => _onTapActionCallback(context),
);
}
Future<void> _onTapActionCallback(BuildContext context) async {
final renderBox = context.findRenderObject();
Offset? position;
Size? size;
if (renderBox != null && renderBox is RenderBox) {
position = renderBox.localToGlobal(Offset.zero);
size = renderBox.size;
}
final aiResult = await AiScribeModalManager.showAIScribeMenuModal(
imagePaths: imagePaths,
availableCategories: AIScribeMenuCategory.values,
buttonPosition: position,
content: selectedText,
buttonSize: size,
);
if (aiResult != null) {
onAiScribeResultCallback(aiResult);
}
}
}
@@ -0,0 +1,120 @@
import 'package:core/presentation/resources/image_paths.dart';
import 'package:flutter/material.dart';
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
import 'package:scribe/scribe/ai/presentation/utils/context_menu/popup_submenu_controller.dart';
import 'package:scribe/scribe/ai/presentation/widgets/context_menu/ai_scribe_context_menu_item.dart';
import 'package:scribe/scribe/ai/presentation/widgets/context_menu/ai_scribe_submenu.dart';
class AiScribeContextMenu extends StatefulWidget {
final ImagePaths imagePaths;
final List<AiScribeContextMenuAction> menuActions;
final ValueChanged<AiScribeContextMenuAction> onActionSelected;
final PopupSubmenuController? submenuController;
const AiScribeContextMenu({
super.key,
required this.imagePaths,
required this.menuActions,
required this.onActionSelected,
this.submenuController,
});
@override
State<AiScribeContextMenu> createState() =>
_AiScribeContextMenuContentState();
}
class _AiScribeContextMenuContentState extends State<AiScribeContextMenu> {
final GlobalKey _contextMenuKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Container(
key: _contextMenuKey,
width: AIScribeSizes.contextMenuWidth,
constraints: const BoxConstraints(
maxHeight: AIScribeSizes.submenuMaxHeight,
),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(AIScribeSizes.menuRadius),
),
color: AIScribeColors.background,
boxShadow: AIScribeShadows.modal,
),
clipBehavior: Clip.antiAlias,
child: ListView.builder(
shrinkWrap: true,
padding: AIScribeSizes.contextMenuPadding,
itemCount: widget.menuActions.length,
itemBuilder: (context, index) {
final menuAction = widget.menuActions[index];
return AiScribeContextMenuItem(
menuAction: menuAction,
imagePaths: widget.imagePaths,
onSelectAction: (menuAction) {
widget.submenuController?.hide();
widget.onActionSelected(menuAction);
},
onHoverShowSubmenu: (itemKey) =>
menuAction.submenuActions?.isNotEmpty == true
? _showSubmenu(
context: context,
itemKey: itemKey,
contextMenuKey: _contextMenuKey,
actions: menuAction.submenuActions!,
)
: null,
onHoverOtherItem: widget.submenuController?.hide,
);
},
),
);
}
Rect? _generateRectByKey(GlobalKey key) {
final renderObject = key.currentContext?.findRenderObject();
if (renderObject is! RenderBox) return null;
final renderBox = renderObject;
final offset = renderBox.localToGlobal(Offset.zero);
return offset & renderBox.size;
}
void _showSubmenu({
required BuildContext context,
required GlobalKey itemKey,
required GlobalKey contextMenuKey,
required List<AiScribeContextMenuAction> actions,
}) {
final itemRect = _generateRectByKey(itemKey);
final contextMenuRect = _generateRectByKey(contextMenuKey);
if (itemRect == null || contextMenuRect == null) {
return;
}
widget.submenuController?.show(
context: context,
anchor: itemRect,
anchorMenu: contextMenuRect,
submenuMaxHeight: AIScribeSizes.submenuMaxHeight,
submenuWidth: AIScribeSizes.submenuWidth,
menuFieldSpacing: AIScribeSizes.fieldSpacing,
submenu: AiScribeSubmenu(
menuActions: actions,
onSelectAction: (action) {
widget.submenuController?.hide();
widget.onActionSelected(action);
},
),
);
}
@override
void dispose() {
widget.submenuController?.dispose();
super.dispose();
}
}
@@ -0,0 +1,134 @@
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
import 'package:scribe/scribe/ai/presentation/utils/context_menu/hover_submenu_controller.dart';
class AiScribeContextMenuItem extends StatefulWidget {
final AiScribeContextMenuAction menuAction;
final ImagePaths imagePaths;
final ValueChanged<AiScribeContextMenuAction> onSelectAction;
final OnHoverShowSubmenu? onHoverShowSubmenu;
final VoidCallback? onHoverOtherItem;
const AiScribeContextMenuItem({
super.key,
required this.menuAction,
required this.imagePaths,
required this.onSelectAction,
this.onHoverShowSubmenu,
this.onHoverOtherItem,
});
@override
State<AiScribeContextMenuItem> createState() =>
_AiScribeContextMenuItemState();
}
class _AiScribeContextMenuItemState extends State<AiScribeContextMenuItem> {
GlobalKey? _itemKey;
HoverSubmenuController? _hoverController;
@override
void initState() {
super.initState();
if (widget.menuAction.hasSubmenu) {
_itemKey = GlobalKey();
_hoverController = HoverSubmenuController();
}
}
@override
Widget build(BuildContext context) {
final childWidget = Container(
key: _itemKey,
height: AIScribeSizes.menuItemHeight,
padding: AIScribeSizes.menuCategoryItemPadding,
alignment: AlignmentDirectional.centerStart,
child: Row(
children: [
if (widget.menuAction.actionIcon != null)
Padding(
padding: const EdgeInsetsDirectional.only(end: 12),
child: SvgPicture.asset(
widget.menuAction.actionIcon!,
width: 20,
height: 20,
fit: BoxFit.fill,
colorFilter:
AppColor.gray424244.withValues(alpha: 0.72).asFilter(),
),
),
Flexible(
child: Text(
widget.menuAction.actionName,
style: AIScribeTextStyles.menuItem,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (widget.menuAction.hasSubmenu)
Padding(
padding: const EdgeInsetsDirectional.only(start: 3),
child: SvgPicture.asset(
widget.imagePaths.icArrowRight,
width: 16,
height: 16,
fit: BoxFit.fill,
colorFilter: AppColor.gray777778.asFilter(),
),
),
],
),
);
if (widget.menuAction.hasSubmenu) {
return MouseRegion(
onEnter: (_) {
_hoverController?.enter();
if (_itemKey != null) {
widget.onHoverShowSubmenu?.call(_itemKey!);
} else {
widget.onHoverOtherItem?.call();
}
},
onExit: (_) {
_hoverController?.exit();
},
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: () => widget.onSelectAction(widget.menuAction),
hoverColor: AppColor.grayBackgroundColor,
child: childWidget,
),
),
);
}
return Material(
type: MaterialType.transparency,
child: InkWell(
onTap: () => widget.onSelectAction(widget.menuAction),
hoverColor: AppColor.grayBackgroundColor,
onHover: (_) {
_hoverController?.exit();
widget.onHoverOtherItem?.call();
},
child: childWidget,
),
);
}
@override
void dispose() {
if (_hoverController != null) {
_hoverController?.dispose();
_hoverController = null;
}
super.dispose();
}
}
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
import 'package:scribe/scribe/ai/presentation/widgets/context_menu/ai_scribe_submenu_item.dart';
class AiScribeSubmenu extends StatelessWidget {
final List<AiScribeContextMenuAction> menuActions;
final ValueChanged<AiScribeContextMenuAction> onSelectAction;
const AiScribeSubmenu({
super.key,
required this.menuActions,
required this.onSelectAction,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AIScribeColors.background,
borderRadius: const BorderRadius.all(
Radius.circular(AIScribeSizes.menuRadius),
),
boxShadow: AIScribeShadows.modal,
),
clipBehavior: Clip.antiAlias,
child: ListView.builder(
shrinkWrap: true,
itemCount: menuActions.length,
itemBuilder: (_, index) {
final action = menuActions[index];
return AiScribeSubmenuItem(
menuAction: action,
onSelectAction: onSelectAction,
);
},
),
);
}
}
@@ -0,0 +1,56 @@
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
class AiScribeSubmenuItem extends StatelessWidget {
final AiScribeContextMenuAction menuAction;
final ValueChanged<AiScribeContextMenuAction> onSelectAction;
const AiScribeSubmenuItem({
super.key,
required this.menuAction,
required this.onSelectAction,
});
@override
Widget build(BuildContext context) {
return Material(
type: MaterialType.transparency,
child: InkWell(
onTap: () => onSelectAction(menuAction),
hoverColor: AppColor.grayBackgroundColor,
child: Container(
height: AIScribeSizes.menuItemHeight,
padding: AIScribeSizes.menuItemPadding,
alignment: AlignmentDirectional.centerStart,
child: Row(
children: [
if (menuAction.actionIcon != null)
Padding(
padding: const EdgeInsetsDirectional.only(end: 12),
child: SvgPicture.asset(
menuAction.actionIcon!,
width: 20,
height: 20,
fit: BoxFit.fill,
colorFilter:
AppColor.gray424244.withValues(alpha: 0.72).asFilter(),
),
),
Flexible(
child: Text(
menuAction.actionName,
style: AIScribeTextStyles.menuItem,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,155 @@
import 'dart:math';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:flutter/material.dart';
import 'package:pointer_interceptor/pointer_interceptor.dart';
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
import 'package:scribe/scribe/ai/presentation/model/ai_action.dart';
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_action_context_menu_action.dart';
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_category_context_menu_action.dart';
import 'package:scribe/scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/anchored_modal_layout_input.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/modal_cross_axis_alignment.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/modal_placement.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
import 'package:scribe/scribe/ai/presentation/utils/context_menu/popup_submenu_controller.dart';
import 'package:scribe/scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart';
import 'package:scribe/scribe/ai/presentation/widgets/context_menu/ai_scribe_context_menu.dart';
import 'package:scribe/scribe/ai/presentation/widgets/search/ai_scribe_bar.dart';
class AiScribeModalWidget extends StatelessWidget {
final ImagePaths imagePaths;
final String? content;
final List<AIScribeMenuCategory> availableCategories;
final Offset? buttonPosition;
final Size? buttonSize;
final ModalPlacement? preferredPlacement;
final ModalCrossAxisAlignment crossAxisAlignment;
final PopupSubmenuController? submenuController;
const AiScribeModalWidget({
super.key,
required this.imagePaths,
required this.availableCategories,
this.content,
this.buttonPosition,
this.buttonSize,
this.preferredPlacement,
this.crossAxisAlignment = ModalCrossAxisAlignment.center,
this.submenuController,
});
@override
Widget build(BuildContext context) {
final hasContent = content?.isNotEmpty ?? false;
final scribeLocalizations = ScribeLocalizations.of(context);
final menuActions = availableCategories
.map((category) => AiScribeCategoryContextMenuAction(
category,
scribeLocalizations,
imagePaths,
))
.toList();
final dialogContent = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: AIScribeSizes.fieldSpacing,
children: [
if (hasContent)
Flexible(
child: AiScribeContextMenu(
imagePaths: imagePaths,
menuActions: menuActions,
submenuController: submenuController,
onActionSelected: (menuAction) => _onActionSelected(
context,
menuAction,
),
),
),
MouseRegion(
onEnter: (_) => submenuController?.hide(),
child: AIScribeBar(
imagePaths: imagePaths,
onCustomPrompt: (customPrompt) {
Navigator.of(context).pop(CustomPromptAction(customPrompt));
submenuController?.hide();
},
),
),
],
);
if (buttonPosition != null && buttonSize != null) {
final maxHeightModal = hasContent
? AIScribeSizes.searchBarMinHeight +
AIScribeSizes.fieldSpacing +
min(menuActions.length * AIScribeSizes.menuItemHeight,
AIScribeSizes.submenuMaxHeight)
: AIScribeSizes.searchBarMinHeight;
final layoutResult = AnchoredModalLayoutCalculator.calculate(
input: AnchoredModalLayoutInput(
screenSize: MediaQuery.of(context).size,
anchorPosition: buttonPosition!,
anchorSize: buttonSize!,
menuSize: Size(
AIScribeSizes.modalMaxWidth,
maxHeightModal,
),
preferredPlacement: preferredPlacement,
crossAxisAlignment: crossAxisAlignment,
),
padding: AIScribeSizes.screenEdgePadding,
);
final position = layoutResult.position;
final top = preferredPlacement == ModalPlacement.top
? position.dy -
(hasContent
? AIScribeSizes.modalSpacing
: AIScribeSizes.modalWithoutContentSpacing)
: position.dy;
return PointerInterceptor(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: Navigator.of(context).pop,
child: Stack(
children: [
PositionedDirectional(
start: position.dx,
top: top,
child: dialogContent,
),
],
),
),
);
}
return Center(child: dialogContent);
}
void _onActionSelected(
BuildContext context,
AiScribeContextMenuAction menuAction,
) {
final navigator = Navigator.of(context);
if (menuAction is AiScribeCategoryContextMenuAction) {
final firstAiScribeAction = menuAction.action.actions.firstOrNull;
if (firstAiScribeAction != null) {
navigator.pop(PredefinedAction(firstAiScribeAction));
} else {
navigator.pop();
}
} else if (menuAction is AiScribeActionContextMenuAction) {
navigator.pop(PredefinedAction(menuAction.action));
} else {
navigator.pop();
}
}
}
@@ -0,0 +1,183 @@
import 'dart:math';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:dartz/dartz.dart' as dartz;
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pointer_interceptor/pointer_interceptor.dart';
import 'package:scribe/scribe/ai/domain/state/generate_ai_text_state.dart';
import 'package:scribe/scribe/ai/domain/usecases/generate_ai_text_interactor.dart';
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
import 'package:scribe/scribe/ai/presentation/model/ai_action.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/anchored_modal_layout_input.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/modal_cross_axis_alignment.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/modal_placement.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
import 'package:scribe/scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart';
import 'package:scribe/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_error.dart';
import 'package:scribe/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_header.dart';
import 'package:scribe/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_loading.dart';
import 'package:scribe/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success.dart';
typedef OnAiScribeResultCallback = void Function(String result);
class AiScribeSuggestionWidget extends StatefulWidget {
final AIAction aiAction;
final String? content;
final ImagePaths imagePaths;
final Offset? buttonPosition;
final Size? buttonSize;
final ModalPlacement? preferredPlacement;
final ModalCrossAxisAlignment crossAxisAlignment;
const AiScribeSuggestionWidget({
super.key,
required this.aiAction,
required this.imagePaths,
this.content,
this.buttonPosition,
this.buttonSize,
this.preferredPlacement,
this.crossAxisAlignment = ModalCrossAxisAlignment.center,
});
@override
State<AiScribeSuggestionWidget> createState() =>
_AiScribeSuggestionWidgetModalState();
}
class _AiScribeSuggestionWidgetModalState
extends State<AiScribeSuggestionWidget> {
GenerateAITextInteractor? _generateAITextInteractor;
final ValueNotifier<dynamic> _valueNotifier =
ValueNotifier(dartz.Right(GenerateAITextLoading()));
@override
void initState() {
super.initState();
if (Get.isRegistered<GenerateAITextInteractor>()) {
_generateAITextInteractor = Get.find<GenerateAITextInteractor>();
_getAiSuggestion(
_generateAITextInteractor!,
widget.aiAction,
widget.content,
);
}
}
void _getAiSuggestion(
GenerateAITextInteractor interactor,
AIAction action,
String? content,
) async {
final result = await interactor.execute(action, content);
result.fold((failure) {
_valueNotifier.value = failure;
}, (success) {
_valueNotifier.value = success;
});
}
@override
Widget build(BuildContext context) {
final localizations = ScribeLocalizations.of(context);
final screenSize = MediaQuery.sizeOf(context);
final modalWidth = min(
screenSize.width * AIScribeSizes.mobileFactor,
AIScribeSizes.suggestionModalMaxWidth,
);
final modalHeight = min(
screenSize.height * AIScribeSizes.mobileFactor,
AIScribeSizes.suggestionModalMaxHeight,
);
final dialogContent = Container(
width: modalWidth,
constraints: BoxConstraints(
minHeight: AIScribeSizes.suggestionModalMinHeight,
maxHeight: modalHeight,
),
decoration: BoxDecoration(
color: AIScribeColors.background,
borderRadius: const BorderRadius.all(
Radius.circular(AIScribeSizes.menuRadius),
),
boxShadow: AIScribeShadows.modal,
),
padding: AIScribeSizes.suggestionContentPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
AiScribeSuggestionHeader(
title: widget.aiAction.getLabel(localizations),
imagePaths: widget.imagePaths,
),
Flexible(
child: ValueListenableBuilder(
valueListenable: _valueNotifier,
builder: (_, value, __) {
if (value is GenerateAITextSuccess) {
return AiScribeSuggestionSuccess(
imagePaths: widget.imagePaths,
suggestionText: value.response.result,
aiAction: widget.aiAction,
);
} else if (value is GenerateAITextFailure) {
return AiScribeSuggestionError(imagePaths: widget.imagePaths);
} else {
return AiScribeSuggestionLoading(
imagePaths: widget.imagePaths,
);
}
},
),
),
],
),
);
if (widget.buttonPosition != null && widget.buttonSize != null) {
final layoutResult = AnchoredModalLayoutCalculator.calculate(
input: AnchoredModalLayoutInput(
screenSize: MediaQuery.of(context).size,
anchorPosition: widget.buttonPosition!,
anchorSize: widget.buttonSize!,
menuSize: Size(
modalWidth,
AIScribeSizes.suggestionModalMinHeight,
),
preferredPlacement: widget.preferredPlacement,
crossAxisAlignment: widget.crossAxisAlignment,
),
padding: AIScribeSizes.screenEdgePadding,
);
final position = layoutResult.position;
final top = widget.preferredPlacement == ModalPlacement.top
? position.dy - AIScribeSizes.modalWithoutContentSpacing
: position.dy;
return PointerInterceptor(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: Navigator.of(context).pop,
child: Stack(
children: [
PositionedDirectional(
start: position.dx,
top: top,
child: dialogContent,
),
],
),
),
);
}
return Center(child: dialogContent);
}
}
@@ -0,0 +1,37 @@
import 'package:core/presentation/resources/image_paths.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
class AiScribeSuggestionError extends StatelessWidget {
final ImagePaths imagePaths;
const AiScribeSuggestionError({
super.key,
required this.imagePaths,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsetsDirectional.only(bottom: 8, start: 8),
child: Row(
spacing: 8,
children: [
SvgPicture.asset(
imagePaths.icWarning,
width: 22,
height: 22,
),
Expanded(
child: Text(
ScribeLocalizations.of(context).failedToGenerate,
style: AIScribeTextStyles.suggestionLoading,
),
),
],
),
);
}
}
@@ -0,0 +1,42 @@
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/views/button/tmail_button_widget.dart';
import 'package:flutter/material.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
class AiScribeSuggestionHeader extends StatelessWidget {
final String title;
final ImagePaths imagePaths;
const AiScribeSuggestionHeader({
super.key,
required this.title,
required this.imagePaths,
});
@override
Widget build(BuildContext context) {
return Row(
spacing: 8,
children: [
Expanded(
child: Text(
title,
style: AIScribeTextStyles.suggestionTitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
TMailButtonWidget.fromIcon(
icon: imagePaths.icCloseDialog,
iconSize: 20,
iconColor: AppColor.gray424244.withValues(alpha: 0.72),
padding: const EdgeInsets.all(3),
borderRadius: 20,
backgroundColor: Colors.transparent,
onTapActionCallback: Navigator.of(context).pop,
)
],
);
}
}
@@ -0,0 +1,136 @@
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
class AiScribeSuggestionLoading extends StatelessWidget {
final ImagePaths imagePaths;
const AiScribeSuggestionLoading({
super.key,
required this.imagePaths,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsetsDirectional.only(bottom: 8, start: 8),
child: Row(
spacing: 8,
children: [
_SparklePulseIcon(
asset: imagePaths.icSparkle,
color: AppColor.blueFF00B7FF,
),
Expanded(
child: _AnimatedEllipsisText(
text: ScribeLocalizations.of(context).generatingResponse,
style: AIScribeTextStyles.suggestionLoading,
),
),
],
),
);
}
}
class _SparklePulseIcon extends StatefulWidget {
final String asset;
final Color color;
const _SparklePulseIcon({
required this.asset,
required this.color,
});
@override
State<_SparklePulseIcon> createState() => _SparklePulseIconState();
}
class _SparklePulseIconState extends State<_SparklePulseIcon>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scale;
late final Animation<double> _opacity;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat(reverse: true);
_scale = Tween(begin: 0.95, end: 1.05).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_opacity = Tween(begin: 0.7, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _opacity,
child: ScaleTransition(
scale: _scale,
child: SvgPicture.asset(
widget.asset,
width: 22,
height: 22,
colorFilter: widget.color.asFilter(),
),
),
);
}
}
class _AnimatedEllipsisText extends StatefulWidget {
final String text;
final TextStyle style;
const _AnimatedEllipsisText({
required this.text,
required this.style,
});
@override
State<_AnimatedEllipsisText> createState() => _AnimatedEllipsisTextState();
}
class _AnimatedEllipsisTextState extends State<_AnimatedEllipsisText> {
static const _dotStates = ['', '.', '..', '...'];
int _index = 0;
@override
void initState() {
super.initState();
_tick();
}
void _tick() {
Future.delayed(const Duration(milliseconds: 500), () {
if (!mounted) return;
setState(() => _index = (_index + 1) % _dotStates.length);
_tick();
});
}
@override
Widget build(BuildContext context) {
return Text(
'${widget.text}${_dotStates[_index]}',
style: widget.style,
);
}
}
@@ -0,0 +1,42 @@
import 'package:core/presentation/resources/image_paths.dart';
import 'package:flutter/material.dart';
import 'package:scribe/scribe.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
import 'package:scribe/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_list_actions.dart';
class AiScribeSuggestionSuccess extends StatelessWidget {
final ImagePaths imagePaths;
final String suggestionText;
final AIAction aiAction;
const AiScribeSuggestionSuccess({
super.key,
required this.imagePaths,
required this.suggestionText,
required this.aiAction,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsetsDirectional.only(bottom: 8),
child: Column(
spacing: 8,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
suggestionText,
style: AIScribeTextStyles.suggestionContent,
),
),
AiScribeSuggestionSuccessListActions(
imagePaths: imagePaths,
suggestionText: suggestionText,
aiAction: aiAction,
),
],
),
);
}
}
@@ -0,0 +1,36 @@
import 'package:core/presentation/resources/image_paths.dart';
import 'package:flutter/material.dart';
import 'package:scribe/scribe.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
class AiScribeSuggestionSuccessListActions extends StatelessWidget {
final ImagePaths imagePaths;
final String suggestionText;
final AIAction aiAction;
const AiScribeSuggestionSuccessListActions({
super.key,
required this.imagePaths,
required this.suggestionText,
required this.aiAction,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsetsDirectional.only(bottom: 8),
child: Column(
spacing: 8,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
suggestionText,
style: AIScribeTextStyles.suggestionContent,
),
),
],
),
);
}
}
@@ -0,0 +1,43 @@
import 'package:core/presentation/resources/image_paths.dart';
import 'package:flutter/material.dart';
import 'package:pointer_interceptor/pointer_interceptor.dart';
import 'package:scribe/scribe/ai/presentation/model/text_selection_model.dart';
import 'package:scribe/scribe/ai/presentation/widgets/button/inline_ai_assist_button.dart';
import 'package:scribe/scribe/ai/presentation/widgets/modal/ai_scribe_suggestion_widget.dart';
class AiSelectionOverlay extends StatelessWidget {
const AiSelectionOverlay({
super.key,
required this.selection,
required this.imagePaths,
required this.onAiScribeResultCallback,
});
final TextSelectionModel? selection;
final ImagePaths imagePaths;
final OnAiScribeResultCallback onAiScribeResultCallback;
@override
Widget build(BuildContext context) {
if (selection == null ||
!selection!.hasSelection ||
selection!.coordinates == null) {
return const SizedBox.shrink();
}
final coordinates = selection!.coordinates!;
final selectedText = selection!.selectedText;
return PositionedDirectional(
start: coordinates.dx,
top: coordinates.dy,
child: PointerInterceptor(
child: InlineAiAssistButton(
imagePaths: imagePaths,
selectedText: selectedText,
onAiScribeResultCallback: onAiScribeResultCallback,
),
),
);
}
}
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/views/button/tmail_button_widget.dart';
import 'package:flutter/material.dart';
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
@@ -53,39 +53,54 @@ class _AIScribeBarState extends State<AIScribeBar> {
@override
Widget build(BuildContext context) {
return Container(
height: AIScribeSizes.barHeight,
padding: AIScribeSizes.barPadding,
width: AIScribeSizes.searchBarWidth,
padding: AIScribeSizes.searchBarPadding,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(AIScribeSizes.searchBarRadius),
),
color: AIScribeColors.background,
boxShadow: AIScribeShadows.modal,
),
constraints: const BoxConstraints(
maxHeight: AIScribeSizes.searchBarMaxHeight,
minHeight: AIScribeSizes.searchBarMinHeight,
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: InputDecoration(
hintText: ScribeLocalizations.of(context)!.inputPlaceholder,
hintStyle: AIScribeTextStyles.menuHint,
hintText: ScribeLocalizations.of(context).inputPlaceholder,
hintStyle: AIScribeTextStyles.searchBarHint,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
contentPadding: const EdgeInsets.symmetric(vertical: 8),
isDense: true,
),
style: AIScribeTextStyles.menuItem,
style: AIScribeTextStyles.searchBar,
maxLines: null,
keyboardType: TextInputType.multiline,
cursorHeight: 16,
onSubmitted: (_) => _onSendPressed(),
),
),
const SizedBox(width: AIScribeSizes.fieldSpacing),
ValueListenableBuilder<bool>(
valueListenable: _isButtonEnabled,
builder: (context, isEnabled, child) {
builder: (_, isEnabled, __) {
return TMailButtonWidget.fromIcon(
icon: widget.imagePaths.icSend,
iconSize: AIScribeSizes.sendIconSize,
iconColor: Colors.white,
iconSize: AIScribeSizes.sendIcon,
padding: AIScribeSizes.sendIconPadding,
iconColor: AIScribeColors.background,
backgroundColor: isEnabled
? AIScribeButtonStyles.sendCustomPromptBackgroundColor
: AIScribeButtonStyles.sendCustomPromptBackgroundColorDisabled,
? AIScribeColors.sendPromptBackground
: AIScribeColors.sendPromptBackgroundDisabled,
onTapActionCallback: isEnabled ? _onSendPressed : null,
);
},
)
),
],
),
);
+1 -1
View File
@@ -539,7 +539,7 @@ packages:
source: sdk
version: "0.0.0"
flutter_svg:
dependency: transitive
dependency: "direct main"
description:
name: flutter_svg
sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1
+2
View File
@@ -36,6 +36,8 @@ dependencies:
json_annotation: 4.8.0
flutter_svg: 2.1.0
dev_dependencies:
flutter_test:
sdk: flutter
@@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/anchored_modal_layout_input.dart';
import 'package:scribe/scribe/ai/presentation/model/modal/modal_placement.dart';
import 'package:scribe/scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart';
void main() {
const screen = Size(800, 600);
const menuSize = Size(300, 200);
const anchorSize = Size(24, 24);
group('AnchoredModalLayoutCalculator placement strategy', () {
test('places menu to the RIGHT when there is enough horizontal space', () {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: screen,
anchorPosition: Offset(100, 200),
anchorSize: anchorSize,
menuSize: menuSize,
),
);
expect(result.placement, ModalPlacement.right);
expect(result.position.dx, greaterThan(100));
});
test(
'places menu at the BOTTOM when right space is insufficient but bottom has enough space',
() {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: screen,
anchorPosition: Offset(750, 200),
anchorSize: anchorSize,
menuSize: menuSize,
),
);
expect(result.placement, ModalPlacement.bottom);
expect(result.position.dy, greaterThan(200));
});
test(
'places menu at the TOP when right and bottom space are insufficient but top has space',
() {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: screen,
anchorPosition: Offset(750, 500),
anchorSize: anchorSize,
menuSize: menuSize,
),
);
expect(result.placement, ModalPlacement.top);
expect(result.position.dy, lessThan(500));
});
test(
'places menu to the LEFT when neither top nor bottom has enough vertical space',
() {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: Size(800, 260),
anchorPosition: Offset(750, 120),
anchorSize: anchorSize,
menuSize: menuSize,
),
);
expect(result.placement, ModalPlacement.left);
expect(result.position.dx, lessThan(750));
});
test('falls back to LEFT placement when no direction has sufficient space',
() {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: Size(320, 480),
anchorPosition: Offset(150, 200),
anchorSize: anchorSize,
menuSize: Size(300, 400),
),
);
expect(result.placement, ModalPlacement.left);
});
});
group('AnchoredModalLayoutCalculator screen clamping', () {
test('clamps LEFT position to screen padding', () {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: screen,
anchorPosition: Offset(5, 200),
anchorSize: anchorSize,
menuSize: menuSize,
),
padding: 16,
);
expect(result.position.dx, greaterThanOrEqualTo(16));
});
test('clamps TOP position to screen padding', () {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: screen,
anchorPosition: Offset(200, 5),
anchorSize: anchorSize,
menuSize: menuSize,
),
padding: 16,
);
expect(result.position.dy, greaterThanOrEqualTo(16));
});
test('does not overflow RIGHT screen edge', () {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: screen,
anchorPosition: Offset(780, 200),
anchorSize: anchorSize,
menuSize: menuSize,
),
padding: 16,
);
expect(
result.position.dx + menuSize.width,
lessThanOrEqualTo(screen.width - 16),
);
});
test('does not overflow BOTTOM screen edge', () {
final result = AnchoredModalLayoutCalculator.calculate(
input: const AnchoredModalLayoutInput(
screenSize: screen,
anchorPosition: Offset(200, 580),
anchorSize: anchorSize,
menuSize: menuSize,
),
padding: 16,
);
expect(
result.position.dy + menuSize.height,
lessThanOrEqualTo(screen.height - 16),
);
});
});
}
@@ -1,202 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe.dart';
void main() {
group('calculateMenuDialogHeight::', () {
test('should calculate correct height with content and default 4 categories', () {
// Arrange
const hasContent = true;
const categoryCount = 4;
// Act
final height = calculateMenuDialogHeight(
hasContent: hasContent,
categoryCount: categoryCount,
);
// Assert
// 4 categories × 40px + 8px spacing + 48px bar = 216px
const expectedHeight = (4 * AIScribeSizes.menuItemHeight) +
AIScribeSizes.fieldSpacing +
AIScribeSizes.barHeight;
expect(height, expectedHeight);
expect(height, 216.0);
});
test('should calculate correct height with content and custom categories', () {
// Arrange
const hasContent = true;
const categoryCount = 2;
// Act
final height = calculateMenuDialogHeight(
hasContent: hasContent,
categoryCount: categoryCount,
);
// Assert
// 2 categories × 40px + 8px spacing + 48px bar = 136px
const expectedHeight = (2 * AIScribeSizes.menuItemHeight) +
AIScribeSizes.fieldSpacing +
AIScribeSizes.barHeight;
expect(height, expectedHeight);
expect(height, 136.0);
});
test('should calculate correct height without content (bar only)', () {
// Arrange
const hasContent = false;
const categoryCount = 4; // Should be ignored when hasContent is false
// Act
final height = calculateMenuDialogHeight(
hasContent: hasContent,
categoryCount: categoryCount,
);
// Assert
// Only the bar height
expect(height, AIScribeSizes.barHeight);
expect(height, 48.0);
});
});
group('calculateModalPosition::', () {
testWidgets('should adjust left position when modal would go off-screen to the right', (tester) async {
// Arrange
const screenSize = Size(800, 600);
const buttonPosition = Offset(700, 300); // Close to right edge
const modalWidth = 440.0;
BuildContext? capturedContext;
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(size: screenSize),
child: Builder(
builder: (context) {
capturedContext = context;
return Container();
},
),
),
),
);
// Act
final position = calculateModalPosition(
context: capturedContext!,
buttonPosition: buttonPosition,
modalWidth: modalWidth,
);
// Assert
final expectedLeft = screenSize.width - modalWidth - AIScribeSizes.screenEdgePadding;
expect(position.left, expectedLeft);
});
testWidgets('should adjust left position to screenEdgePadding when modal would go off-screen to the left', (tester) async {
// Arrange
const screenSize = Size(800, 600);
const buttonPosition = Offset(5, 300); // Very close to left edge
const modalWidth = 440.0;
BuildContext? capturedContext;
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(size: screenSize),
child: Builder(
builder: (context) {
capturedContext = context;
return Container();
},
),
),
),
);
// Act
final position = calculateModalPosition(
context: capturedContext!,
buttonPosition: buttonPosition,
modalWidth: modalWidth,
);
// Assert
expect(position.left, AIScribeSizes.screenEdgePadding);
});
testWidgets('should adjust bottom position when modal height is provided and would go off-screen to the top', (tester) async {
// Arrange
const screenSize = Size(800, 600);
const buttonPosition = Offset(100, 50); // Close to top
const modalWidth = 440.0;
const modalHeight = 400.0;
BuildContext? capturedContext;
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(size: screenSize),
child: Builder(
builder: (context) {
capturedContext = context;
return Container();
},
),
),
),
);
// Act
final position = calculateModalPosition(
context: capturedContext!,
buttonPosition: buttonPosition,
modalWidth: modalWidth,
modalHeight: modalHeight,
);
// Assert
final expectedBottom = screenSize.height - modalHeight - AIScribeSizes.screenEdgePadding;
expect(position.bottom, expectedBottom);
});
testWidgets('should adjust bottom position to screenEdgePadding when calculated bottom is too small', (tester) async {
// Arrange
const screenSize = Size(800, 600);
const buttonPosition = Offset(100, 595); // Very close to bottom edge
const modalWidth = 440.0;
const modalHeight = 400.0;
BuildContext? capturedContext;
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(size: screenSize),
child: Builder(
builder: (context) {
capturedContext = context;
return Container();
},
),
),
),
);
// Act
final position = calculateModalPosition(
context: capturedContext!,
buttonPosition: buttonPosition,
modalWidth: modalWidth,
modalHeight: modalHeight,
);
// Assert
// bottom = 600 - 595 + 8 = 13, which is < 16, so it should be adjusted to 16
expect(position.bottom, AIScribeSizes.screenEdgePadding);
});
});
}