diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2ce108e1..64ecad0d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,6 +28,7 @@ env: TARGET_NAME_dmg: "Hiddify-MacOS" TARGET_NAME_pkg: "Hiddify-MacOS-Installer" TARGET_NAME_ipa: "Hiddify-iOS" + TARGET_NAME_ipa2: "Hiddify-iOS2" jobs: test: @@ -91,6 +92,7 @@ jobs: steps: - name: checkout uses: actions/checkout@v3 + - name: Import Apple Codesign Certificates if: ${{ inputs.upload-artifact && startsWith(matrix.os,'macos') }} uses: apple-actions/import-codesign-certs@v3 @@ -103,7 +105,7 @@ jobs: run: | mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles echo "${{secrets.NEW_APPLE_MOBILE_PROVISIONING_PROFILES_TARGZ_BASE64}}"|base64 --decode | tar xJ -C ~/Library/MobileDevice/Provisioning\ Profiles - ls ~/Library/MobileDevice/Provisioning\ Profiles + # # echo "${{secrets.NEW_APPLE_MOBILE_PROVISIONING_PROFILES_TARGZ_BASE64_2}}"|base64 --decode | tar xz -C ~/Library/MobileDevice/Provisioning\ Profiles # # echo "${{secrets.APPLE_DEVLOP_PROVISIONING_PROFILES_TARGZ_BASE64}}"|base64 --decode | tar xz -C ~/Library/MobileDevice/Provisioning\ Profiles diff --git a/lib/features/profile/data/profile_data_providers.dart b/lib/features/profile/data/profile_data_providers.dart index 26755634..52c636c3 100644 --- a/lib/features/profile/data/profile_data_providers.dart +++ b/lib/features/profile/data/profile_data_providers.dart @@ -1,6 +1,8 @@ import 'package:hiddify/core/database/database_provider.dart'; import 'package:hiddify/core/directories/directories_provider.dart'; import 'package:hiddify/core/http_client/http_client_provider.dart'; +import 'package:hiddify/features/config_option/data/config_option_data_providers.dart'; +import 'package:hiddify/features/config_option/notifier/config_option_notifier.dart'; import 'package:hiddify/features/profile/data/profile_data_source.dart'; import 'package:hiddify/features/profile/data/profile_path_resolver.dart'; import 'package:hiddify/features/profile/data/profile_repository.dart'; @@ -15,6 +17,7 @@ Future profileRepository(ProfileRepositoryRef ref) async { profileDataSource: ref.watch(profileDataSourceProvider), profilePathResolver: ref.watch(profilePathResolverProvider), singbox: ref.watch(singboxServiceProvider), + configOptionRepository: ref.watch(configOptionRepositoryProvider), httpClient: ref.watch(httpClientProvider), ); await repo.init().getOrElse((l) => throw l).run(); diff --git a/lib/features/profile/data/profile_repository.dart b/lib/features/profile/data/profile_repository.dart index f6c2c12e..e6537017 100644 --- a/lib/features/profile/data/profile_repository.dart +++ b/lib/features/profile/data/profile_repository.dart @@ -6,6 +6,8 @@ import 'package:fpdart/fpdart.dart'; import 'package:hiddify/core/database/app_database.dart'; import 'package:hiddify/core/http_client/dio_http_client.dart'; import 'package:hiddify/core/utils/exception_handler.dart'; +import 'package:hiddify/features/config_option/data/config_option_repository.dart'; +import 'package:hiddify/features/connection/model/connection_failure.dart'; import 'package:hiddify/features/profile/data/profile_data_mapper.dart'; import 'package:hiddify/features/profile/data/profile_data_source.dart'; import 'package:hiddify/features/profile/data/profile_parser.dart'; @@ -36,7 +38,10 @@ abstract interface class ProfileRepository { bool markAsActive = false, CancelToken? cancelToken, }); - + TaskEither updateContent( + String profileId, + String content, + ); TaskEither addByContent( String content, { required String name, @@ -67,12 +72,14 @@ class ProfileRepositoryImpl with ExceptionHandler, InfraLogger implements Profil required this.profileDataSource, required this.profilePathResolver, required this.singbox, + required this.configOptionRepository, required this.httpClient, }); final ProfileDataSource profileDataSource; final ProfilePathResolver profilePathResolver; final SingboxService singbox; + final ConfigOptionRepository configOptionRepository; final DioHttpClient httpClient; @override @@ -177,6 +184,30 @@ class ProfileRepositoryImpl with ExceptionHandler, InfraLogger implements Profil ); } + @override + TaskEither updateContent( + String profileId, + String content, + ) { + return exceptionHandler( + () async { + final file = profilePathResolver.file(profileId); + final tempFile = profilePathResolver.tempFile(profileId); + + try { + await tempFile.writeAsString(content); + return await validateConfig(file.path, tempFile.path, false).run(); + } finally { + if (tempFile.existsSync()) tempFile.deleteSync(); + } + }, + (error, stackTrace) { + loggy.warning("error adding profile by content", error, stackTrace); + return ProfileUnexpectedFailure(error, stackTrace); + }, + ); + } + @override TaskEither addByContent( String content, { @@ -186,28 +217,22 @@ class ProfileRepositoryImpl with ExceptionHandler, InfraLogger implements Profil return exceptionHandler( () async { final profileId = const Uuid().v4(); - final file = profilePathResolver.file(profileId); - final tempFile = profilePathResolver.tempFile(profileId); - try { - await tempFile.writeAsString(content); - return await validateConfig(file.path, tempFile.path, false) - .andThen( - () => TaskEither(() async { - final profile = LocalProfileEntity( - id: profileId, - active: markAsActive, - name: name, - lastUpdate: DateTime.now(), - ); - await profileDataSource.insert(profile.toEntry()); - return right(unit); - }), - ) - .run(); - } finally { - if (tempFile.existsSync()) tempFile.deleteSync(); - } + return await updateContent(profileId, content) + .andThen( + () => TaskEither(() async { + final profile = LocalProfileEntity( + id: profileId, + active: markAsActive, + name: name, + lastUpdate: DateTime.now(), + ); + await profileDataSource.insert(profile.toEntry()); + + return right(unit); + }), + ) + .run(); }, (error, stackTrace) { loggy.warning("error adding profile by content", error, stackTrace); @@ -252,6 +277,10 @@ class ProfileRepositoryImpl with ExceptionHandler, InfraLogger implements Profil ($) async { final configFile = profilePathResolver.file(id); // TODO pass options + final options = await configOptionRepository.getConfigOptions(); + + singbox.changeOptions(options).mapLeft(InvalidConfigOption.new).run(); + return await $( singbox.generateFullConfigByPath(configFile.path).mapLeft(ProfileFailure.unexpected), ); diff --git a/lib/features/profile/details/json_editor.dart b/lib/features/profile/details/json_editor.dart new file mode 100644 index 00000000..461d116e --- /dev/null +++ b/lib/features/profile/details/json_editor.dart @@ -0,0 +1,1330 @@ +library json_editor_flutter; + +import 'dart:convert'; +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +const _space = 18.0; +const _textStyle = TextStyle(fontSize: 16); +const _options = Icon(Icons.more_horiz, size: 16); +const _expandIconWidth = 10.0; +const _rowHeight = 30.0; +const _popupMenuHeight = 30.0; +const _popupMenuItemPadding = 20.0; +const _textSpacer = SizedBox(width: 5); +const _newKey = "new_key_added"; +const _downArrow = SizedBox( + width: _expandIconWidth, + child: Icon(CupertinoIcons.arrowtriangle_down_fill, size: 14), +); +const _rightArrow = SizedBox( + width: _expandIconWidth, + child: Icon(CupertinoIcons.arrowtriangle_right_fill, size: 14), +); +const _newDataValue = { + _OptionItems.string: "", + _OptionItems.bool: false, + _OptionItems.num: 0, +}; +bool _enableMoreOptions = true; +bool _enableKeyEdit = true; +bool _enableValueEdit = true; + +enum _OptionItems { map, list, string, bool, num, delete } + +enum _SearchActions { next, prev } + +/// Supported editors for JSON Editor. +enum Editors { tree, text } + +/// Edit your JSON object with this Widget. Create, edit and format objects +/// using this user friendly widget. +class JsonEditor extends StatefulWidget { + /// JSON can be edited in two ways, Tree editor or text editor. You can disable + /// either of them. + /// + /// When UI editor is active, you can disable adding/deleting keys by using + /// [enableMoreOptions]. Editing keys and values can also be disabled by using + /// [enableKeyEdit] and [enableValueEdit]. + /// + /// When text editor is active, it will simply ignore [enableMoreOptions], + /// [enableKeyEdit] and [enableValueEdit]. + /// + /// [duration] is the debounce time for [onChanged] function. Defaults to + /// 500 milliseconds. + /// + /// [editors] is the supported list of editors. First element will be + /// used as default editor. Defaults to `[Editors.tree, Editors.text]`. + const JsonEditor({ + super.key, + required this.json, + required this.onChanged, + this.duration = const Duration(milliseconds: 500), + this.enableMoreOptions = true, + this.enableKeyEdit = true, + this.enableValueEdit = true, + this.editors = const [Editors.tree, Editors.text], + this.themeColor, + this.actions = const [], + this.enableHorizontalScroll = false, + this.searchDuration = const Duration(milliseconds: 500), + this.hideEditorsMenuButton = false, + this.expandedObjects = const [], + }) : assert(editors.length > 0, "editors list cannot be empty"); + + /// JSON string to be edited. + final String json; + + /// Callback function that will be called with the new [dynamic] data. + final ValueChanged onChanged; + + /// Debounce duration for [onChanged] function. + final Duration duration; + + /// Enables more options like adding or deleting data. Defaults to `true`. + final bool enableMoreOptions; + + /// Enables editing of keys. Defaults to `true`. + final bool enableKeyEdit; + + /// Enables editing of values. Defaults to `true`. + final bool enableValueEdit; + + /// Theme color for the editor. Changes the border color and header color. + final Color? themeColor; + + /// List of supported editors. First element will be used as default editor. + final List editors; + + /// A list of Widgets to display in a row at the end of header. + final List actions; + + /// Enables horizontal scroll for the tree view. Defaults to `false`. + final bool enableHorizontalScroll; + + /// Debounce duration for search function. + final Duration searchDuration; + + /// Hides the option of changing editor. Defaults to `false`. + final bool hideEditorsMenuButton; + + /// [expandedObjects] refers to the objects that will be expanded by + /// default. Index can be provided when the data is a List. + /// + /// Examples: + /// ```dart + /// data = { + /// "hobbies": ["Reading books", "Playing Cricket"], + /// "education": [ + /// {"name": "Bachelor of Engineering", "marks": 75}, + /// {"name": "Master of Engineering", "marks": 72}, + /// ], + /// } + /// ``` + /// + /// For the given data + /// 1. To expand education pass => `["education"]` + /// 2. To expand hobbies and education pass => `["hobbies", "education"]` + /// 3. To expand the first element (index 0) of education list, this means + /// we need to expand education too. In this case you need not to pass + /// "education" separately. Just pass a list of all nested objects => + /// `[["education", 0]]` + /// + /// ```dart + /// JsonEditor( + /// expandedObjects: const [ + /// "hobbies", + /// ["education", 0] // expands nested object in education + /// ], + /// onChanged: (_) {}, + /// json: jsonEncode(data), + /// ) + /// ``` + final List expandedObjects; + + @override + State createState() => _JsonEditorState(); +} + +class _JsonEditorState extends State { + Timer? _timer; + Timer? _searchTimer; + late dynamic _data; + late final _themeColor = widget.themeColor ?? Theme.of(context).primaryColor; + late Editors _editor = widget.editors.first; + bool _onError = false; + bool? allExpanded; + late final _controller = TextEditingController()..text = _stringifyData(_data, 0, true); + late final _scrollController = ScrollController(); + final _matchedKeys = {}; + final _matchedKeysLocation = []; + int? _focusedKey; + int? _results; + late final _expandedObjects = { + ["config"].toString(): true, + if (widget.expandedObjects.isNotEmpty) ...getExpandedParents(), + }; + + Map getExpandedParents() { + final map = {}; + for (var key in widget.expandedObjects) { + if (key is List) { + final newExpandList = ["config", ...key]; + for (int i = newExpandList.length - 1; i > 0; i--) { + map[newExpandList.toString()] = true; + newExpandList.removeLast(); + } + } else { + map[["config", key].toString()] = true; + } + } + return map; + } + + void callOnChanged() { + if (_timer?.isActive ?? false) _timer?.cancel(); + + _timer = Timer(widget.duration, () { + widget.onChanged(jsonDecode(jsonEncode(_data))); + }); + } + + void parseData(String value) { + if (_timer?.isActive ?? false) _timer?.cancel(); + + _timer = Timer(widget.duration, () { + try { + _data = jsonDecode(value); + widget.onChanged(_data); + setState(() { + _onError = false; + }); + } catch (_) { + setState(() { + _onError = true; + }); + } + }); + } + + void copyData() async { + await Clipboard.setData( + ClipboardData(text: jsonEncode(_data)), + ); + } + + bool updateParentObjects(List newExpandList) { + bool needsRebuilding = false; + for (int i = newExpandList.length - 1; i >= 0; i--) { + if (_expandedObjects[newExpandList.toString()] == null) { + _expandedObjects[newExpandList.toString()] = true; + needsRebuilding = true; + } + newExpandList.removeLast(); + } + return needsRebuilding; + } + + void findMatchingKeys(data, String text, List nestedParents) { + if (data is Map) { + final keys = data.keys.toList(); + for (var key in keys) { + final keyName = key.toString(); + if (keyName.toLowerCase().contains(text) || (data[key] is String && data[key].toString().toLowerCase().contains(text))) { + _results = _results! + 1; + _matchedKeys[keyName] = true; + _matchedKeysLocation.add([...nestedParents, key]); + } + if (data[key] is Map) { + findMatchingKeys(data[key], text, [...nestedParents, key]); + } else if (data[key] is List) { + findMatchingKeys(data[key], text, [...nestedParents, key]); + } + } + } else if (data is List) { + for (int i = 0; i < data.length; i++) { + final item = data[i]; + if (item is Map) { + findMatchingKeys(item, text, [...nestedParents, i]); + } else if (item is List) { + findMatchingKeys(item, text, [...nestedParents, i]); + } + } + } + } + + void onSearch(String text) { + if (_searchTimer?.isActive ?? false) _searchTimer?.cancel(); + + _searchTimer = Timer(widget.searchDuration, () async { + _matchedKeys.clear(); + _matchedKeysLocation.clear(); + _focusedKey = null; + if (text.isEmpty) { + setState(() { + _results = null; + }); + } else { + _results = 0; + findMatchingKeys(_data, text.toLowerCase(), ["config"]); + setState(() {}); + if (_matchedKeys.isNotEmpty) { + _focusedKey = 0; + scrollTo(0); + } + } + }); + } + + int getOffset(List toFind) { + int offset = 1; + bool keyFound = false; + + void calculateOffset(data, List parents, List toFind) { + if (keyFound) return; + if (data is Map) { + for (var entry in data.entries) { + if (keyFound) return; + offset++; + final newList = [...parents, entry.key]; + if (entry.key == toFind.last && newList.toString() == toFind.toString()) { + keyFound = true; + return; + } + if (entry.value is Map || entry.value is List) { + if (_expandedObjects[newList.toString()] == true && !keyFound) { + calculateOffset(entry.value, newList, toFind); + } + } + } + } else if (data is List) { + for (int i = 0; i < data.length; i++) { + if (keyFound) return; + offset++; + if (data[i] is Map || data[i] is List) { + final newList = [...parents, i]; + if (_expandedObjects[newList.toString()] == true && !keyFound) { + calculateOffset(data[i], newList, toFind); + } + } + } + } + } + + calculateOffset(_data, ["config"], toFind); + return offset; + } + + void scrollTo(int index) { + final toFind = [..._matchedKeysLocation[index]]; + final needsRebuilding = updateParentObjects( + [..._matchedKeysLocation[index]]..removeLast(), + ); + if (needsRebuilding) setState(() {}); + Future.delayed(const Duration(milliseconds: 150), () { + _scrollController.animateTo( + (getOffset(toFind) * _rowHeight) - 90, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + }); + } + + void onSearchAction(_SearchActions action) { + if (_matchedKeys.isEmpty) return; + if (action == _SearchActions.next) { + if (_focusedKey != null && _matchedKeysLocation.length - 1 > _focusedKey!) { + _focusedKey = _focusedKey! + 1; + } else { + _focusedKey = 0; + } + } else { + if (_focusedKey != null && _focusedKey! > 0) { + _focusedKey = _focusedKey! - 1; + } else { + _focusedKey = _matchedKeysLocation.length - 1; + } + } + scrollTo(_focusedKey!); + } + + void expandAllObjects(data, List expandedList) { + if (data is Map) { + for (var entry in data.entries) { + if (entry.value is Map || entry.value is List) { + final newList = [...expandedList, entry.key]; + _expandedObjects[newList.toString()] = true; + expandAllObjects(entry.value, newList); + } + } + } else if (data is List) { + for (int i = 0; i < data.length; i++) { + if (data[i] is Map || data[i] is List) { + final newList = [...expandedList, i]; + _expandedObjects[newList.toString()] = true; + expandAllObjects(data[i], newList); + } + } + } + } + + Widget wrapWithHorizontolScroll(Widget child) { + if (widget.enableHorizontalScroll) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: child, + ); + } + return child; + } + + @override + void initState() { + super.initState(); + _data = jsonDecode(widget.json); + _enableMoreOptions = widget.enableMoreOptions; + _enableKeyEdit = widget.enableKeyEdit; + _enableValueEdit = widget.enableValueEdit; + } + + @override + void dispose() { + _timer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + width: _onError ? 2 : 1, + color: _onError ? Colors.red : _themeColor, + ), + ), + child: SizedBox( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: _themeColor, + border: _onError + ? const Border( + bottom: BorderSide(color: Colors.red, width: 2), + ) + : null), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 6, + horizontal: 10, + ), + child: Row( + children: [ + if (!widget.hideEditorsMenuButton) + PopupMenuButton( + initialValue: _editor, + tooltip: 'Change editor', + padding: EdgeInsets.zero, + onSelected: (value) { + if (value == Editors.text) { + _controller.text = _stringifyData(_data, 0, true); + } + setState(() { + _editor = value; + }); + }, + position: PopupMenuPosition.under, + enabled: widget.editors.length > 1, + constraints: const BoxConstraints( + minWidth: 50, + maxWidth: 150, + ), + itemBuilder: (context) { + return >[ + PopupMenuItem( + height: _popupMenuHeight, + padding: const EdgeInsets.symmetric(horizontal: 12), + enabled: widget.editors.contains(Editors.tree), + value: Editors.tree, + child: const Text("Tree"), + ), + PopupMenuItem( + height: _popupMenuHeight, + padding: const EdgeInsets.symmetric(horizontal: 12), + enabled: widget.editors.contains(Editors.text), + value: Editors.text, + child: const Text("Text"), + ), + ]; + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_editor.name, style: _textStyle), + const Icon(Icons.arrow_drop_down, size: 20), + ], + ), + ), + const Spacer(), + if (_editor == Editors.text) ...[ + const SizedBox(width: 20), + InkWell( + onTap: () { + _controller.text = _stringifyData(_data, 0, true); + }, + child: const Tooltip( + message: 'Format', + child: Icon(Icons.format_align_left, size: 20), + ), + ), + ] else ...[ + const SizedBox(width: 20), + if (_results != null) ...[ + Text("$_results results"), + const SizedBox(width: 5), + ], + _SearchField(onSearch, onSearchAction), + const SizedBox(width: 20), + InkWell( + onTap: () { + _expandedObjects[["config"].toString()] = true; + expandAllObjects(_data, ["config"]); + setState(() {}); + }, + child: const Tooltip( + message: 'Expand All', + child: Icon(Icons.expand, size: 20), + ), + ), + const SizedBox(width: 20), + InkWell( + onTap: () { + _expandedObjects.clear(); + setState(() {}); + }, + child: const Tooltip( + message: 'Collapse All', + child: Icon(Icons.compress, size: 20), + ), + ), + ], + const SizedBox(width: 20), + InkWell( + onTap: copyData, + child: const Tooltip( + message: 'Copy', + child: Icon(Icons.copy, size: 20), + ), + ), + if (widget.actions.isNotEmpty) const SizedBox(width: 20), + ...widget.actions, + ], + ), + ), + ), + if (_editor == Editors.tree) + Expanded( + child: SingleChildScrollView( + controller: _scrollController, + physics: const ClampingScrollPhysics(), + child: wrapWithHorizontolScroll( + _Holder( + key: UniqueKey(), + data: _data, + keyName: "config", + paddingLeft: _space, + onChanged: callOnChanged, + parentObject: {"config": _data}, + setState: setState, + matchedKeys: _matchedKeys, + allParents: const ["config"], + expandedObjects: _expandedObjects, + ), + ), + ), + ), + if (_editor == Editors.text) + Expanded( + child: TextFormField( + controller: _controller, + onChanged: parseData, + maxLines: 10, + minLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.only( + left: 5, + top: 8, + bottom: 8, + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _Holder extends StatefulWidget { + const _Holder({ + super.key, + this.keyName, + required this.data, + required this.paddingLeft, + required this.onChanged, + required this.parentObject, + required this.setState, + required this.matchedKeys, + required this.allParents, + required this.expandedObjects, + }); + + final dynamic keyName; + final dynamic data; + final double paddingLeft; + final VoidCallback onChanged; + final dynamic parentObject; + final StateSetter setState; + final Map matchedKeys; + final List allParents; + final Map expandedObjects; + + @override + State<_Holder> createState() => _HolderState(); +} + +class _HolderState extends State<_Holder> { + late bool isExpanded = widget.expandedObjects[widget.allParents.toString()] == true; + + void _toggleState() { + if (!isExpanded) { + widget.expandedObjects[widget.allParents.toString()] = true; + } else { + widget.expandedObjects.remove(widget.allParents.toString()); + } + setState(() { + isExpanded = !isExpanded; + }); + } + + void onSelected(_OptionItems selectedItem) { + if (selectedItem == _OptionItems.delete) { + if (widget.parentObject is Map) { + widget.parentObject.remove(widget.keyName); + } else { + widget.parentObject.removeAt(widget.keyName); + } + + widget.setState(() {}); + } else if (selectedItem == _OptionItems.map) { + if (widget.data is Map) { + widget.data[_newKey] = Map(); + } else { + widget.data.add(Map()); + } + + setState(() {}); + } else if (selectedItem == _OptionItems.list) { + if (widget.data is Map) { + widget.data[_newKey] = []; + } else { + widget.data.add([]); + } + + setState(() {}); + } else { + if (widget.data is Map) { + widget.data[_newKey] = _newDataValue[selectedItem]; + } else { + widget.data.add(_newDataValue[selectedItem]); + } + + setState(() {}); + } + + widget.onChanged(); + } + + void onKeyChanged(Object key) { + final val = widget.parentObject.remove(widget.keyName); + widget.parentObject[key] = val; + + widget.onChanged(); + widget.setState(() {}); + } + + void onValueChanged(Object value) { + widget.parentObject[widget.keyName] = value; + + widget.onChanged(); + } + + Widget wrapWithColoredBox(Widget child, String key) { + if (widget.matchedKeys[key] == true) { + return ColoredBox(color: Theme.of(context).colorScheme.secondaryContainer, child: child); + } + return child; + } + + String getChildSummary(_Holder widget) { + final data = widget.data; + + var res = "{"; + if (data is Map) { + if (widget.expandedObjects[widget.allParents.toString()] ?? false) return ""; + final content = data as Map; + //res += "${data.length}"; + if (content["type"] != null) { + res += "${content["type"]}"; + } + if (content["tag"] != null) { + res += " [${content["tag"]}]"; + } else { + final d = "$content"; + res += " [${d.substring(0, min(20, d.length))}...]"; + } + } else if (data is List) { + final content = data as List; + res += "${content.length}"; + } + return res + "}"; + } + + @override + Widget build(BuildContext context) { + if (widget.data is Map) { + final mapWidget = []; + final widgetData = widget.data as Map; + final List keys = widgetData.keys.toList(); + for (var key in keys) { + mapWidget.add(_Holder( + key: Key(key), + data: widget.data[key], + keyName: key, + onChanged: widget.onChanged, + parentObject: widget.data, + paddingLeft: widget.paddingLeft + _space, + setState: setState, + matchedKeys: widget.matchedKeys, + allParents: [...widget.allParents, key], + expandedObjects: widget.expandedObjects, + )); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: _rowHeight, + child: Row( + children: [ + const SizedBox(width: _expandIconWidth), + if (_enableMoreOptions) _Options(onSelected), + SizedBox(width: widget.paddingLeft), + InkWell( + hoverColor: Colors.transparent, + splashColor: Colors.transparent, + onTap: _toggleState, + child: isExpanded ? _downArrow : _rightArrow, + ), + const SizedBox(width: _expandIconWidth), + if (_enableKeyEdit && widget.parentObject is! List) ...[ + _ReplaceTextWithField( + key: Key(widget.keyName.toString()), + initialValue: widget.keyName, + isKey: true, + onChanged: onKeyChanged, + setState: setState, + isHighlighted: widget.matchedKeys["${widget.keyName}"] == true, + ), + _textSpacer, + Text( + getChildSummary(widget), + style: _textStyle, + ), + ] else + InkWell( + hoverColor: Colors.transparent, + splashColor: Colors.transparent, + onTap: _toggleState, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + wrapWithColoredBox( + Text("${widget.keyName}", style: _textStyle), + "${widget.keyName}", + ), + _textSpacer, + Text(getChildSummary(widget), style: _textStyle), + ], + ), + ), + ], + ), + ), + if (isExpanded) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: mapWidget, + ), + ], + ); + } else if (widget.data is List) { + final listWidget = []; + final widgetData = widget.data as List; + for (int i = 0; i < widgetData.length; i++) { + listWidget.add(_Holder( + key: Key("$i"), + keyName: i, + data: widgetData[i], + onChanged: widget.onChanged, + parentObject: widget.data, + paddingLeft: widget.paddingLeft + _space, + setState: setState, + matchedKeys: widget.matchedKeys, + allParents: [...widget.allParents, i], + expandedObjects: widget.expandedObjects, + )); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: _rowHeight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: _expandIconWidth), + if (_enableMoreOptions) _Options(onSelected), + SizedBox(width: widget.paddingLeft), + InkWell( + hoverColor: Colors.transparent, + splashColor: Colors.transparent, + onTap: _toggleState, + child: isExpanded ? _downArrow : _rightArrow, + ), + const SizedBox(width: _expandIconWidth), + if (_enableKeyEdit && widget.parentObject is! List) ...[ + _ReplaceTextWithField( + key: Key(widget.keyName.toString()), + initialValue: widget.keyName, + isKey: true, + onChanged: onKeyChanged, + setState: setState, + isHighlighted: widget.matchedKeys["${widget.keyName}"] == true, + ), + _textSpacer, + Text( + "[${widget.data.length}]", + style: _textStyle, + ), + ] else + InkWell( + hoverColor: Colors.transparent, + splashColor: Colors.transparent, + onTap: _toggleState, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + wrapWithColoredBox( + Text("${widget.keyName}", style: _textStyle), + "${widget.keyName}", + ), + _textSpacer, + Text("[${widget.data.length}]", style: _textStyle), + ], + ), + ), + ], + ), + ), + if (isExpanded) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: listWidget, + ), + ], + ); + } else { + return SizedBox( + height: _rowHeight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: _expandIconWidth), + if (_enableMoreOptions) _Options(onSelected), + SizedBox( + width: widget.paddingLeft + (_expandIconWidth * 2), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_enableKeyEdit) ...[ + _ReplaceTextWithField( + key: Key(widget.keyName.toString()), + initialValue: widget.keyName, + isKey: true, + onChanged: onKeyChanged, + setState: setState, + isHighlighted: widget.matchedKeys["${widget.keyName}"] == true, + ), + const Text(' :', style: _textStyle), + ] else + Row( + mainAxisSize: MainAxisSize.min, + children: [ + wrapWithColoredBox( + Text("${widget.keyName}", style: _textStyle), + "${widget.keyName}", + ), + _textSpacer, + const Text(" :", style: _textStyle), + ], + ), + _textSpacer, + if (_enableValueEdit) ...[ + _ReplaceTextWithField( + key: UniqueKey(), + initialValue: widget.data, + onChanged: onValueChanged, + setState: setState, + ), + _textSpacer, + ] else ...[ + Text(widget.data.toString(), style: _textStyle), + _textSpacer, + ], + ], + ), + ], + ), + ); + } + } +} + +class _ReplaceTextWithField extends StatefulWidget { + const _ReplaceTextWithField({ + super.key, + required this.initialValue, + required this.onChanged, + required this.setState, + this.isKey = false, + this.isHighlighted = false, + }); + + final dynamic initialValue; + final bool isKey; + final ValueChanged onChanged; + final StateSetter setState; + final bool isHighlighted; + + @override + State<_ReplaceTextWithField> createState() => _ReplaceTextWithFieldState(); +} + +class _ReplaceTextWithFieldState extends State<_ReplaceTextWithField> { + late final _focusNode = FocusNode(); + bool _isFocused = false; + bool _value = false; + String _text = ""; + late final BoxConstraints _constraints; + + void handleChange() { + if (!_focusNode.hasFocus) { + _text = _text.trim(); + final val = num.tryParse(_text); + if (val == null) { + widget.onChanged(_text); + } else { + widget.onChanged(val); + } + + setState(() { + _isFocused = false; + }); + } + } + + Widget wrapWithColoredBox(String keyName) { + if (widget.isHighlighted) { + return ColoredBox( + color: Colors.amber, + child: Text(keyName, style: _textStyle), + ); + } + return Text(keyName, style: _textStyle); + } + + @override + void initState() { + super.initState(); + + if (widget.initialValue is bool) { + _value = widget.initialValue as bool; + } else { + if (widget.initialValue == _newKey) { + _text = ""; + _isFocused = true; + _focusNode.requestFocus(); + } else { + _text = widget.initialValue.toString(); + } + } + + if (widget.isKey) { + _constraints = const BoxConstraints(minWidth: 20, maxWidth: 100); + } else if (widget.initialValue is num) { + _constraints = const BoxConstraints(minWidth: 20, maxWidth: 80); + } else { + _constraints = const BoxConstraints(minWidth: 20, maxWidth: 200); + } + + _focusNode.addListener(handleChange); + } + + @override + void dispose() { + _focusNode.removeListener(handleChange); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.initialValue is bool) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Transform.scale( + scale: 0.75, + child: Checkbox( + visualDensity: const VisualDensity(horizontal: -4, vertical: -4), + value: _value, + onChanged: (value) { + widget.onChanged(value!); + setState(() { + _value = value; + }); + }, + ), + ), + Text(_value.toString(), style: _textStyle), + ], + ); + } else { + if (_isFocused) { + return TextFormField( + initialValue: _text, + focusNode: _focusNode, + onChanged: (value) => _text = value, + autocorrect: false, + cursorWidth: 1, + style: _textStyle, + cursorHeight: 12, + decoration: InputDecoration( + constraints: _constraints, + border: InputBorder.none, + fillColor: Colors.transparent, + filled: true, + isDense: true, + contentPadding: const EdgeInsets.all(3), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.zero, + borderSide: BorderSide(width: 0.3), + ), + ), + ); + } else { + return InkWell( + onTap: () { + setState(() { + _isFocused = true; + }); + _focusNode.requestFocus(); + }, + mouseCursor: MaterialStateMouseCursor.textable, + child: widget.initialValue is String && _text.isEmpty ? const SizedBox(width: 200, height: 18) : wrapWithColoredBox(_text), + ); + } + } + } +} + +class _Options extends StatelessWidget { + const _Options(this.onSelected); + + final void Function(_OptionItems) onSelected; + + @override + Widget build(BuildContext context) { + return PopupMenuButton<_OptionItems>( + tooltip: 'Add new object', + padding: EdgeInsets.zero, + onSelected: onSelected, + itemBuilder: (context) { + return >[ + if (T == Map) + const _PopupMenuWidget(Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(width: 5), + Icon(Icons.add), + SizedBox(width: 10), + Text("Insert", style: TextStyle(fontSize: 14)), + ], + )), + if (T == List) + const _PopupMenuWidget(Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(width: 5), + Icon(Icons.add), + SizedBox(width: 10), + Text("Append", style: TextStyle(fontSize: 14)), + ], + )), + if (T == Map || T == List) ...[ + const PopupMenuItem<_OptionItems>( + height: _popupMenuHeight, + padding: EdgeInsets.only(left: _popupMenuItemPadding), + value: _OptionItems.string, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.abc), + SizedBox(width: 10), + Text("String", style: TextStyle(fontSize: 14)), + ], + ), + ), + const PopupMenuItem<_OptionItems>( + height: _popupMenuHeight, + padding: EdgeInsets.only(left: _popupMenuItemPadding), + value: _OptionItems.num, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.onetwothree), + SizedBox(width: 10), + Text("Number", style: TextStyle(fontSize: 14)), + ], + ), + ), + const PopupMenuItem<_OptionItems>( + height: _popupMenuHeight, + padding: EdgeInsets.only(left: _popupMenuItemPadding), + value: _OptionItems.bool, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_rounded), + SizedBox(width: 10), + Text("Boolean", style: TextStyle(fontSize: 14)), + ], + ), + ), + const PopupMenuItem<_OptionItems>( + height: _popupMenuHeight, + padding: EdgeInsets.only(left: _popupMenuItemPadding), + value: _OptionItems.map, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.data_object), + SizedBox(width: 10), + Text("config", style: TextStyle(fontSize: 14)), + ], + ), + ), + const PopupMenuItem<_OptionItems>( + height: _popupMenuHeight, + padding: EdgeInsets.only(left: _popupMenuItemPadding), + value: _OptionItems.list, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.data_array), + SizedBox(width: 10), + Text("List", style: TextStyle(fontSize: 14)), + ], + ), + ), + ], + const PopupMenuDivider(height: 1), + const PopupMenuItem<_OptionItems>( + height: _popupMenuHeight, + padding: EdgeInsets.only(left: 5), + value: _OptionItems.delete, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.delete), + SizedBox(width: 10), + Text("Delete", style: TextStyle(fontSize: 14)), + ], + ), + ), + ]; + }, + child: _options, + ); + } +} + +class _PopupMenuWidget extends PopupMenuEntry { + const _PopupMenuWidget(this.child); + + final Widget child; + + @override + final double height = _popupMenuHeight; + + @override + bool represents(_) => false; + + @override + State<_PopupMenuWidget> createState() => _PopupMenuWidgetState(); +} + +class _PopupMenuWidgetState extends State<_PopupMenuWidget> { + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +class _SearchField extends StatelessWidget { + final ValueChanged onChanged; + final ValueChanged<_SearchActions> onAction; + + const _SearchField(this.onChanged, this.onAction); + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: Theme.of(context).searchBarTheme.backgroundColor?.resolve({}) ?? Colors.black, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 2), + const Icon(CupertinoIcons.search, size: 20), + const SizedBox(width: 5), + TextField( + onChanged: onChanged, + autocorrect: false, + cursorWidth: 1, + // style: _textStyle, + cursorHeight: 12, + decoration: InputDecoration( + hintText: "Search", + hintStyle: Theme.of(context).textTheme.bodySmall, + constraints: BoxConstraints(maxWidth: 100), + border: InputBorder.none, + // fillColor: Colors.transparent, + // filled: true, + isDense: true, + contentPadding: EdgeInsets.all(3), + focusedBorder: InputBorder.none, + // hoverColor: Colors.transparent, + ), + ), + const SizedBox(width: 5), + InkWell( + onTap: () { + onAction(_SearchActions.next); + }, + child: const Tooltip( + message: 'Next', + child: Icon( + CupertinoIcons.arrowtriangle_down_fill, + size: 20, + ), + ), + ), + const SizedBox(width: 2), + InkWell( + onTap: () { + onAction(_SearchActions.prev); + }, + child: const Tooltip( + message: 'Previous', + child: Icon( + CupertinoIcons.arrowtriangle_up_fill, + size: 20, + ), + ), + ), + const SizedBox(width: 5), + ], + ), + ); + } +} + +List _getSpace(int count) { + if (count == 0) return ['', ' ']; + + String space = ''; + for (int i = 0; i < count; i++) { + space += ' '; + } + return [space, '$space ']; +} + +String _stringifyData(data, int spacing, [bool isLast = false]) { + String str = ''; + final spaceList = _getSpace(spacing); + final objectSpace = spaceList[0]; + final dataSpace = spaceList[1]; + + if (data is Map) { + str += '$objectSpace{'; + str += '\n'; + final keys = data.keys.toList(); + for (int i = 0; i < keys.length; i++) { + str += '$dataSpace"${keys[i]}": ${_stringifyData(data[keys[i]], spacing + 1, i == keys.length - 1)}'; + str += '\n'; + } + str += '$objectSpace}'; + if (!isLast) str += ','; + } else if (data is List) { + str += '$objectSpace['; + str += '\n'; + for (int i = 0; i < data.length; i++) { + final item = data[i]; + if (item is Map || item is List) { + str += _stringifyData(item, spacing + 1, i == data.length - 1); + } else { + str += '$dataSpace${_stringifyData(item, spacing + 1, i == data.length - 1)}'; + } + str += '\n'; + } + str += '$objectSpace]'; + if (!isLast) str += ','; + } else { + if (data is String) { + str = '"$data"'; + } else { + str = '$data'; + } + if (!isLast) str += ','; + } + + return str; +} diff --git a/lib/features/profile/details/profile_details_notifier.dart b/lib/features/profile/details/profile_details_notifier.dart index b291bf6d..eb3a10dd 100644 --- a/lib/features/profile/details/profile_details_notifier.dart +++ b/lib/features/profile/details/profile_details_notifier.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:dartx/dartx.dart'; import 'package:fpdart/fpdart.dart'; import 'package:hiddify/features/profile/data/profile_data_providers.dart'; @@ -36,22 +38,53 @@ class ProfileDetailsNotifier extends _$ProfileDetailsNotifier with AppLogger { loggy.warning('failed to load profile', err); throw err; }, - (profile) { + (profile) async { if (profile == null) { loggy.warning('profile with id: [$id] does not exist'); throw const ProfileNotFoundFailure(); } + _originalProfile = profile; - return ProfileDetailsState(profile: profile, isEditing: true); + final result = await _profilesRepo.generateConfig(id).run(); + + var configContent = result.fold( + (failure) => throw Exception('Failed to generate config: $failure'), + (config) => config, + ); + if (configContent.isNotEmpty) { + try { + final jsonObject = jsonDecode(configContent); + List> res = []; + if (jsonObject is Map && jsonObject['outbounds'] is List) { + for (var outbound in jsonObject['outbounds'] as List) { + if (outbound is Map && outbound['type'] != null && !['selector', 'urltest', 'dns', 'block'].contains(outbound['type']) && !['direct', 'bypass', 'direct-fragment'].contains(outbound['tag'])) { + res.add(outbound); + } + } + } else { + // print('No outbounds found in the config'); + } + configContent = '{"outbounds": ${json.encode(res)}}'; + } catch (e) { + // print('Error parsing JSON: $e'); + } + } else { + // print('Config content is null or empty'); + } + return ProfileDetailsState(profile: profile, isEditing: true, configContent: configContent); }, ); } - ProfileRepository get _profilesRepo => - ref.read(profileRepositoryProvider).requireValue; + ProfileRepository get _profilesRepo => ref.read(profileRepositoryProvider).requireValue; ProfileEntity? _originalProfile; - void setField({String? name, String? url, Option? updateInterval}) { + void setField({ + String? name, + String? url, + Option? updateInterval, + String? configContent, + }) { if (state case AsyncData(:final value)) { state = AsyncData( value.copyWith( @@ -70,6 +103,8 @@ class ProfileDetailsNotifier extends _$ProfileDetailsNotifier with AppLogger { ), local: (lp) => lp.copyWith(name: name ?? lp.name), ), + configContentChanged: value.configContentChanged || value.configContent != configContent, + configContent: configContent ?? value.configContent, ), ); } @@ -91,15 +126,28 @@ class ProfileDetailsNotifier extends _$ProfileDetailsNotifier with AppLogger { if (profile.name.isBlank || profile.url.isBlank) { loggy.debug('save: invalid arguments'); } else if (value.isEditing) { - if (_originalProfile case RemoteProfileEntity(:final url) - when url == profile.url) { + if (_originalProfile case RemoteProfileEntity(:final url) when url == profile.url) { loggy.debug('editing profile'); failureOrSuccess = await _profilesRepo.patch(profile).run(); + if (failureOrSuccess.isRight()) { + failureOrSuccess = await _profilesRepo + .updateContent( + profile.id, + value.configContent, + ) + .run(); + } } else { loggy.debug('updating profile'); - failureOrSuccess = await _profilesRepo - .updateSubscription(profile, patchBaseProfile: true) - .run(); + failureOrSuccess = await _profilesRepo.updateSubscription(profile, patchBaseProfile: true).run(); + if (failureOrSuccess.isRight()) { + failureOrSuccess = await _profilesRepo + .updateContent( + profile.id, + value.configContent, + ) + .run(); + } } } else { loggy.debug('adding profile, url: [${profile.url}]'); @@ -138,10 +186,7 @@ class ProfileDetailsNotifier extends _$ProfileDetailsNotifier with AppLogger { final profile = value.profile; state = AsyncData(value.copyWith(update: const AsyncLoading())); - final failureOrUpdatedProfile = await _profilesRepo - .updateSubscription(profile as RemoteProfileEntity) - .flatMap((_) => _profilesRepo.getById(id)) - .run(); + final failureOrUpdatedProfile = await _profilesRepo.updateSubscription(profile as RemoteProfileEntity).flatMap((_) => _profilesRepo.getById(id)).run(); state = AsyncData( value.copyWith( @@ -167,10 +212,7 @@ class ProfileDetailsNotifier extends _$ProfileDetailsNotifier with AppLogger { state = AsyncData( value.copyWith( delete: await AsyncValue.guard(() async { - await _profilesRepo - .deleteById(profile.id) - .getOrElse((l) => throw l) - .run(); + await _profilesRepo.deleteById(profile.id).getOrElse((l) => throw l).run(); }), ), ); diff --git a/lib/features/profile/details/profile_details_page.dart b/lib/features/profile/details/profile_details_page.dart index 472d1746..0de8e160 100644 --- a/lib/features/profile/details/profile_details_page.dart +++ b/lib/features/profile/details/profile_details_page.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; import 'package:fpdart/fpdart.dart'; @@ -7,12 +9,17 @@ import 'package:hiddify/core/localization/translations.dart'; import 'package:hiddify/core/model/failures.dart'; import 'package:hiddify/core/widget/adaptive_icon.dart'; import 'package:hiddify/features/common/confirmation_dialogs.dart'; +import 'package:hiddify/features/profile/details/json_editor.dart'; import 'package:hiddify/features/profile/details/profile_details_notifier.dart'; import 'package:hiddify/features/profile/model/profile_entity.dart'; import 'package:hiddify/features/settings/widgets/widgets.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:humanizer/humanizer.dart'; +// import 'package:lucy_editor/lucy_editor.dart'; +// import 'package:re_highlight/languages/json.dart'; +// import 'package:re_highlight/styles/atom-one-light.dart'; +// import 'package:json_editor_flutter/json_editor_flutter.dart'; class ProfileDetailsPage extends HookConsumerWidget with PresLogger { const ProfileDetailsPage(this.id, {super.key}); @@ -39,14 +46,12 @@ class ProfileDetailsPage extends HookConsumerWidget with PresLogger { ); case AsyncError(:final error): final String action; - if (ref.read(provider) case AsyncData(value: final data) - when data.isEditing) { + if (ref.read(provider) case AsyncData(value: final data) when data.isEditing) { action = t.profile.save.failureMsg; } else { action = t.profile.add.failureMsg; } - CustomAlertDialog.fromErr(t.presentError(error, action: action)) - .show(context); + CustomAlertDialog.fromErr(t.presentError(error, action: action)).show(context); } }, ); @@ -82,9 +87,7 @@ class ProfileDetailsPage extends HookConsumerWidget with PresLogger { switch (ref.watch(provider)) { case AsyncData(value: final state): - final showLoadingOverlay = state.isBusy || - state.save is MutationSuccess || - state.delete is MutationSuccess; + final showLoadingOverlay = state.isBusy || state.save is MutationSuccess || state.delete is MutationSuccess; return Stack( children: [ @@ -96,6 +99,16 @@ class ProfileDetailsPage extends HookConsumerWidget with PresLogger { title: Text(t.profile.detailsPageTitle), pinned: true, actions: [ + // MenuItemButton( + // onPressed: context.pop, + // child: Text( + // MaterialLocalizations.of(context).cancelButtonLabel, + // ), + // ), + MenuItemButton( + onPressed: notifier.save, + child: Text(t.profile.save.buttonText), + ), if (state.isEditing) PopupMenuButton( icon: Icon(AdaptiveIcon(context).more), @@ -111,8 +124,7 @@ class ProfileDetailsPage extends HookConsumerWidget with PresLogger { PopupMenuItem( child: Text(t.profile.delete.buttonTxt), onTap: () async { - final deleteConfirmed = - await showConfirmationDialog( + final deleteConfirmed = await showConfirmationDialog( context, title: t.profile.delete.buttonTxt, message: t.profile.delete.confirmationMsg, @@ -129,9 +141,7 @@ class ProfileDetailsPage extends HookConsumerWidget with PresLogger { ], ), Form( - autovalidateMode: state.showErrorMessages - ? AutovalidateMode.always - : AutovalidateMode.disabled, + autovalidateMode: state.showErrorMessages ? AutovalidateMode.always : AutovalidateMode.disabled, child: SliverList.list( children: [ Padding( @@ -141,20 +151,13 @@ class ProfileDetailsPage extends HookConsumerWidget with PresLogger { ), child: CustomTextFormField( initialValue: state.profile.name, - onChanged: (value) => - notifier.setField(name: value), - validator: (value) => (value?.isEmpty ?? true) - ? t.profile.detailsForm.emptyNameMsg - : null, + onChanged: (value) => notifier.setField(name: value), + validator: (value) => (value?.isEmpty ?? true) ? t.profile.detailsForm.emptyNameMsg : null, label: t.profile.detailsForm.nameLabel, hint: t.profile.detailsForm.nameHint, ), ), - if (state.profile - case RemoteProfileEntity( - :final url, - :final options - )) ...[ + if (state.profile case RemoteProfileEntity(:final url, :final options)) ...[ Padding( padding: const EdgeInsets.symmetric( horizontal: 16, @@ -162,12 +165,8 @@ class ProfileDetailsPage extends HookConsumerWidget with PresLogger { ), child: CustomTextFormField( initialValue: url, - onChanged: (value) => - notifier.setField(url: value), - validator: (value) => - (value != null && !isUrl(value)) - ? t.profile.detailsForm.invalidUrlMsg - : null, + onChanged: (value) => notifier.setField(url: value), + validator: (value) => (value != null && !isUrl(value)) ? t.profile.detailsForm.invalidUrlMsg : null, label: t.profile.detailsForm.urlLabel, hint: t.profile.detailsForm.urlHint, ), @@ -180,13 +179,10 @@ class ProfileDetailsPage extends HookConsumerWidget with PresLogger { ) ?? t.general.toggle.disabled, ), - leading: - const Icon(FluentIcons.arrow_sync_24_regular), + leading: const Icon(FluentIcons.arrow_sync_24_regular), onTap: () async { - final intervalInHours = - await SettingsInputDialog( - title: t.profile.detailsForm - .updateIntervalDialogTitle, + final intervalInHours = await SettingsInputDialog( + title: t.profile.detailsForm.updateIntervalDialogTitle, initialValue: options?.updateInterval.inHours, optionalAction: ( t.general.state.disable, @@ -205,17 +201,28 @@ class ProfileDetailsPage extends HookConsumerWidget with PresLogger { }, ), ], + // Padding( + // padding: const EdgeInsets.symmetric( + // horizontal: 16, + // vertical: 8, + // ), + // child: CustomTextFormField( + // initialValue: state.configContent, + // // onChanged: (value) => notifier.setField(name: value), + // maxLines: 7, + // label: t.profile.detailsForm.configContentLabel, + // hint: t.profile.detailsForm.configContentHint, + // ), + // ), if (state.isEditing) ...[ ListTile( title: Text(t.profile.detailsForm.lastUpdate), - leading: - const Icon(FluentIcons.history_24_regular), + leading: const Icon(FluentIcons.history_24_regular), subtitle: Text(state.profile.lastUpdate.format()), dense: true, ), ], - if (state.profile - case RemoteProfileEntity(:final subInfo?)) ...[ + if (state.profile case RemoteProfileEntity(:final subInfo?)) ...[ Padding( padding: const EdgeInsets.symmetric( horizontal: 18, @@ -225,8 +232,7 @@ class ProfileDetailsPage extends HookConsumerWidget with PresLogger { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text.rich( - style: - Theme.of(context).textTheme.bodySmall, + style: Theme.of(context).textTheme.bodySmall, TextSpan( children: [ _buildSubProp( @@ -242,8 +248,7 @@ class ProfileDetailsPage extends HookConsumerWidget with PresLogger { ), const TextSpan(text: " "), _buildSubProp( - FluentIcons - .arrow_bidirectional_up_down_16_regular, + FluentIcons.arrow_bidirectional_up_down_16_regular, subInfo.total.size(), t.profile.subscription.total, ), @@ -252,8 +257,7 @@ class ProfileDetailsPage extends HookConsumerWidget with PresLogger { ), const Gap(12), Text.rich( - style: - Theme.of(context).textTheme.bodySmall, + style: Theme.of(context).textTheme.bodySmall, TextSpan( children: [ _buildSubProp( @@ -268,39 +272,23 @@ class ProfileDetailsPage extends HookConsumerWidget with PresLogger { ), ), ], - ], - ), - ), - SliverFillRemaining( - hasScrollBody: false, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - OverflowBar( - spacing: 12, - overflowAlignment: OverflowBarAlignment.end, - children: [ - OutlinedButton( - onPressed: context.pop, - child: Text( - MaterialLocalizations.of(context) - .cancelButtonLabel, - ), - ), - FilledButton( - onPressed: notifier.save, - child: Text(t.profile.save.buttonText), - ), - ], + if (state.isEditing) ...[ + SizedBox( + height: MediaQuery.of(context).size.height * 0.8, + child: JsonEditor( + expandedObjects: const ["outbounds"], + onChanged: (value) { + if (value == null) return; + const encoder = const JsonEncoder.withIndent(' '); + + notifier.setField(configContent: encoder.convert(value)); + }, + enableHorizontalScroll: true, + json: state.configContent, + ), ), ], - ), + ], ), ), ], diff --git a/lib/features/profile/details/profile_details_state.dart b/lib/features/profile/details/profile_details_state.dart index 894abf24..972f6b76 100644 --- a/lib/features/profile/details/profile_details_state.dart +++ b/lib/features/profile/details/profile_details_state.dart @@ -15,8 +15,9 @@ class ProfileDetailsState with _$ProfileDetailsState { AsyncValue? save, AsyncValue? update, AsyncValue? delete, + @Default("") String configContent, + @Default(false) bool configContentChanged, }) = _ProfileDetailsState; - bool get isBusy => - save is AsyncLoading || delete is AsyncLoading || update is AsyncLoading; + bool get isBusy => save is AsyncLoading || delete is AsyncLoading || update is AsyncLoading; } diff --git a/lib/features/profile/overview/profiles_overview_notifier.dart b/lib/features/profile/overview/profiles_overview_notifier.dart index 2839c196..69e2fe26 100644 --- a/lib/features/profile/overview/profiles_overview_notifier.dart +++ b/lib/features/profile/overview/profiles_overview_notifier.dart @@ -13,37 +13,26 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'profiles_overview_notifier.g.dart'; @riverpod -class ProfilesOverviewSortNotifier extends _$ProfilesOverviewSortNotifier - with AppLogger { +class ProfilesOverviewSortNotifier extends _$ProfilesOverviewSortNotifier with AppLogger { @override ({ProfilesSort by, SortMode mode}) build() { return (by: ProfilesSort.lastUpdate, mode: SortMode.descending); } - void changeSort(ProfilesSort sortBy) => - state = (by: sortBy, mode: state.mode); + void changeSort(ProfilesSort sortBy) => state = (by: sortBy, mode: state.mode); - void toggleMode() => state = ( - by: state.by, - mode: state.mode == SortMode.ascending - ? SortMode.descending - : SortMode.ascending - ); + void toggleMode() => state = (by: state.by, mode: state.mode == SortMode.ascending ? SortMode.descending : SortMode.ascending); } @riverpod -class ProfilesOverviewNotifier extends _$ProfilesOverviewNotifier - with AppLogger { +class ProfilesOverviewNotifier extends _$ProfilesOverviewNotifier with AppLogger { @override Stream> build() { final sort = ref.watch(profilesOverviewSortNotifierProvider); - return _profilesRepo - .watchAll(sort: sort.by, sortMode: sort.mode) - .map((event) => event.getOrElse((l) => throw l)); + return _profilesRepo.watchAll(sort: sort.by, sortMode: sort.mode).map((event) => event.getOrElse((l) => throw l)); } - ProfileRepository get _profilesRepo => - ref.read(profileRepositoryProvider).requireValue; + ProfileRepository get _profilesRepo => ref.read(profileRepositoryProvider).requireValue; Future selectActiveProfile(String id) async { loggy.debug('changing active profile to: [$id]'); diff --git a/libcore b/libcore index 77fe588e..d52ac585 160000 --- a/libcore +++ b/libcore @@ -1 +1 @@ -Subproject commit 77fe588eae4d49966cccfe57e9bb495b37933642 +Subproject commit d52ac5854b4fac64b6b7c9b5272a2a7d3bfa78ae diff --git a/pubspec.yaml b/pubspec.yaml index 72c3e88d..9c70991f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,10 @@ dependencies: git: url: https://github.com/alex-relov/humanizer ref: up-version + # lucy_editor: ^1.0.5 + # re_highlight: ^0.0.3 + # json_editor_flutter: ^1.4.2 + slang: ^3.30.1 slang_flutter: ^3.30.0 fpdart: ^1.1.0