From 25447e96679e14767d29e6ed59d65ba31b92b6e6 Mon Sep 17 00:00:00 2001 From: lifegpc Date: Sat, 9 Sep 2023 09:56:45 +0800 Subject: [PATCH 1/5] Impl saveFile on Windows platform --- lib/components/thumbnail.dart | 14 +++++++ lib/l10n/app_en.arb | 3 +- lib/l10n/app_zh.arb | 3 +- lib/platform/path.dart | 9 +++-- windows/runner/CMakeLists.txt | 1 + windows/runner/flutter_window.cpp | 65 +++++++++++++++++++++++++++++++ 6 files changed, 90 insertions(+), 5 deletions(-) diff --git a/lib/components/thumbnail.dart b/lib/components/thumbnail.dart index 38c0b0e..f7c908e 100644 --- a/lib/components/thumbnail.dart +++ b/lib/components/thumbnail.dart @@ -4,6 +4,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:logging/logging.dart'; +import 'package:path/path.dart'; import '../api/client.dart'; import '../api/gallery.dart'; import '../globals.dart'; @@ -45,6 +46,7 @@ class Thumbnail extends StatefulWidget { enum _ThumbnailMenu { copyImage, copyImgUrl, + saveAs, } class _Thumbnail extends State { @@ -55,6 +57,7 @@ class _Thumbnail extends State { bool _showNsfw = false; String? _uri; CancelToken? _cancel; + String? _fileName; Future _fetchData() async { try { _cancel = CancelToken(); @@ -106,6 +109,7 @@ class _Thumbnail extends State { _fileId = widget._fileId; _showNsfw = false; _uri = null; + _fileName = "${basenameWithoutExtension(widget._pMeta.name)}_thumb"; super.initState(); } @@ -133,6 +137,13 @@ class _Thumbnail extends State { _log.warning("Failed to copy image url to clipboard:", err); } break; + case _ThumbnailMenu.saveAs: + try { + await platformPath.saveFile(_fileName!, "image/jpeg", _data!); + } catch (err) { + _log.warning("Failed to save image:", err); + } + break; } } @@ -160,6 +171,9 @@ class _Thumbnail extends State { PopupMenuItem( value: _ThumbnailMenu.copyImgUrl, child: Text(AppLocalizations.of(context)!.copyImgUrl)), + PopupMenuItem( + value: _ThumbnailMenu.saveAs, + child: Text(AppLocalizations.of(context)!.saveAs)), ]; return list; })); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e313588..e2b6016 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -35,5 +35,6 @@ "none": "none", "sortByGid": "Sort by gallery id", "asc": "Ascending", - "desc": "Descending" + "desc": "Descending", + "saveAs": "Save As" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9997bcc..b2d697b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -35,5 +35,6 @@ "none": "无", "sortByGid": "按画廊ID排序", "asc": "升序", - "desc": "降序" + "desc": "降序", + "saveAs": "另存为" } diff --git a/lib/platform/path.dart b/lib/platform/path.dart index d38a00c..bc7770a 100644 --- a/lib/platform/path.dart +++ b/lib/platform/path.dart @@ -7,7 +7,7 @@ final Logger _log = Logger("platformPath"); class Path { static const platform = MethodChannel("lifegpc.eh_downloader_flutter/path"); - static const _safChannel=MethodChannel("lifegpc.eh_downloader_flutter/saf"); + static const _safChannel = MethodChannel("lifegpc.eh_downloader_flutter/saf"); String? _currentExe; bool _currentExeLoaded = false; @@ -28,7 +28,10 @@ class Path { } /// 保存文件 - static Future saveFile(String filenameWithoutExtension,String mimeType,Uint8List bytes,{String dir=""}) async{ - return _safChannel.invokeMethod("saveFile",[filenameWithoutExtension,dir,mimeType,bytes]); + Future saveFile( + String filenameWithoutExtension, String mimeType, Uint8List bytes, + {String dir = ""}) async { + return _safChannel.invokeMethod( + "saveFile", [filenameWithoutExtension, dir, mimeType, bytes]); } } diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt index 24483a2..ece282c 100644 --- a/windows/runner/CMakeLists.txt +++ b/windows/runner/CMakeLists.txt @@ -39,6 +39,7 @@ target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") target_link_libraries(${BINARY_NAME} PRIVATE utils) +target_link_libraries(${BINARY_NAME} PRIVATE "Comdlg32.lib") target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") # Run the Flutter tool portions of the build. This must not be removed. diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index 3c5fce3..d28352b 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -58,6 +58,71 @@ bool FlutterWindow::OnCreate() { result->NotImplemented(); } }); + flutter::MethodChannel<> saf(flutter_controller_->engine()->messenger(), "lifegpc.eh_downloader_flutter/saf", + &flutter::StandardMethodCodec::GetInstance()); + saf.SetMethodCallHandler([&](const flutter::MethodCall<>& call, std::unique_ptr> result) { + if (call.method_name() == "saveFile") { + auto args = std::get_if(call.arguments()); + auto fileName = std::get_if(&args->at(0)); + auto dir = std::get_if(&args->at(1)); + auto mimeType = std::get_if(&args->at(2)); + auto data = std::get_if>(&args->at(3)); + if (!fileName || !dir || !mimeType || !data) { + result->Error("INVALID_ARGUMENT", "Invalid arguments."); + return; + } + std::wstring wFileName; + if (!wchar_util::str_to_wstr(wFileName, *fileName, CP_UTF8)) { + result->Error("ERROR", "Failed to convert file name to wstring."); + return; + } + std::wstring wDir; + if (!dir->empty() && !wchar_util::str_to_wstr(wDir, *dir, CP_UTF8)) { + result->Error("ERROR", "Failed to convert dir to wstring."); + return; + } + OPENFILENAMEW ofn; + ZeroMemory(&ofn, sizeof(ofn)); + ofn.lStructSize = sizeof(ofn); + ofn.hwndOwner = Win32Window::GetHandle(); + std::wstring filter; + std::wstring defExt; + if (*mimeType == "image/jpeg") { + filter.append(std::wstring(L"JPEG File(*.jpg)\0*.jpg\0", 23)); + defExt = L"jpg"; + } else if (*mimeType == "image/png") { + filter.append(std::wstring(L"PNG File(*.png)\0*.png\0", 22)); + defExt = L"png"; + } else if (*mimeType == "image/gif") { + filter.append(std::wstring(L"GIF File(*.gif)\0*.gif\0", 22)); + defExt = L"gif"; + }; + filter.append(std::wstring(L"All Files\0*.*\0\0", 15)); + ofn.lpstrFilter = filter.c_str(); + ofn.lpstrDefExt = defExt.empty() ? nullptr : defExt.c_str(); + wchar_t wFileNameBuf[MAX_PATH_SIZE]; + memcpy(wFileNameBuf, wFileName.c_str(), (wFileName.size() + 1) * sizeof(wchar_t)); + ofn.lpstrFile = wFileNameBuf; + ofn.nMaxFile = MAX_PATH_SIZE; + ofn.lpstrInitialDir = wDir.empty() ? nullptr : wDir.c_str(); + ofn.Flags = OFN_DONTADDTORECENT | OFN_NONETWORKBUTTON | OFN_NOREADONLYRETURN | OFN_OVERWRITEPROMPT; + if (!GetSaveFileNameW(&ofn)) { + result->Error("ERROR", "Failed to get file name."); + return; + } + FILE* f = nullptr; + _wfopen_s(&f, wFileNameBuf, L"wb"); + if (!f) { + result->Error("ERROR", "Failed to open file."); + return; + } + fwrite(data->data(), sizeof(uint8_t), data->size(), f); + fclose(f); + result->Success(); + } else { + result->NotImplemented(); + } + }); SetChildContent(flutter_controller_->view()->GetNativeWindow()); From d0521d3774682654779ac0168a0b0ce751aca6d2 Mon Sep 17 00:00:00 2001 From: lifegpc Date: Sat, 9 Sep 2023 12:46:09 +0800 Subject: [PATCH 2/5] Fix gallery sort label dispear in light theme --- lib/galleries.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galleries.dart b/lib/galleries.dart index cb3ed2a..4f34d3e 100644 --- a/lib/galleries.dart +++ b/lib/galleries.dart @@ -91,7 +91,7 @@ class _GalleriesPage extends State with ThemeModeWidget { } }, label: Text(AppLocalizations.of(context)!.sortByGid, - style: Theme.of(context).primaryTextTheme.labelMedium), + style: Theme.of(context).textTheme.labelMedium), dropdownMenuEntries: [ DropdownMenuEntry( value: SortByGid.none, From 4ec4d616569f7f57f2fa815b7f0db49f06eeffb3 Mon Sep 17 00:00:00 2001 From: lifegpc Date: Sat, 9 Sep 2023 13:13:16 +0800 Subject: [PATCH 3/5] Impl saveFile for web --- lib/platform/path.dart | 5 +++++ lib/platform/save_file.dart | 1 + lib/platform/save_file_none.dart | 6 ++++++ lib/platform/save_file_web.dart | 28 ++++++++++++++++++++++++++++ 4 files changed, 40 insertions(+) create mode 100644 lib/platform/save_file.dart create mode 100644 lib/platform/save_file_none.dart create mode 100644 lib/platform/save_file_web.dart diff --git a/lib/platform/path.dart b/lib/platform/path.dart index bc7770a..1c01687 100644 --- a/lib/platform/path.dart +++ b/lib/platform/path.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; import '../utils.dart'; +import 'save_file.dart'; final Logger _log = Logger("platformPath"); @@ -31,6 +33,9 @@ class Path { Future saveFile( String filenameWithoutExtension, String mimeType, Uint8List bytes, {String dir = ""}) async { + if (kIsWeb) { + return saveFileWeb(bytes, mimeType, filenameWithoutExtension); + } return _safChannel.invokeMethod( "saveFile", [filenameWithoutExtension, dir, mimeType, bytes]); } diff --git a/lib/platform/save_file.dart b/lib/platform/save_file.dart new file mode 100644 index 0000000..406ac07 --- /dev/null +++ b/lib/platform/save_file.dart @@ -0,0 +1 @@ +export 'save_file_none.dart' if (dart.library.html) 'save_file_web.dart'; diff --git a/lib/platform/save_file_none.dart b/lib/platform/save_file_none.dart new file mode 100644 index 0000000..5181ed2 --- /dev/null +++ b/lib/platform/save_file_none.dart @@ -0,0 +1,6 @@ +import 'dart:typed_data'; + +void saveFileWeb( + Uint8List data, String mimeType, String filenameWithoutExtension) { + throw UnimplementedError(); +} diff --git a/lib/platform/save_file_web.dart b/lib/platform/save_file_web.dart new file mode 100644 index 0000000..6e66c45 --- /dev/null +++ b/lib/platform/save_file_web.dart @@ -0,0 +1,28 @@ +// ignore: avoid_web_libraries_in_flutter +import 'dart:html'; +import 'dart:typed_data'; + +void saveFileWeb( + Uint8List data, String mimeType, String filenameWithoutExtension) { + final blob = Blob([data], mimeType); + final url = Url.createObjectUrlFromBlob(blob); + final a = document.createElement("a") as AnchorElement; + a.href = url; + var ext = ""; + switch (mimeType) { + case "image/jpeg": + ext = ".jpg"; + break; + case "image/png": + ext = ".png"; + break; + case "image/gif": + ext = ".gif"; + break; + default: + break; + } + a.download = "$filenameWithoutExtension$ext"; + a.click(); + Url.revokeObjectUrl(url); +} From 6a44090e240b933ca36bfc3c546c2ea41f97ac1d Mon Sep 17 00:00:00 2001 From: lifegpc Date: Sat, 9 Sep 2023 13:52:27 +0800 Subject: [PATCH 4/5] Update context menu for image --- lib/components/gallery_basic_info.dart | 4 +++- lib/components/gallery_info.dart | 9 ++++++--- lib/components/gallery_info_desktop.dart | 4 +++- lib/components/image.dart | 17 ++++++++++++++++- lib/components/thumbnail.dart | 18 +++++++++++++----- lib/components/thumbnail_gridview.dart | 6 ++++-- lib/utils/clipboard.dart | 13 ++++++++++++- 7 files changed, 57 insertions(+), 14 deletions(-) diff --git a/lib/components/gallery_basic_info.dart b/lib/components/gallery_basic_info.dart index 378579a..3348f21 100644 --- a/lib/components/gallery_basic_info.dart +++ b/lib/components/gallery_basic_info.dart @@ -17,7 +17,9 @@ class GalleryBasicInfo extends StatelessWidget { child: Column(children: [ Expanded( child: Row(children: [ - Expanded(flex: 2, child: Thumbnail(firstPage, fileId: fileId)), + Expanded( + flex: 2, + child: Thumbnail(firstPage, fileId: fileId, gid: gMeta.gid)), Expanded( flex: 3, child: Column( diff --git a/lib/components/gallery_info.dart b/lib/components/gallery_info.dart index 9e09454..bf10a57 100644 --- a/lib/components/gallery_info.dart +++ b/lib/components/gallery_info.dart @@ -67,9 +67,12 @@ class _GalleryInfo extends State with ThemeModeWidget { fileId: firstFileId, controller: controller), ]), ), - ThumbnailGridView(widget.gData.pages, - SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: useMobile ? 2 : 5), - files: widget.files), + ThumbnailGridView( + widget.gData.pages, + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: useMobile ? 2 : 5), + files: widget.files, + gid: widget.gData.meta.gid), ], ); } diff --git a/lib/components/gallery_info_desktop.dart b/lib/components/gallery_info_desktop.dart index da2fca8..5b456ac 100644 --- a/lib/components/gallery_info_desktop.dart +++ b/lib/components/gallery_info_desktop.dart @@ -21,7 +21,9 @@ class GalleryInfoDesktop extends StatelessWidget { width: 1280, child: Row(children: [ Expanded( - flex: 3, child: Thumbnail(gData.pages.first, fileId: fileId)), + flex: 3, + child: Thumbnail(gData.pages.first, + fileId: fileId, gid: gData.meta.gid)), Expanded( flex: 7, child: Column( diff --git a/lib/components/image.dart b/lib/components/image.dart index f45852b..80387bb 100644 --- a/lib/components/image.dart +++ b/lib/components/image.dart @@ -3,17 +3,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:logging/logging.dart'; import 'package:super_context_menu/super_context_menu.dart'; +import '../globals.dart'; import '../utils/clipboard.dart'; final _log = Logger("ImageWithContextMenu"); class ImageWithContextMenu extends StatelessWidget { const ImageWithContextMenu(this.data, - {Key? key, this.uri, this.fmt = ImageFmt.jpg}) + {Key? key, this.uri, this.dir, this.fileName, this.fmt = ImageFmt.jpg}) : super(key: key); final Uint8List data; final String? uri; final ImageFmt fmt; + final String? fileName; + final String? dir; @override Widget build(BuildContext context) { return ContextMenuWidget( @@ -38,6 +41,18 @@ class ImageWithContextMenu extends StatelessWidget { }); })); } + if (fileName != null) { + list.add(MenuAction( + title: AppLocalizations.of(context)!.saveAs, + callback: () { + try { + platformPath.saveFile(fileName!, fmt.toMimeType(), data, + dir: dir ?? ""); + } catch (err) { + _log.warning("Failed to save image:", err); + } + })); + } return Menu(children: list); }, child: Image.memory(data)); diff --git a/lib/components/thumbnail.dart b/lib/components/thumbnail.dart index f7c908e..b67c330 100644 --- a/lib/components/thumbnail.dart +++ b/lib/components/thumbnail.dart @@ -8,6 +8,7 @@ import 'package:path/path.dart'; import '../api/client.dart'; import '../api/gallery.dart'; import '../globals.dart'; +import '../utils.dart'; import '../utils/clipboard.dart'; import 'image.dart'; @@ -15,7 +16,7 @@ final _log = Logger("Thumbnail"); class Thumbnail extends StatefulWidget { const Thumbnail(ExtendedPMeta pMeta, - {Key? key, int? max, int? width, int? height, int? fileId}) + {Key? key, int? max, int? width, int? height, int? fileId, this.gid}) : _pMeta = pMeta, _max = max ?? 1200, _width = width, @@ -27,6 +28,7 @@ class Thumbnail extends StatefulWidget { final int? _width; final int? _height; final int? _fileId; + final int? gid; int get height => _height != null ? _height! @@ -58,6 +60,7 @@ class _Thumbnail extends State { String? _uri; CancelToken? _cancel; String? _fileName; + String _dir = ""; Future _fetchData() async { try { _cancel = CancelToken(); @@ -110,6 +113,7 @@ class _Thumbnail extends State { _showNsfw = false; _uri = null; _fileName = "${basenameWithoutExtension(widget._pMeta.name)}_thumb"; + _dir = isAndroid && widget.gid != null ? widget.gid!.toString() : ""; super.initState(); } @@ -139,7 +143,8 @@ class _Thumbnail extends State { break; case _ThumbnailMenu.saveAs: try { - await platformPath.saveFile(_fileName!, "image/jpeg", _data!); + await platformPath.saveFile(_fileName!, "image/jpeg", _data!, + dir: _dir); } catch (err) { _log.warning("Failed to save image:", err); } @@ -194,8 +199,10 @@ class _Thumbnail extends State { sigmaX: 10, sigmaY: 10, tileMode: TileMode.decal), - child: - ImageWithContextMenu(_data!, uri: _uri))), + child: ImageWithContextMenu(_data!, + uri: _uri, + fileName: _fileName, + dir: _dir))), SizedBox( width: widget.width.toDouble(), height: widget.height.toDouble(), @@ -216,7 +223,8 @@ class _Thumbnail extends State { SizedBox( width: widget.width.toDouble(), height: widget.height.toDouble(), - child: ImageWithContextMenu(_data!, uri: _uri)), + child: ImageWithContextMenu(_data!, + uri: _uri, fileName: _fileName, dir: _dir)), moreVertMenu ]) : Center( diff --git a/lib/components/thumbnail_gridview.dart b/lib/components/thumbnail_gridview.dart index 9f321e5..9cbc15c 100644 --- a/lib/components/thumbnail_gridview.dart +++ b/lib/components/thumbnail_gridview.dart @@ -5,9 +5,11 @@ import '../globals.dart'; import 'thumbnail.dart'; class ThumbnailGridView extends StatelessWidget { - const ThumbnailGridView(this.pages, this.gridDelegate, {Key? key, this.files}) + const ThumbnailGridView(this.pages, this.gridDelegate, + {Key? key, this.files, this.gid}) : super(key: key); final List pages; + final int? gid; final EhFiles? files; final SliverGridDelegate gridDelegate; @@ -24,7 +26,7 @@ class ThumbnailGridView extends StatelessWidget { files != null ? files!.files[page.token]!.first.id : null; return Container( padding: const EdgeInsets.all(4), - child: Thumbnail(page, fileId: fileId)); + child: Thumbnail(page, fileId: fileId, gid: gid)); }); } } diff --git a/lib/utils/clipboard.dart b/lib/utils/clipboard.dart index 76d5c39..704ba95 100644 --- a/lib/utils/clipboard.dart +++ b/lib/utils/clipboard.dart @@ -6,7 +6,18 @@ import '../platform/to_png_none.dart' enum ImageFmt { jpg, png, - gif, + gif; + + String toMimeType() { + switch (this) { + case ImageFmt.jpg: + return "image/jpeg"; + case ImageFmt.png: + return "image/png"; + case ImageFmt.gif: + return "image/gif"; + } + } } Future copyImageToClipboard(Uint8List data, ImageFmt fmt) async { From 2e8ea280dc16c8aa90bb37c708185f5215c15543 Mon Sep 17 00:00:00 2001 From: lifegpc Date: Sat, 9 Sep 2023 15:12:10 +0800 Subject: [PATCH 5/5] reduce iconsize if width is too small --- lib/components/thumbnail.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/components/thumbnail.dart b/lib/components/thumbnail.dart index b67c330..897ac23 100644 --- a/lib/components/thumbnail.dart +++ b/lib/components/thumbnail.dart @@ -157,14 +157,16 @@ class _Thumbnail extends State { final isLoading = _data == null && _error == null; final isNsfw = widget._pMeta.isNsfw; if (isLoading && !_isLoading) _fetchData(); - final iconSize = Theme.of(context).iconTheme.size; + final iconSize = MediaQuery.of(context).size.width < 400 + ? 14.0 + : Theme.of(context).iconTheme.size; final moreVertMenu = Positioned( right: 0, top: 0, width: iconSize, height: iconSize, child: PopupMenuButton( - icon: const Icon(Icons.more_vert), + child: Icon(Icons.more_vert, size: iconSize), onSelected: (v) { onItemSelected(v); },