diff --git a/lib/common/widgets/dialog/export_import.dart b/lib/common/widgets/dialog/export_import.dart new file mode 100644 index 000000000..1228a628a --- /dev/null +++ b/lib/common/widgets/dialog/export_import.dart @@ -0,0 +1,278 @@ +import 'dart:async' show FutureOr; +import 'dart:convert' show utf8, jsonDecode; +import 'dart:io' show File; + +import 'package:PiliPlus/common/constants.dart' show StyleString; +import 'package:PiliPlus/utils/extension/context_ext.dart'; +import 'package:PiliPlus/utils/utils.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show Clipboard; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get_core/src/get_main.dart'; +import 'package:get/get_navigation/src/extension_navigation.dart'; +import 'package:intl/intl.dart' show DateFormat; +import 'package:re_highlight/languages/json.dart'; +import 'package:re_highlight/re_highlight.dart'; +import 'package:re_highlight/styles/base16/github.dart'; +import 'package:re_highlight/styles/github-dark.dart'; + +void exportToClipBoard({ + required ValueGetter onExport, +}) { + Utils.copyText(onExport()); +} + +void exportToLocalFile({ + required ValueGetter onExport, + required ValueGetter localFileName, +}) { + final res = utf8.encode(onExport()); + Utils.saveBytes2File( + name: + 'piliplus_${localFileName()}_' + '${DateFormat('yyyyMMddHHmmss').format(DateTime.now())}.json', + bytes: res, + allowedExtensions: const ['json'], + ); +} + +Future importFromClipBoard( + BuildContext context, { + required String title, + required ValueGetter onExport, + required FutureOr Function(T json) onImport, + bool showConfirmDialog = true, +}) async { + final data = await Clipboard.getData('text/plain'); + if (data?.text?.isNotEmpty != true) { + SmartDialog.showToast('剪贴板无数据'); + return; + } + if (!context.mounted) return; + final text = data!.text!; + late final T json; + late final String formatText; + try { + json = jsonDecode(text); + formatText = Utils.jsonEncoder.convert(json); + } catch (e) { + SmartDialog.showToast('解析json失败:$e'); + return; + } + bool? executeImport; + if (showConfirmDialog) { + final highlight = Highlight()..registerLanguage('json', langJson); + final result = highlight.highlight( + code: formatText, + language: 'json', + ); + late TextSpanRenderer renderer; + bool? isDarkMode; + executeImport = await showDialog( + context: context, + builder: (context) { + final isDark = context.isDarkMode; + if (isDark != isDarkMode) { + isDarkMode = isDark; + renderer = TextSpanRenderer( + const TextStyle(), + isDark ? githubDarkTheme : githubTheme, + ); + result.render(renderer); + } + return AlertDialog( + title: Text('是否导入如下$title?'), + content: SingleChildScrollView( + child: Text.rich(renderer.span!), + ), + actions: [ + TextButton( + onPressed: Get.back, + child: Text( + '取消', + style: TextStyle( + color: Theme.of(context).colorScheme.outline, + ), + ), + ), + TextButton( + onPressed: () => Get.back(result: true), + child: const Text('确定'), + ), + ], + ); + }, + ); + } else { + executeImport = true; + } + if (executeImport ?? false) { + try { + await onImport(json); + SmartDialog.showToast('导入成功'); + } catch (e) { + SmartDialog.showToast('导入失败:$e'); + } + } +} + +Future importFromLocalFile({ + required FutureOr Function(T json) onImport, +}) async { + final result = await FilePicker.pickFiles(); + if (result != null) { + final path = result.files.first.path; + if (path != null) { + final data = await File(path).readAsString(); + late final T json; + try { + json = jsonDecode(data); + } catch (e) { + SmartDialog.showToast('解析json失败:$e'); + return; + } + try { + await onImport(json); + SmartDialog.showToast('导入成功'); + } catch (e) { + SmartDialog.showToast('导入失败:$e'); + } + } + } +} + +void importFromInput( + BuildContext context, { + required String title, + required FutureOr Function(T json) onImport, +}) { + final key = GlobalKey>(); + late T json; + String? forceErrorText; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('输入$title'), + constraints: StyleString.dialogFixedConstraints, + content: TextFormField( + key: key, + minLines: 4, + maxLines: 12, + autofocus: true, + decoration: const InputDecoration( + border: OutlineInputBorder(), + errorMaxLines: 3, + ), + validator: (value) { + if (forceErrorText != null) return forceErrorText; + try { + json = jsonDecode(value!) as T; + return null; + } catch (e) { + if (e is FormatException) {} + return '解析json失败:$e'; + } + }, + ), + actions: [ + TextButton( + onPressed: Get.back, + child: Text( + '取消', + style: TextStyle( + color: Theme.of(context).colorScheme.outline, + ), + ), + ), + TextButton( + onPressed: () async { + if (key.currentState?.validate() == true) { + try { + await onImport(json); + Get.back(); + SmartDialog.showToast('导入成功'); + return; + } catch (e) { + forceErrorText = '导入失败:$e'; + } + key.currentState?.validate(); + forceErrorText = null; + } + }, + child: const Text('确定'), + ), + ], + ), + ); +} + +Future showImportExportDialog( + BuildContext context, { + required String title, + String? label, + required ValueGetter onExport, + required FutureOr Function(T json) onImport, + required ValueGetter localFileName, +}) => showDialog( + context: context, + builder: (context) { + const style = TextStyle(fontSize: 14); + return SimpleDialog( + clipBehavior: Clip.hardEdge, + title: Text('导入/导出$title'), + children: [ + ListTile( + dense: true, + title: const Text('导出至剪贴板', style: style), + onTap: () { + Get.back(); + exportToClipBoard(onExport: onExport); + }, + ), + ListTile( + dense: true, + title: const Text('导出文件至本地', style: style), + onTap: () { + Get.back(); + exportToLocalFile(onExport: onExport, localFileName: localFileName); + }, + ), + Divider( + height: 1, + color: ColorScheme.of(context).outline.withValues(alpha: 0.1), + ), + ListTile( + dense: true, + title: const Text('输入', style: style), + onTap: () { + Get.back(); + importFromInput(context, title: title, onImport: onImport); + }, + ), + ListTile( + dense: true, + title: const Text('从剪贴板导入', style: style), + onTap: () { + Get.back(); + importFromClipBoard( + context, + title: title, + onExport: onExport, + onImport: onImport, + ); + }, + ), + ListTile( + dense: true, + title: const Text('从本地文件导入', style: style), + onTap: () { + Get.back(); + importFromLocalFile(onImport: onImport); + }, + ), + ], + ); + }, +); diff --git a/lib/pages/about/view.dart b/lib/pages/about/view.dart index 77d3089cc..267a8a3e8 100644 --- a/lib/pages/about/view.dart +++ b/lib/pages/about/view.dart @@ -1,10 +1,10 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'package:PiliPlus/build_config.dart'; import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/dialog/dialog.dart'; +import 'package:PiliPlus/common/widgets/dialog/export_import.dart'; import 'package:PiliPlus/common/widgets/flutter/list_tile.dart'; import 'package:PiliPlus/pages/mine/controller.dart'; import 'package:PiliPlus/services/logger.dart'; @@ -21,15 +21,9 @@ import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/update.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/material.dart' hide ListTile; -import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -import 'package:intl/intl.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; -import 'package:re_highlight/languages/json.dart'; -import 'package:re_highlight/re_highlight.dart'; -import 'package:re_highlight/styles/github-dark.dart'; -import 'package:re_highlight/styles/github.dart'; class AboutPage extends StatefulWidget { const AboutPage({super.key, this.showAppBar = true}); @@ -86,7 +80,7 @@ class _AboutPageState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - const style = TextStyle(fontSize: 15); + const style = TextStyle(fontSize: 14); final outline = theme.colorScheme.outline; final subTitleStyle = TextStyle(fontSize: 13, color: outline); final showAppBar = widget.showAppBar; @@ -249,8 +243,10 @@ Commit Hash: ${BuildConfig.commitHash}''', onTap: () => showImportExportDialog( context, title: '登录信息', - toJson: () => Utils.jsonEncoder.convert(Accounts.account.toMap()), - fromJson: (json) async { + localFileName: () => 'account', + onExport: () => + Utils.jsonEncoder.convert(Accounts.account.toMap()), + onImport: (json) async { final res = json.map( (key, value) => MapEntry(key, LoginAccount.fromJson(value)), ); @@ -260,7 +256,6 @@ Commit Hash: ${BuildConfig.commitHash}''', if (Accounts.main.isLogin) { await LoginUtils.onLoginMain(); } - return true; }, ), ), @@ -268,12 +263,14 @@ Commit Hash: ${BuildConfig.commitHash}''', title: const Text('导入/导出设置'), dense: false, leading: const Icon(Icons.import_export_outlined), - onTap: () => showImportExportDialog( + onTap: () => showImportExportDialog>( context, title: '设置', + localFileName: () => + 'setting_${context.isTablet ? 'pad' : 'phone'}', label: GStorage.setting.name, - toJson: GStorage.exportAllSettings, - fromJson: GStorage.importAllJsonSettings, + onExport: GStorage.exportAllSettings, + onImport: GStorage.importAllJsonSettings, ), ), ListTile( @@ -302,15 +299,7 @@ Commit Hash: ${BuildConfig.commitHash}''', dense: true, onTap: () async { Get.back(); - await Future.wait([ - GStorage.userInfo.clear(), - GStorage.setting.clear(), - GStorage.localCache.clear(), - GStorage.video.clear(), - GStorage.historyWord.clear(), - Accounts.clear(), - GStorage.watchProgress.clear(), - ]); + await GStorage.clear(); SmartDialog.showToast('重置成功'); }, title: const Text('重置所有数据(含登录信息)', style: style), @@ -325,190 +314,3 @@ Commit Hash: ${BuildConfig.commitHash}''', ); } } - -Future showImportExportDialog( - BuildContext context, { - required String title, - String? label, - required ValueGetter toJson, - required FutureOr Function(T json) fromJson, -}) => showDialog( - context: context, - builder: (context) { - const style = TextStyle(fontSize: 15); - return SimpleDialog( - clipBehavior: Clip.hardEdge, - title: Text('导入/导出$title'), - children: [ - if (label != null) - ListTile( - dense: true, - title: const Text('导出文件至本地', style: style), - onTap: () { - Get.back(); - final res = utf8.encode(toJson()); - final name = - 'piliplus_${label}_${context.isTablet ? 'pad' : 'phone'}_' - '${DateFormat('yyyyMMddHHmmss').format(DateTime.now())}.json'; - Utils.saveBytes2File( - name: name, - bytes: res, - allowedExtensions: const ['json'], - ); - }, - ), - ListTile( - dense: true, - title: Text('导出$title至剪贴板', style: style), - onTap: () { - Get.back(); - Utils.copyText(toJson()); - }, - ), - ListTile( - dense: true, - title: Text('从剪贴板导入$title', style: style), - onTap: () async { - Get.back(); - ClipboardData? data = await Clipboard.getData( - 'text/plain', - ); - if (data?.text?.isNotEmpty != true) { - SmartDialog.showToast('剪贴板无数据'); - return; - } - if (!context.mounted) return; - final text = data!.text!; - late final T json; - late final String formatText; - try { - json = jsonDecode(text); - formatText = Utils.jsonEncoder.convert(json); - } catch (e) { - SmartDialog.showToast('解析json失败:$e'); - return; - } - final highlight = Highlight()..registerLanguage('json', langJson); - final result = highlight.highlight( - code: formatText, - language: 'json', - ); - late TextSpanRenderer renderer; - bool? isDarkMode; - showDialog( - context: context, - builder: (context) { - final isDark = context.isDarkMode; - if (isDark != isDarkMode) { - isDarkMode = isDark; - renderer = TextSpanRenderer( - const TextStyle(), - isDark ? githubDarkTheme : githubTheme, - ); - result.render(renderer); - } - return AlertDialog( - title: Text('是否导入如下$title?'), - content: SingleChildScrollView( - child: Text.rich(renderer.span!), - ), - actions: [ - TextButton( - onPressed: Get.back, - child: Text( - '取消', - style: TextStyle( - color: Theme.of(context).colorScheme.outline, - ), - ), - ), - TextButton( - onPressed: () async { - Get.back(); - try { - if (await fromJson(json)) { - SmartDialog.showToast('导入成功'); - } - } catch (e) { - SmartDialog.showToast('导入失败:$e'); - } - }, - child: const Text('确定'), - ), - ], - ); - }, - ); - }, - ), - ListTile( - dense: true, - title: Text('输入$title', style: style), - onTap: () { - Get.back(); - final key = GlobalKey>(); - late T json; - String? forceErrorText; - - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text('输入$title'), - constraints: StyleString.dialogFixedConstraints, - content: TextFormField( - key: key, - minLines: 4, - maxLines: 12, - autofocus: true, - decoration: const InputDecoration( - border: OutlineInputBorder(), - errorMaxLines: 3, - ), - validator: (value) { - if (forceErrorText != null) return forceErrorText; - try { - json = jsonDecode(value!) as T; - return null; - } catch (e) { - if (e is FormatException) {} - return '解析json失败:$e'; - } - }, - ), - actions: [ - TextButton( - onPressed: Get.back, - child: Text( - '取消', - style: TextStyle( - color: Theme.of(context).colorScheme.outline, - ), - ), - ), - TextButton( - onPressed: () async { - if (key.currentState?.validate() == true) { - try { - if (await fromJson(json)) { - Get.back(); - SmartDialog.showToast('导入成功'); - return; - } - } catch (e) { - forceErrorText = '导入失败:$e'; - } - key.currentState?.validate(); - forceErrorText = null; - } - }, - child: const Text('确定'), - ), - ], - ), - ); - }, - ), - ], - ); - }, -); diff --git a/lib/pages/my_reply/view.dart b/lib/pages/my_reply/view.dart index 9428fa136..fea883b07 100644 --- a/lib/pages/my_reply/view.dart +++ b/lib/pages/my_reply/view.dart @@ -1,7 +1,9 @@ import 'package:PiliPlus/common/widgets/dialog/dialog.dart'; +import 'package:PiliPlus/common/widgets/dialog/export_import.dart'; import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; import 'package:PiliPlus/common/widgets/view_sliver_safe_area.dart'; -import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart'; +import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' + show ReplyInfo; import 'package:PiliPlus/pages/video/reply/widgets/reply_item_grpc.dart'; import 'package:PiliPlus/utils/app_scheme.dart'; import 'package:PiliPlus/utils/id_utils.dart'; @@ -9,9 +11,13 @@ import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/reply_utils.dart'; import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; +import 'package:PiliPlus/utils/utils.dart'; import 'package:PiliPlus/utils/waterfall.dart'; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; +import 'package:get/get_core/src/get_main.dart'; +import 'package:get/get_navigation/src/extension_navigation.dart'; +import 'package:get/get_rx/get_rx.dart'; import 'package:waterfall_flow/waterfall_flow.dart'; class MyReply extends StatefulWidget { @@ -22,13 +28,19 @@ class MyReply extends StatefulWidget { } class _MyReplyState extends State with DynMixin { - late final List _replies; + final List _replies = []; @override void initState() { super.initState(); - _replies = GStorage.reply!.values.map(ReplyInfo.fromBuffer).toList() - ..sort((a, b) => b.ctime.compareTo(a.ctime)); // rpid not aligned + _initReply(); + } + + void _initReply() { + _replies.assignAll( + GStorage.reply!.values.map(ReplyInfo.fromBuffer).toList() + ..sort((a, b) => b.ctime.compareTo(a.ctime)), // rpid not aligned + ); } @override @@ -36,24 +48,33 @@ class _MyReplyState extends State with DynMixin { return Scaffold( appBar: AppBar( title: const Text('我的评论'), - actions: kDebugMode - ? [ - IconButton( - tooltip: 'Clear', - onPressed: () => showConfirmDialog( - context: context, - title: 'Clear Local Storage?', - onConfirm: () { - GStorage.reply!.clear(); - _replies.clear(); - setState(() {}); - }, - ), - icon: const Icon(Icons.clear_all), - ), - const SizedBox(width: 6), - ] - : null, + actions: [ + if (kDebugMode) + IconButton( + tooltip: 'Clear', + onPressed: () => showConfirmDialog( + context: context, + title: 'Clear Local Storage?', + onConfirm: () { + GStorage.reply!.clear(); + _replies.clear(); + setState(() {}); + }, + ), + icon: const Icon(Icons.clear_all), + ), + IconButton( + tooltip: '导出', + onPressed: _showExportDialog, + icon: const Icon(Icons.file_upload_outlined), + ), + IconButton( + tooltip: '导入', + onPressed: _showImportDialog, + icon: const Icon(Icons.file_download_outlined), + ), + const SizedBox(width: 6), + ], ), body: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), @@ -121,4 +142,89 @@ class _MyReplyState extends State with DynMixin { isManual: true, ); } + + String _onExport() { + return Utils.jsonEncoder.convert( + _replies.map((e) => e.toProto3Json()).toList(), + ); + } + + void _showExportDialog() { + const style = TextStyle(fontSize: 14); + showDialog( + context: context, + builder: (context) => SimpleDialog( + clipBehavior: .hardEdge, + contentPadding: const .symmetric(vertical: 12), + children: [ + ListTile( + dense: true, + title: const Text('导出至剪贴板', style: style), + onTap: () { + Get.back(); + exportToClipBoard(onExport: _onExport); + }, + ), + ListTile( + dense: true, + title: const Text('导出文件至本地', style: style), + onTap: () { + Get.back(); + exportToLocalFile( + onExport: _onExport, + localFileName: () => 'reply', + ); + }, + ), + ], + ), + ); + } + + Future _onImport(List list) async { + await GStorage.reply!.putAll({ + for (var e in list) + e['id'].toString(): (ReplyInfo.create()..mergeFromProto3Json(e)) + .writeToBuffer(), + }); + if (mounted) { + _initReply(); + setState(() {}); + } + } + + void _showImportDialog() { + const style = TextStyle(fontSize: 14); + showDialog( + context: context, + builder: (context) => SimpleDialog( + clipBehavior: .hardEdge, + contentPadding: const .symmetric(vertical: 12), + children: [ + ListTile( + dense: true, + title: const Text('从剪贴板导入', style: style), + onTap: () { + Get.back(); + importFromClipBoard>( + context, + title: '评论', + onExport: _onExport, + onImport: _onImport, + showConfirmDialog: false, + ); + }, + ), + ListTile( + dense: true, + title: const Text('从本地文件导入', style: style), + onTap: () { + Get.back(); + importFromLocalFile>(onImport: _onImport); + }, + ), + ], + ), + ); + } } diff --git a/lib/pages/search/view.dart b/lib/pages/search/view.dart index d64163a06..7f7500dee 100644 --- a/lib/pages/search/view.dart +++ b/lib/pages/search/view.dart @@ -1,11 +1,11 @@ import 'dart:convert'; +import 'package:PiliPlus/common/widgets/dialog/export_import.dart'; import 'package:PiliPlus/common/widgets/disabled_icon.dart'; import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; import 'package:PiliPlus/common/widgets/sliver_wrap.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models_new/search/search_rcmd/data.dart'; -import 'package:PiliPlus/pages/about/view.dart' show showImportExportDialog; import 'package:PiliPlus/pages/search/controller.dart'; import 'package:PiliPlus/pages/search/widgets/hot_keyword.dart'; import 'package:PiliPlus/pages/search/widgets/search_text.dart'; @@ -424,12 +424,12 @@ class _SearchPageState extends State { onPressed: () => showImportExportDialog( context, title: '历史记录', - toJson: () => jsonEncode(_searchController.historyList), - fromJson: (json) { + localFileName: () => 'search', + onExport: () => jsonEncode(_searchController.historyList), + onImport: (json) { final list = List.from(json); _searchController.historyList.value = list; GStorage.historyWord.put('cacheList', list); - return true; }, ), ); diff --git a/lib/utils/storage.dart b/lib/utils/storage.dart index a91554e40..79482d14b 100644 --- a/lib/utils/storage.dart +++ b/lib/utils/storage.dart @@ -87,12 +87,13 @@ abstract final class GStorage { static Future importAllSettings(String data) => importAllJsonSettings(jsonDecode(data)); - static Future importAllJsonSettings(Map map) async { - await Future.wait([ + static Future> importAllJsonSettings( + Map map, + ) { + return Future.wait([ setting.clear().then((_) => setting.putAll(map[setting.name])), video.clear().then((_) => video.putAll(map[video.name])), ]); - return true; } static void regAdapter() { @@ -107,8 +108,8 @@ abstract final class GStorage { ..registerAdapter(RuleFilterAdapter()); } - static Future compact() async { - await Future.wait([ + static Future> compact() { + return Future.wait([ userInfo.compact(), historyWord.compact(), localCache.compact(), @@ -120,8 +121,8 @@ abstract final class GStorage { ]); } - static Future close() async { - await Future.wait([ + static Future> close() { + return Future.wait([ userInfo.close(), historyWord.close(), localCache.close(), @@ -133,6 +134,19 @@ abstract final class GStorage { ]); } + static Future> clear() { + return Future.wait([ + userInfo.clear(), + historyWord.clear(), + localCache.clear(), + setting.clear(), + video.clear(), + Accounts.clear(), + watchProgress.clear(), + ?reply?.clear(), + ]); + } + static int _intStrKeyComparator(dynamic k1, dynamic k2) { if (k1 is int) { if (k2 is int) {