mirror of
https://github.com/foss42/apidash.git
synced 2025-08-06 13:51:20 +08:00
feat: environment autocomplete fields
This commit is contained in:
@ -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,7 +107,8 @@ class DashApp extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDarkMode =
|
||||
ref.watch(settingsProvider.select((value) => value.isDark));
|
||||
return MaterialApp(
|
||||
return Portal(
|
||||
child: MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
fontFamily: kFontFamily,
|
||||
@ -142,6 +144,7 @@ class DashApp extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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 = [
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ final mobileScaffoldKeyStateProvider = StateProvider<GlobalKey<ScaffoldState>>(
|
||||
final leftDrawerStateProvider = StateProvider<bool>((ref) => false);
|
||||
final navRailIndexStateProvider = StateProvider<int>((ref) => 0);
|
||||
final selectedIdEditStateProvider = StateProvider<String?>((ref) => null);
|
||||
final environmentFieldEditStateProvider = StateProvider<String?>((ref) => null);
|
||||
final codePaneVisibleStateProvider = StateProvider<bool>((ref) => false);
|
||||
final saveDataStateProvider = StateProvider<bool>((ref) => false);
|
||||
final clearDataStateProvider = StateProvider<bool>((ref) => false);
|
||||
|
200
lib/screens/common/environment_autocomplete.dart
Normal file
200
lib/screens/common/environment_autocomplete.dart
Normal file
@ -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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
89
lib/screens/common/environment_textfields.dart
Normal file
89
lib/screens/common/environment_textfields.dart
Normal file
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
},
|
||||
));
|
||||
|
@ -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<EditRequestHeaders> {
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
CellField(
|
||||
EnvCellField(
|
||||
keyId: "$selectedId-$index-headers-v-$seed",
|
||||
initialValue: headerRows[index].value,
|
||||
hintText: kHintAddValue,
|
||||
|
@ -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<EditRequestURLParams> {
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
CellField(
|
||||
EnvCellField(
|
||||
keyId: "$selectedId-$index-params-k-$seed",
|
||||
initialValue: paramRows[index].name,
|
||||
hintText: kHintAddURLParam,
|
||||
@ -129,7 +130,7 @@ class EditRequestURLParamsState extends ConsumerState<EditRequestURLParams> {
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
CellField(
|
||||
EnvCellField(
|
||||
keyId: "$selectedId-$index-params-v-$seed",
|
||||
initialValue: paramRows[index].value,
|
||||
hintText: kHintAddValue,
|
||||
|
@ -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)
|
||||
|
@ -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<String, List<EnvironmentVariableModel>> 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<String, List<EnvironmentVariableModel>> 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<EnvironmentVariableSuggestion>? getEnvironmentVariableSuggestions(
|
||||
String? query,
|
||||
Map<String, List<EnvironmentVariableModel>> envMap,
|
||||
String? activeEnvironmentId) {
|
||||
if (query == null || kEnvVarRegEx.hasMatch(query)) return null;
|
||||
|
||||
query = query.substring(1);
|
||||
|
||||
final suggestions = <EnvironmentVariableSuggestion>[];
|
||||
final Set<String> 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;
|
||||
}
|
||||
|
261
lib/widgets/environment_field.dart
Normal file
261
lib/widgets/environment_field.dart
Normal file
@ -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<EnvironmentVariableSuggestion>? suggestions;
|
||||
final String? mentionValue;
|
||||
final void Function(String?) onMentionValueChanged;
|
||||
|
||||
@override
|
||||
State<EnvironmentAutocompleteFieldBase> createState() =>
|
||||
_EnvironmentAutocompleteFieldBaseState();
|
||||
}
|
||||
|
||||
class _EnvironmentAutocompleteFieldBaseState
|
||||
extends State<EnvironmentAutocompleteFieldBase> {
|
||||
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<int>(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<bool> 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<EnvironmentVariableSuggestion>? 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
18
pubspec.lock
18
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"
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user