diff --git a/lib/app.dart b/lib/app.dart index a29b124b..cf0196be 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,6 +1,7 @@ // ignore_for_file: use_build_context_synchronously import 'package:flutter/material.dart'; +import 'package:flutter_portal/flutter_portal.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:window_manager/window_manager.dart' hide WindowCaption; import 'widgets/widgets.dart' show WindowCaption; @@ -106,41 +107,43 @@ class DashApp extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final isDarkMode = ref.watch(settingsProvider.select((value) => value.isDark)); - return MaterialApp( - debugShowCheckedModeBanner: false, - theme: ThemeData( - fontFamily: kFontFamily, - fontFamilyFallback: kFontFamilyFallback, - colorSchemeSeed: kColorSchemeSeed, - useMaterial3: true, - brightness: Brightness.light, - visualDensity: VisualDensity.adaptivePlatformDensity, - ), - darkTheme: ThemeData( - fontFamily: kFontFamily, - fontFamilyFallback: kFontFamilyFallback, - colorSchemeSeed: kColorSchemeSeed, - useMaterial3: true, - brightness: Brightness.dark, - visualDensity: VisualDensity.adaptivePlatformDensity, - ), - themeMode: isDarkMode ? ThemeMode.dark : ThemeMode.light, - home: Stack( - children: [ - !kIsLinux && !kIsMobile - ? const App() - : context.isMediumWindow - ? const MobileDashboard() - : const Dashboard(), - if (kIsWindows) - SizedBox( - height: 29, - child: WindowCaption( - backgroundColor: Colors.transparent, - brightness: isDarkMode ? Brightness.dark : Brightness.light, + return Portal( + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData( + fontFamily: kFontFamily, + fontFamilyFallback: kFontFamilyFallback, + colorSchemeSeed: kColorSchemeSeed, + useMaterial3: true, + brightness: Brightness.light, + visualDensity: VisualDensity.adaptivePlatformDensity, + ), + darkTheme: ThemeData( + fontFamily: kFontFamily, + fontFamilyFallback: kFontFamilyFallback, + colorSchemeSeed: kColorSchemeSeed, + useMaterial3: true, + brightness: Brightness.dark, + visualDensity: VisualDensity.adaptivePlatformDensity, + ), + themeMode: isDarkMode ? ThemeMode.dark : ThemeMode.light, + home: Stack( + children: [ + !kIsLinux && !kIsMobile + ? const App() + : context.isMediumWindow + ? const MobileDashboard() + : const Dashboard(), + if (kIsWindows) + SizedBox( + height: 29, + child: WindowCaption( + backgroundColor: Colors.transparent, + brightness: isDarkMode ? Brightness.dark : Brightness.light, + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/consts.dart b/lib/consts.dart index a7da2f26..85e2d4aa 100644 --- a/lib/consts.dart +++ b/lib/consts.dart @@ -66,6 +66,7 @@ const kFormDataButtonLabelTextStyle = TextStyle( fontWeight: FontWeight.w600, ); +const kBorderRadius4 = BorderRadius.all(Radius.circular(4)); const kBorderRadius8 = BorderRadius.all(Radius.circular(8)); final kBorderRadius10 = BorderRadius.circular(10); const kBorderRadius12 = BorderRadius.all(Radius.circular(12)); @@ -286,6 +287,8 @@ enum FormDataType { text, file } enum EnvironmentVariableType { variable, secret } +final kEnvVarRegEx = RegExp(r'{{(.*?)}}'); + const kSupportedUriSchemes = ["https", "http"]; const kDefaultUriScheme = "https"; const kMethodsWithBody = [ diff --git a/lib/models/environment_model.dart b/lib/models/environment_model.dart index caab30f7..0454e98f 100644 --- a/lib/models/environment_model.dart +++ b/lib/models/environment_model.dart @@ -42,3 +42,27 @@ const kEnvironmentVariableEmptyModel = EnvironmentVariableModel(key: "", value: ""); const kEnvironmentSecretEmptyModel = EnvironmentVariableModel( key: "", value: "", type: EnvironmentVariableType.secret); + +class EnvironmentVariableSuggestion { + final String environmentId; + final EnvironmentVariableModel variable; + final bool isUnknown; + + const EnvironmentVariableSuggestion({ + required this.environmentId, + required this.variable, + this.isUnknown = false, + }); + + EnvironmentVariableSuggestion copyWith({ + String? environmentId, + EnvironmentVariableModel? variable, + bool? isUnknown, + }) { + return EnvironmentVariableSuggestion( + environmentId: environmentId ?? this.environmentId, + variable: variable ?? this.variable, + isUnknown: isUnknown ?? this.isUnknown, + ); + } +} diff --git a/lib/providers/ui_providers.dart b/lib/providers/ui_providers.dart index e7d67038..fe0d7388 100644 --- a/lib/providers/ui_providers.dart +++ b/lib/providers/ui_providers.dart @@ -6,6 +6,7 @@ final mobileScaffoldKeyStateProvider = StateProvider>( final leftDrawerStateProvider = StateProvider((ref) => false); final navRailIndexStateProvider = StateProvider((ref) => 0); final selectedIdEditStateProvider = StateProvider((ref) => null); +final environmentFieldEditStateProvider = StateProvider((ref) => null); final codePaneVisibleStateProvider = StateProvider((ref) => false); final saveDataStateProvider = StateProvider((ref) => false); final clearDataStateProvider = StateProvider((ref) => false); diff --git a/lib/screens/common/environment_autocomplete.dart b/lib/screens/common/environment_autocomplete.dart new file mode 100644 index 00000000..b4eb8d24 --- /dev/null +++ b/lib/screens/common/environment_autocomplete.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_portal/flutter_portal.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:apidash/consts.dart'; +import 'package:apidash/models/environment_model.dart'; +import 'package:apidash/providers/providers.dart'; +import 'package:apidash/utils/envvar_utils.dart'; +import 'package:apidash/widgets/widgets.dart'; + +class EnvironmentAutocompleteField extends HookConsumerWidget { + const EnvironmentAutocompleteField({ + super.key, + required this.keyId, + this.initialValue, + this.onChanged, + this.onFieldSubmitted, + this.style, + this.decoration, + }); + + final String keyId; + final String? initialValue; + final void Function(String)? onChanged; + final void Function(String)? onFieldSubmitted; + final TextStyle? style; + final InputDecoration? decoration; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mentionValue = ref.watch(environmentFieldEditStateProvider); + final envMap = ref.watch(availableEnvironmentVariablesStateProvider); + final activeEnvironmentId = ref.watch(activeEnvironmentIdStateProvider); + final initialMentions = + getMentions(initialValue, envMap, activeEnvironmentId); + final suggestions = getEnvironmentVariableSuggestions( + mentionValue, envMap, activeEnvironmentId); + return EnvironmentAutocompleteFieldBase( + key: Key(keyId), + mentionValue: mentionValue, + onMentionValueChanged: (value) { + ref.read(environmentFieldEditStateProvider.notifier).state = value; + }, + initialValue: initialValue, + initialMentions: initialMentions, + suggestions: suggestions, + onChanged: onChanged, + onFieldSubmitted: onFieldSubmitted, + style: style, + decoration: decoration, + ); + } +} + +class EnvironmentVariableSpan extends HookConsumerWidget { + const EnvironmentVariableSpan({ + super.key, + required this.suggestion, + }); + + final EnvironmentVariableSuggestion suggestion; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final environments = ref.watch(environmentsStateNotifierProvider); + final envMap = ref.watch(availableEnvironmentVariablesStateProvider); + final activeEnvironmentId = ref.watch(activeEnvironmentIdStateProvider); + + final currentSuggestion = + getCurrentVariableStatus(suggestion, envMap, activeEnvironmentId); + + final showPopover = useState(false); + + final isMissingVariable = currentSuggestion.isUnknown; + final String scope = isMissingVariable + ? 'unknown' + : getEnvironmentTitle( + environments?[currentSuggestion.environmentId]?.name); + final colorScheme = Theme.of(context).colorScheme; + + var text = Text( + '{{${currentSuggestion.variable.key}}}', + style: TextStyle( + color: isMissingVariable ? colorScheme.error : colorScheme.primary, + fontWeight: FontWeight.w600), + ); + + return PortalTarget( + visible: showPopover.value, + portalFollower: MouseRegion( + onEnter: (_) { + showPopover.value = true; + }, + onExit: (_) { + showPopover.value = false; + }, + child: + EnvironmentPopoverCard(suggestion: currentSuggestion, scope: scope), + ), + anchor: const Aligned( + follower: Alignment.bottomCenter, + target: Alignment.topCenter, + backup: Aligned( + follower: Alignment.topCenter, + target: Alignment.bottomCenter, + ), + ), + child: kIsMobile + ? TapRegion( + onTapInside: (_) { + showPopover.value = true; + }, + onTapOutside: (_) { + showPopover.value = false; + }, + child: text, + ) + : MouseRegion( + onEnter: (_) { + showPopover.value = true; + }, + onExit: (_) { + showPopover.value = false; + }, + child: text, + ), + ); + } +} + +class EnvironmentPopoverCard extends StatelessWidget { + const EnvironmentPopoverCard({ + super.key, + required this.suggestion, + required this.scope, + }); + + final EnvironmentVariableSuggestion suggestion; + final String scope; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.card, + borderRadius: kBorderRadius8, + elevation: 8, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 200), + child: Ink( + padding: kP8, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: kBorderRadius8, + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + EnvironmentIndicator(suggestion: suggestion), + kHSpacer10, + Text(suggestion.variable.key), + ], + ), + kVSpacer5, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'VALUE', + style: Theme.of(context).textTheme.labelSmall, + ), + kHSpacer10, + Text(suggestion.variable.value), + ], + ), + kVSpacer5, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'SCOPE', + style: Theme.of(context).textTheme.labelSmall, + ), + kHSpacer10, + Text(scope), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/common/environment_textfields.dart b/lib/screens/common/environment_textfields.dart new file mode 100644 index 00000000..ad44a3b9 --- /dev/null +++ b/lib/screens/common/environment_textfields.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:apidash/consts.dart'; +import 'environment_autocomplete.dart'; + +class EnvURLField extends StatelessWidget { + const EnvURLField({ + super.key, + required this.selectedId, + this.initialValue, + this.onChanged, + this.onFieldSubmitted, + }); + + final String selectedId; + final String? initialValue; + final void Function(String)? onChanged; + final void Function(String)? onFieldSubmitted; + + @override + Widget build(BuildContext context) { + return EnvironmentAutocompleteField( + keyId: "url-$selectedId", + initialValue: initialValue, + style: kCodeStyle, + decoration: InputDecoration( + hintText: kHintTextUrlCard, + hintStyle: kCodeStyle.copyWith( + color: Theme.of(context).colorScheme.outline.withOpacity( + kHintOpacity, + ), + ), + border: InputBorder.none, + ), + onChanged: onChanged, + onFieldSubmitted: onFieldSubmitted, + ); + } +} + +class EnvCellField extends StatelessWidget { + const EnvCellField({ + super.key, + required this.keyId, + this.initialValue, + this.hintText, + this.onChanged, + this.colorScheme, + }); + + final String keyId; + final String? initialValue; + final String? hintText; + final void Function(String)? onChanged; + final ColorScheme? colorScheme; + + @override + Widget build(BuildContext context) { + var clrScheme = colorScheme ?? Theme.of(context).colorScheme; + return EnvironmentAutocompleteField( + keyId: keyId, + initialValue: initialValue, + style: kCodeStyle.copyWith( + color: clrScheme.onSurface, + ), + decoration: InputDecoration( + hintStyle: kCodeStyle.copyWith( + color: clrScheme.outline.withOpacity( + kHintOpacity, + ), + ), + hintText: hintText, + contentPadding: const EdgeInsets.only(bottom: 12), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: clrScheme.primary.withOpacity( + kHintOpacity, + ), + ), + ), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: clrScheme.surfaceVariant, + ), + ), + ), + onChanged: onChanged, + ); + } +} diff --git a/lib/screens/common/main_editor_widgets.dart b/lib/screens/common/main_editor_widgets.dart index 698dfeb1..7c3cd249 100644 --- a/lib/screens/common/main_editor_widgets.dart +++ b/lib/screens/common/main_editor_widgets.dart @@ -86,6 +86,9 @@ class EnvironmentDropdown extends ConsumerWidget { onChanged: (value) { ref.read(activeEnvironmentIdStateProvider.notifier).state = value?.id; + ref + .read(settingsProvider.notifier) + .update(activeEnvironmentId: value?.id); ref.read(hasUnsavedChangesProvider.notifier).state = true; }, )); diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/request_headers.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/request_headers.dart index 1b7eb3fd..c85d699b 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/request_headers.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/request_headers.dart @@ -6,6 +6,7 @@ import 'package:apidash/providers/providers.dart'; import 'package:apidash/widgets/widgets.dart'; import 'package:apidash/models/models.dart'; import 'package:apidash/consts.dart'; +import 'package:apidash/screens/common/environment_textfields.dart'; class EditRequestHeaders extends ConsumerStatefulWidget { const EditRequestHeaders({super.key}); @@ -129,7 +130,7 @@ class EditRequestHeadersState extends ConsumerState { ), ), DataCell( - CellField( + EnvCellField( keyId: "$selectedId-$index-headers-v-$seed", initialValue: headerRows[index].value, hintText: kHintAddValue, diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/request_params.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/request_params.dart index 41becac2..76aeb180 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/request_params.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/request_params.dart @@ -1,11 +1,12 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:data_table_2/data_table_2.dart'; import 'package:apidash/providers/providers.dart'; import 'package:apidash/widgets/widgets.dart'; import 'package:apidash/models/models.dart'; import 'package:apidash/consts.dart'; -import 'package:data_table_2/data_table_2.dart'; +import 'package:apidash/screens/common/environment_textfields.dart'; class EditRequestURLParams extends ConsumerStatefulWidget { const EditRequestURLParams({super.key}); @@ -103,7 +104,7 @@ class EditRequestURLParamsState extends ConsumerState { ), ), DataCell( - CellField( + EnvCellField( keyId: "$selectedId-$index-params-k-$seed", initialValue: paramRows[index].name, hintText: kHintAddURLParam, @@ -129,7 +130,7 @@ class EditRequestURLParamsState extends ConsumerState { ), ), DataCell( - CellField( + EnvCellField( keyId: "$selectedId-$index-params-v-$seed", initialValue: paramRows[index].value, hintText: kHintAddValue, diff --git a/lib/screens/home_page/editor_pane/url_card.dart b/lib/screens/home_page/editor_pane/url_card.dart index 749991d3..fd72d373 100644 --- a/lib/screens/home_page/editor_pane/url_card.dart +++ b/lib/screens/home_page/editor_pane/url_card.dart @@ -4,6 +4,7 @@ import 'package:apidash/providers/providers.dart'; import 'package:apidash/widgets/widgets.dart'; import 'package:apidash/consts.dart'; import 'package:apidash/extensions/extensions.dart'; +import '../../common/environment_textfields.dart'; class EditorPaneRequestURLCard extends StatelessWidget { const EditorPaneRequestURLCard({super.key}); @@ -82,7 +83,7 @@ class URLTextField extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final selectedId = ref.watch(selectedIdStateProvider); - return URLField( + return EnvURLField( selectedId: selectedId!, initialValue: ref .read(collectionStateNotifierProvider.notifier) diff --git a/lib/utils/envvar_utils.dart b/lib/utils/envvar_utils.dart index 7057d7f2..27eb32ae 100644 --- a/lib/utils/envvar_utils.dart +++ b/lib/utils/envvar_utils.dart @@ -1,5 +1,8 @@ import 'package:apidash/consts.dart'; import 'package:apidash/models/models.dart'; +import 'package:apidash/screens/common/environment_autocomplete.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; String getEnvironmentTitle(String? name) { if (name == null || name.trim() == "") { @@ -53,8 +56,7 @@ String? substituteVariables( combinedMap[variable.key] = variable.value; } - final regex = RegExp(r'{{(.*?)}}'); - String result = input.replaceAllMapped(regex, (match) { + String result = input.replaceAllMapped(kEnvVarRegEx, (match) { final key = match.group(1)?.trim() ?? ''; return combinedMap[key] ?? ''; }); @@ -74,14 +76,124 @@ HttpRequestModel substituteHttpRequestModel( )!, headers: httpRequestModel.headers?.map((header) { return header.copyWith( + name: + substituteVariables(header.name, envMap, activeEnvironmentId) ?? "", value: substituteVariables(header.value, envMap, activeEnvironmentId), ); }).toList(), params: httpRequestModel.params?.map((param) { return param.copyWith( + name: + substituteVariables(param.name, envMap, activeEnvironmentId) ?? "", value: substituteVariables(param.value, envMap, activeEnvironmentId), ); }).toList(), ); return newRequestModel; } + +List<(String, Object?, Widget?)> getMentions( + String? text, + Map> envMap, + String? activeEnvironmentId) { + if (text == null) { + return []; + } + final mentions = <(String, Object?, Widget?)>[]; + + final matches = kEnvVarRegEx.allMatches(text); + + for (final match in matches) { + final variableName = match.group(1); + EnvironmentVariableModel? variable; + String? environmentId; + + for (final entry in envMap.entries) { + variable = entry.value.firstWhereOrNull((v) => v.key == variableName); + if (variable != null) { + environmentId = entry.key; + break; + } + } + + final suggestion = EnvironmentVariableSuggestion( + environmentId: variable == null ? "unknown" : environmentId!, + variable: variable ?? + kEnvironmentVariableEmptyModel.copyWith( + key: variableName ?? "", + ), + isUnknown: variable == null); + mentions.add(( + '{{${variable?.key ?? variableName}}}', + suggestion, + EnvironmentVariableSpan(suggestion: suggestion) + )); + } + + return mentions; +} + +EnvironmentVariableSuggestion getCurrentVariableStatus( + EnvironmentVariableSuggestion currentSuggestion, + Map> envMap, + String? activeEnvironmentId) { + if (activeEnvironmentId != null) { + final variable = envMap[activeEnvironmentId]! + .firstWhereOrNull((v) => v.key == currentSuggestion.variable.key); + if (variable != null) { + return currentSuggestion.copyWith( + environmentId: activeEnvironmentId, + variable: variable, + isUnknown: false, + ); + } + } + + final globalVariable = envMap[kGlobalEnvironmentId] + ?.firstWhereOrNull((v) => v.key == currentSuggestion.variable.key); + if (globalVariable != null) { + return currentSuggestion.copyWith( + environmentId: kGlobalEnvironmentId, + variable: globalVariable, + isUnknown: false, + ); + } + + return currentSuggestion.copyWith( + isUnknown: true, + variable: currentSuggestion.variable.copyWith(value: "unknown")); +} + +List? getEnvironmentVariableSuggestions( + String? query, + Map> envMap, + String? activeEnvironmentId) { + if (query == null || kEnvVarRegEx.hasMatch(query)) return null; + + query = query.substring(1); + + final suggestions = []; + final Set addedVariableKeys = {}; + + if (activeEnvironmentId != null && envMap[activeEnvironmentId] != null) { + for (final variable in envMap[activeEnvironmentId]!) { + if ((query.isEmpty || variable.key.contains(query)) && + !addedVariableKeys.contains(variable.key)) { + suggestions.add(EnvironmentVariableSuggestion( + environmentId: activeEnvironmentId, variable: variable)); + addedVariableKeys.add(variable.key); + } + } + } + + envMap[kGlobalEnvironmentId]?.forEach((variable) { + if ((query!.isEmpty || variable.key.contains(query)) && + !addedVariableKeys.contains(variable.key)) { + suggestions.add(EnvironmentVariableSuggestion( + environmentId: kGlobalEnvironmentId, variable: variable)); + addedVariableKeys.add(variable.key); + } + }); + + return suggestions; +} diff --git a/lib/widgets/environment_field.dart b/lib/widgets/environment_field.dart new file mode 100644 index 00000000..3f95bb26 --- /dev/null +++ b/lib/widgets/environment_field.dart @@ -0,0 +1,261 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_portal/flutter_portal.dart'; +import 'package:mention_tag_text_field/mention_tag_text_field.dart'; +import 'package:apidash/models/models.dart'; +import 'package:apidash/consts.dart'; +import '../screens/common/environment_autocomplete.dart'; + +class EnvironmentAutocompleteFieldBase extends StatefulHookWidget { + const EnvironmentAutocompleteFieldBase({ + super.key, + this.initialValue, + this.onChanged, + this.onFieldSubmitted, + this.style, + this.decoration, + this.initialMentions, + this.suggestions, + this.mentionValue, + required this.onMentionValueChanged, + }); + + final String? initialValue; + final void Function(String)? onChanged; + final void Function(String)? onFieldSubmitted; + final TextStyle? style; + final InputDecoration? decoration; + final List<(String, Object?, Widget?)>? initialMentions; + final List? suggestions; + final String? mentionValue; + final void Function(String?) onMentionValueChanged; + + @override + State createState() => + _EnvironmentAutocompleteFieldBaseState(); +} + +class _EnvironmentAutocompleteFieldBaseState + extends State { + final MentionTagTextEditingController controller = + MentionTagTextEditingController(); + + final FocusNode focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + controller.text = widget.initialValue ?? ""; + } + + @override + Widget build(BuildContext context) { + final isSuggestionsVisible = useState(false); + + return PortalTarget( + visible: isSuggestionsVisible.value && focusNode.hasFocus, + portalFollower: EnvironmentSuggestionsMenu( + mentionController: controller, + suggestions: widget.suggestions, + onSelect: (suggestion) { + controller.addMention( + label: '{${suggestion.variable.key}}}', + data: suggestion, + stylingWidget: EnvironmentVariableSpan(suggestion: suggestion)); + widget.onChanged?.call(controller.text); + widget.onMentionValueChanged.call(null); + isSuggestionsVisible.value = false; + var mentionsCharacters = + controller.mentions.fold(0, (previousValue, element) { + return previousValue + element.variable.key.length + 4 as int; + }); + controller.selection = TextSelection.collapsed( + offset: controller.text.length + + controller.mentions.length - + mentionsCharacters, + ); + }, + ), + anchor: const Aligned( + follower: Alignment.topLeft, + target: Alignment.bottomLeft, + backup: Aligned( + follower: Alignment.bottomLeft, + target: Alignment.topLeft, + ), + ), + child: EnvironmentMentionField( + focusNode: focusNode, + controller: controller, + initialMentions: widget.initialMentions ?? [], + mentionValue: widget.mentionValue, + onMentionValueChanged: widget.onMentionValueChanged, + isSuggestionsVisible: isSuggestionsVisible, + onChanged: widget.onChanged, + onFieldSubmitted: widget.onFieldSubmitted, + style: widget.style, + decoration: widget.decoration, + ), + ); + } +} + +class EnvironmentMentionField extends StatelessWidget { + const EnvironmentMentionField({ + super.key, + required this.focusNode, + required this.controller, + required this.initialMentions, + required this.mentionValue, + required this.onMentionValueChanged, + required this.isSuggestionsVisible, + this.onChanged, + this.onFieldSubmitted, + this.style, + this.decoration, + }); + + final FocusNode focusNode; + final MentionTagTextEditingController controller; + final List<(String, Object?, Widget?)> initialMentions; + final String? mentionValue; + final void Function(String?) onMentionValueChanged; + final ValueNotifier isSuggestionsVisible; + final void Function(String)? onChanged; + final void Function(String)? onFieldSubmitted; + final TextStyle? style; + final InputDecoration? decoration; + + void onMention(String? value) { + onMentionValueChanged.call(value); + if (value != null) { + isSuggestionsVisible.value = true; + } else { + isSuggestionsVisible.value = false; + } + } + + @override + Widget build(BuildContext context) { + return MentionTagTextFormField( + focusNode: focusNode, + onTap: () { + focusNode.requestFocus(); + }, + onTapOutside: (event) { + focusNode.unfocus(); + isSuggestionsVisible.value = false; + }, + controller: controller, + style: style, + initialMentions: initialMentions, + onMention: onMention, + onChanged: (value) { + onChanged?.call(controller.text); + }, + onFieldSubmitted: (value) { + onFieldSubmitted?.call(controller.text); + isSuggestionsVisible.value = false; + }, + decoration: decoration, + mentionTagDecoration: const MentionTagDecoration( + mentionStart: ['{'], + mentionBreak: + " ", // This is a workaround for the exception but adds a space after the mention + maxWords: 1, + allowDecrement: false, + allowEmbedding: true, + showMentionStartSymbol: false, + ), + ); + } +} + +class EnvironmentSuggestionsMenu extends StatelessWidget { + const EnvironmentSuggestionsMenu({ + super.key, + required this.mentionController, + required this.suggestions, + this.onSelect, + }); + + final MentionTagTextEditingController mentionController; + final List? suggestions; + final Function(EnvironmentVariableSuggestion)? onSelect; + + @override + Widget build(BuildContext context) { + return suggestions == null || suggestions!.isEmpty + ? const SizedBox.shrink() + : ClipRRect( + borderRadius: kBorderRadius8, + child: Material( + type: MaterialType.card, + elevation: 8, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: Ink( + width: 300, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: kBorderRadius8, + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: ListView.separated( + shrinkWrap: true, + itemCount: suggestions?.length ?? 0, + separatorBuilder: (context, index) => const Divider( + height: 2, + ), + itemBuilder: (context, index) { + final suggestion = suggestions![index]; + return ListTile( + dense: true, + leading: EnvironmentIndicator(suggestion: suggestion), + title: Text(suggestion.variable.key), + subtitle: Text(suggestion.variable.value), + onTap: () { + onSelect?.call(suggestions![index]); + }, + ); + }, + ), + ), + ), + ), + ); + } +} + +class EnvironmentIndicator extends StatelessWidget { + const EnvironmentIndicator({super.key, required this.suggestion}); + + final EnvironmentVariableSuggestion suggestion; + + @override + Widget build(BuildContext context) { + final isUnknown = suggestion.isUnknown; + final isGlobal = suggestion.environmentId == kGlobalEnvironmentId; + return Container( + padding: kP4, + decoration: BoxDecoration( + color: isUnknown + ? Theme.of(context).colorScheme.errorContainer + : isGlobal + ? Theme.of(context).colorScheme.secondaryContainer + : Theme.of(context).colorScheme.primaryContainer, + borderRadius: kBorderRadius4, + ), + child: Icon( + isUnknown + ? Icons.block + : isGlobal + ? Icons.public + : Icons.computer, + size: 16, + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index bba295f5..8bcb12b6 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -6,6 +6,7 @@ export 'codegen_previewer.dart'; export 'dropdowns.dart'; export 'editor_json.dart'; export 'editor.dart'; +export 'environment_field.dart'; export 'error_message.dart'; export 'headerfield.dart'; export 'intro_message.dart'; diff --git a/pubspec.lock b/pubspec.lock index 4e4b5ddb..de82d387 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -470,6 +470,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.23" + flutter_portal: + dependency: "direct main" + description: + name: flutter_portal + sha256: "4601b3dc24f385b3761721bd852a3f6c09cddd4e943dd184ed58ee1f43006257" + url: "https://pub.dev" + source: hosted + version: "1.1.4" flutter_riverpod: dependency: "direct main" description: @@ -809,6 +817,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.8.0" + mention_tag_text_field: + dependency: "direct main" + description: + name: mention_tag_text_field + sha256: b0e831f5fc8ca942a835916fd084e6f5054226715affc740c613cd320004b8a7 + url: "https://pub.dev" + source: hosted + version: "0.0.4" meta: dependency: transitive description: @@ -1520,5 +1536,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.3.0 <4.0.0" + dart: ">=3.3.4 <4.0.0" flutter: ">=3.19.2" diff --git a/pubspec.yaml b/pubspec.yaml index 922f98df..2b050d94 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,6 +62,8 @@ dependencies: file_selector: ^1.0.3 hooks_riverpod: ^2.5.1 flutter_hooks: ^0.20.5 + flutter_portal: ^1.1.4 + mention_tag_text_field: ^0.0.4 dependency_overrides: web: ^0.5.0