feat(ai-scribe): reorganize abstractions and update dialog modal UI
This commit is contained in:
+17
-9
@@ -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';
|
||||
|
||||
@@ -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:
|
||||
|
||||
+28
@@ -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;
|
||||
}
|
||||
+35
@@ -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();
|
||||
}
|
||||
+13
@@ -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();
|
||||
}
|
||||
}
|
||||
+134
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+42
@@ -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,
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+136
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+42
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+36
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+28
-13
@@ -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
@@ -539,7 +539,7 @@ packages:
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_svg:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1
|
||||
|
||||
@@ -36,6 +36,8 @@ dependencies:
|
||||
|
||||
json_annotation: 4.8.0
|
||||
|
||||
flutter_svg: 2.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
+153
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user