diff --git a/lib/api/client.dart b/lib/api/client.dart index 4b48db4..b759827 100644 --- a/lib/api/client.dart +++ b/lib/api/client.dart @@ -252,6 +252,18 @@ abstract class _EHApi { {@Part(name: "expired") int? expired, @Part(name: "type") String type = "gallery", @CancelRequest() CancelToken? cancel}); + @PATCH('/shared_token') + @MultiPart() + Future> updateShareGallery( + @Part(name: "token") String token, + {@Part(name: "expired") int? expired, + @Part(name: "type") String type = "gallery", + @CancelRequest() CancelToken? cancel}); + @GET('/shared_token/list') + Future>> listShareGalleries( + {@Query("gid") int? gid, + @Query("type") String type = "gallery", + @CancelRequest() CancelToken? cancel}); @GET('/tag/{id}') // ignore: unused_element diff --git a/lib/api/client.g.dart b/lib/api/client.g.dart index ff45268..20559bf 100644 --- a/lib/api/client.g.dart +++ b/lib/api/client.g.dart @@ -1056,6 +1056,102 @@ class __EHApi implements _EHApi { return _value; } + @override + Future> updateShareGallery( + String token, { + int? expired, + String type = "gallery", + CancelToken? cancel, + }) async { + final _extra = {}; + final queryParameters = {}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + final _data = FormData(); + _data.fields.add(MapEntry( + 'token', + token, + )); + if (expired != null) { + _data.fields.add(MapEntry( + 'expired', + expired.toString(), + )); + } + _data.fields.add(MapEntry( + 'type', + type, + )); + final _result = await _dio.fetch>( + _setStreamType>(Options( + method: 'PATCH', + headers: _headers, + extra: _extra, + contentType: 'multipart/form-data', + ) + .compose( + _dio.options, + '/shared_token', + queryParameters: queryParameters, + data: _data, + cancelToken: cancel, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + )))); + final _value = ApiResult.fromJson( + _result.data!, + (json) => SharedTokenWithUrl.fromJson(json as Map), + ); + return _value; + } + + @override + Future>> listShareGalleries({ + int? gid, + String type = "gallery", + CancelToken? cancel, + }) async { + final _extra = {}; + final queryParameters = { + r'gid': gid, + r'type': type, + }; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + const Map? _data = null; + final _result = await _dio.fetch>( + _setStreamType>>(Options( + method: 'GET', + headers: _headers, + extra: _extra, + ) + .compose( + _dio.options, + '/shared_token/list', + queryParameters: queryParameters, + data: _data, + cancelToken: cancel, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + )))); + final _value = ApiResult>.fromJson( + _result.data!, + (json) => json is List + ? json + .map( + (i) => SharedTokenWithUrl.fromJson(i as Map)) + .toList() + : List.empty(), + ); + return _value; + } + @override Future> _getTags( String id, { diff --git a/lib/auth.dart b/lib/auth.dart index 28ec8ea..721a798 100644 --- a/lib/auth.dart +++ b/lib/auth.dart @@ -33,6 +33,8 @@ class AuthInfo { _user?.permissions.has(UserPermission.deleteGallery); bool? get canManageTasks => _user?.permissions.has(UserPermission.manageTasks); + bool? get canShareGallery => + _user?.permissions.has(UserPermission.shareGallery); void clear() { _user = null; diff --git a/lib/dialog/gallery_share_page.dart b/lib/dialog/gallery_share_page.dart new file mode 100644 index 0000000..a698779 --- /dev/null +++ b/lib/dialog/gallery_share_page.dart @@ -0,0 +1,419 @@ +import 'dart:ui'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_datetime_picker_plus/flutter_datetime_picker_plus.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:logging/logging.dart'; +import '../api/token.dart'; +import '../globals.dart'; +import '../main.dart'; +import '../platform/media_query.dart'; +import '../utils.dart'; +import '../utils/clipboard.dart'; + +final _log = Logger("GallerySharePage"); + +Future _change( + String token, DateTime? expired, AppLocalizations i18n) async { + try { + final t = (await api.updateShareGallery(token, + expired: expired?.millisecondsSinceEpoch)) + .unwrap(); + listener.tryEmit("gallery_share_token_changed", t); + } catch (e, stack) { + String errmsg = "${i18n.failedChangeExpireTime}$e"; + if (e is (int, String)) { + _log.warning("Failed to change expire time: $e"); + } else { + _log.severe("Failed to change expire time: $e\n$stack"); + } + final snack = SnackBar(content: Text(errmsg)); + rootScaffoldMessengerKey.currentState?.showSnackBar(snack); + } +} + +Future _add(int gid, DateTime? expired, AppLocalizations i18n) async { + try { + final t = + (await api.shareGallery(gid, expired: expired?.millisecondsSinceEpoch)) + .unwrap(); + listener.tryEmit("gallery_share_token_added", t); + } catch (e, stack) { + String errmsg = "${i18n.failedShareGallery}$e"; + if (e is (int, String)) { + _log.warning("Failed to share gallery: $e"); + } else { + _log.severe("Failed to share gallery: $e\n$stack"); + } + final snack = SnackBar(content: Text(errmsg)); + rootScaffoldMessengerKey.currentState?.showSnackBar(snack); + } +} + +class _ChangeDialog extends StatefulWidget { + const _ChangeDialog(this.gid, {this.token}); + final int gid; + final String? token; + @override + State<_ChangeDialog> createState() => _ChangeDialogState(); +} + +enum _ExpireDuration { + day, + week, + month, + never, + custom; + + DateTime? expiredTime() { + switch (this) { + case _ExpireDuration.day: + return DateTime.now().add(const Duration(days: 1)); + case _ExpireDuration.week: + return DateTime.now().add(const Duration(days: 7)); + case _ExpireDuration.month: + return DateTime.now().add(const Duration(days: 30)); + default: + return null; + } + } + + String localText(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + switch (this) { + case _ExpireDuration.custom: + return i18n.custom; + case _ExpireDuration.day: + return i18n.oneDayAfter; + case _ExpireDuration.week: + return i18n.oneWeekAfter; + case _ExpireDuration.month: + return i18n.oneMonthAfter; + case _ExpireDuration.never: + return i18n.never; + } + } +} + +class _ChangeDialogState extends State<_ChangeDialog> { + final _formKey = GlobalKey(); + DateTime _expired = DateTime.now(); + _ExpireDuration _dur = _ExpireDuration.never; + + @override + Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + return AlertDialog( + title: + Text(widget.token != null ? i18n.editExpireTime : i18n.shareGallery), + content: Form( + key: _formKey, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + DropdownButtonFormField<_ExpireDuration>( + items: _ExpireDuration.values + .map((e) => DropdownMenuItem( + value: e, child: Text(e.localText(context)))) + .toList(), + value: _dur, + onChanged: (dur) { + if (dur != null) { + setState(() { + _dur = dur; + }); + } + if (dur == _ExpireDuration.custom) { + DatePicker.showDateTimePicker(context, onConfirm: (e) { + setState(() { + _expired = e; + }); + }, + locale: MainApp.of(context).lang.toLocaleType(), + minTime: DateTime.now()); + } + }, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: i18n.expireTime, + )), + ]), + ), + actions: [ + TextButton( + onPressed: _dur != _ExpireDuration.custom || + _dur == _ExpireDuration.custom && + _expired.isAfter(DateTime.now()) + ? () { + final expired = _dur != _ExpireDuration.custom + ? _dur.expiredTime() + : _expired; + if (widget.token != null) { + _change(widget.token!, expired, i18n); + } else { + _add(widget.gid, expired, i18n); + } + context.pop(); + } + : null, + child: Text(widget.token != null ? i18n.edit : i18n.share)), + TextButton( + onPressed: () { + context.pop(); + }, + child: Text(i18n.cancel)), + ], + ); + } +} + +class GallerySharePage extends StatefulWidget { + const GallerySharePage(this.gid, {super.key}); + final int gid; + + static const routeName = "/dialog/gallery/share/:gid"; + + @override + State createState() => _GallerySharePage(); +} + +class _GallerySharePage extends State { + List? _lists; + CancelToken? _cancel; + bool _isLoading = false; + Object? _error; + final GlobalKey _refreshIndicatorKey = + GlobalKey(); + + Future _fetchList() async { + _cancel = CancelToken(); + _isLoading = true; + try { + _lists = (await api.listShareGalleries(gid: widget.gid, cancel: _cancel)) + .unwrap(); + if (!_cancel!.isCancelled) { + setState(() { + _isLoading = false; + }); + } + } catch (e) { + if (!_cancel!.isCancelled) { + _log.severe("Failed to load gallery shared list ${widget.gid}:", e); + setState(() { + _error = e; + _isLoading = false; + }); + } + } + } + + void onTokenChanged(dynamic arg) { + if (_lists == null) return; + final t = arg as SharedTokenWithUrl; + final ind = _lists!.indexWhere((a) => a.token.id == t.token.id); + if (ind != -1) { + setState(() { + _lists![ind] = t; + }); + } + } + + void onTokenAdded(dynamic arg) { + if (_lists == null) return; + final t = arg as SharedTokenWithUrl; + final g = t.token.info as GallerySharedTokenInfo; + if (g.gid == widget.gid) { + setState(() { + _lists!.add(t); + }); + } + } + + @override + void initState() { + listener.on("gallery_share_token_changed", onTokenChanged); + listener.on("gallery_share_token_added", onTokenAdded); + super.initState(); + } + + @override + void dispose() { + _cancel?.cancel(); + listener.removeEventListener("gallery_share_token_changed", onTokenChanged); + listener.removeEventListener("gallery_share_token_added", onTokenAdded); + super.dispose(); + } + + Widget _buildView(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + final p = Theme.of(context).colorScheme.primary; + final s = TextStyle(color: p); + return CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Stack( + alignment: Alignment.center, + children: [ + Text( + i18n.shareGallery, + style: Theme.of(context).textTheme.headlineSmall, + ), + Align( + alignment: Alignment.centerRight, + child: IconButton( + onPressed: () => context.canPop() + ? context.pop() + : context.go("/gallery/${widget.gid}"), + icon: const Icon(Icons.close), + )), + ], + )), + SliverToBoxAdapter( + child: Row(children: [ + Expanded( + child: Text(i18n.token, textAlign: TextAlign.center, style: s)), + Expanded( + child: Text(i18n.expireTime, + textAlign: TextAlign.center, style: s)), + SizedBox( + width: 120, + child: + Text(i18n.action, textAlign: TextAlign.center, style: s)), + ])), + SliverList.builder( + itemBuilder: (context, index) { + final item = _lists![index]; + return Row(children: [ + Expanded( + child: SelectableText(item.token.token, + textAlign: TextAlign.center)), + Expanded( + child: SelectableText( + item.token.expired == null + ? i18n.never + : DateFormat.yMd(MainApp.of(context) + .lang + .toLocale() + .toString()) + .add_jms() + .format(item.token.expired!.toLocal()), + textAlign: TextAlign.center), + ), + SizedBox( + width: 120, + child: Row(children: [ + IconButton.filled( + onPressed: () { + copyTextToClipboard(item.url); + }, + icon: const Icon(Icons.link)), + IconButton.filled( + onPressed: () => showDialog( + context: context, + builder: (context) => _ChangeDialog(widget.gid, + token: item.token.token)), + icon: const Icon(Icons.edit)), + IconButton.filled( + onPressed: () => showDialog( + context: context, + builder: (context) { + return AlertDialog(title: Text(i18n.delete)); + }), + icon: const Icon(Icons.delete)), + ]), + ), + ]); + }, + itemCount: _lists!.length), + ]); + } + + Widget _buildRefreshIcon(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + return IconButton( + onPressed: () { + _refreshIndicatorKey.currentState?.show(); + }, + tooltip: i18n.refresh, + icon: const Icon(Icons.refresh)); + } + + Widget _buildAddIcon(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + return IconButton( + onPressed: () => showDialog( + context: context, builder: (context) => _ChangeDialog(widget.gid)), + tooltip: i18n.create, + icon: const Icon(Icons.add)); + } + + Widget _buildIconList(BuildContext context) { + return Row(children: [ + isDesktop || (kIsWeb && pointerIsMouse) + ? _buildRefreshIcon(context) + : Container(), + _buildAddIcon(context), + ]); + } + + @override + Widget build(BuildContext context) { + tryInitApi(context); + final isLoading = _lists == null && _error == null; + final i18n = AppLocalizations.of(context)!; + final size = MediaQuery.of(context).size; + final maxWidth = size.width; + if (isLoading && !_isLoading) _fetchList(); + return Container( + padding: maxWidth < 400 + ? const EdgeInsets.symmetric(vertical: 20, horizontal: 10) + : const EdgeInsets.all(20), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(10)), + width: maxWidth < 810 ? null : 800, + child: isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("Error: $_error"), + ElevatedButton.icon( + onPressed: () { + _fetchList(); + setState(() { + _error = null; + }); + }, + icon: const Icon(Icons.refresh), + label: Text(i18n.retry)), + ], + )) + : Stack( + children: [ + RefreshIndicator( + key: _refreshIndicatorKey, + onRefresh: () async { + return await _fetchList(); + }, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.trackpad, + }, + ), + child: _buildView(context))), + Positioned( + bottom: size.height / 10, + right: size.width / 10, + child: _buildIconList(context)) + ], + ), + ); + } +} diff --git a/lib/globals.dart b/lib/globals.dart index 571fc22..3d56949 100644 --- a/lib/globals.dart +++ b/lib/globals.dart @@ -7,6 +7,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart' show ApplicationSwitcherDescription, SystemChrome; +import 'package:flutter_datetime_picker_plus/flutter_datetime_picker_plus.dart' + show LocaleType; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; @@ -171,6 +173,7 @@ enum MoreVertSettings { taskManager, markAsAd, markAsNonAd, + shareGallery, } void onMoreVertSettingsSelected(BuildContext context, MoreVertSettings value) { @@ -196,6 +199,12 @@ void onMoreVertSettingsSelected(BuildContext context, MoreVertSettings value) { case MoreVertSettings.markAsNonAd: GalleryPage.maybeOf(context)?.markAsAd(false); break; + case MoreVertSettings.shareGallery: + final gid = GalleryPage.maybeOf(context)?.gid; + if (gid != null) { + context.push("/dialog/gallery/share/$gid"); + } + break; default: break; } @@ -223,6 +232,10 @@ List> buildMoreVertSettings( list.add(PopupMenuItem( value: MoreVertSettings.taskManager, child: Text(i18n.taskManager))); } + if (path == "/gallery/:gid" && auth.canShareGallery == true) { + list.add(PopupMenuItem( + value: MoreVertSettings.shareGallery, child: Text(i18n.shareGallery))); + } var showNsfw = prefs.getBool("showNsfw") ?? false; list.add(PopupMenuItem( child: StatefulBuilder( @@ -363,6 +376,16 @@ enum Lang { return PlatformDispatcher.instance.locale; } } + + LocaleType toLocaleType() { + final l = toLocale(); + switch (l.languageCode) { + case "zh": + return LocaleType.zh; + default: + return LocaleType.en; + } + } } enum ThumbnailSize { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 892fdf3..fd42111 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -315,5 +315,17 @@ } }, "enableServerTiming": "Enable server time tracking", - "shareGallery": "Share gallery" + "shareGallery": "Share gallery", + "expireTime": "Expire time", + "token": "Token", + "action": "Action", + "never": "Never", + "oneDayAfter": "1 day after", + "oneWeekAfter": "1 week after", + "oneMonthAfter": "1 month after", + "custom": "Custom", + "editExpireTime": "Edit expire time", + "share": "Share", + "failedChangeExpireTime": "Failed to change expired time: ", + "failedShareGallery": "Failed to share gallery: " } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 29560d8..037e9e3 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -315,5 +315,17 @@ } }, "enableServerTiming": "测量服务器所用时间", - "shareGallery": "分享画廊" + "shareGallery": "分享画廊", + "expireTime": "过期时间", + "token": "令牌", + "action": "操作", + "never": "从不", + "oneDayAfter": "1 天后", + "oneWeekAfter": "1 周后", + "oneMonthAfter": "1 月后", + "custom": "自定义", + "editExpireTime": "修改过期时间", + "share": "分享", + "failedChangeExpireTime": "修改过期时间失败:", + "failedShareGallery": "分享画廊失败:" } diff --git a/lib/main.dart b/lib/main.dart index a85d16b..29604a1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'dialog/dialog_page.dart'; import 'dialog/download_zip_page.dart'; import 'dialog/edit_user_page.dart'; import 'dialog/gallery_details_page.dart'; +import 'dialog/gallery_share_page.dart'; import 'dialog/new_download_task_page.dart'; import 'dialog/new_export_zip_task_page.dart'; import 'dialog/new_import_task_page.dart'; @@ -307,6 +308,25 @@ final _router = GoRouter( return NewImportTaskPage(gid: gid, token: token); }); }), + GoRoute( + path: GallerySharePage.routeName, + pageBuilder: (context, state) { + final gid = int.parse(state.pathParameters["gid"]!); + return DialogPage( + key: state.pageKey, + builder: (context) { + return GallerySharePage(gid); + }); + }, + redirect: (context, state) { + try { + int.parse(state.pathParameters["gid"]!); + return null; + } catch (e) { + _routerLog.warning("Failed to parse gid:", e); + return "/"; + } + }), ], observers: [ _NavigatorObserver(), diff --git a/lib/pages/gallery.dart b/lib/pages/gallery.dart index 1e2664d..c0af836 100644 --- a/lib/pages/gallery.dart +++ b/lib/pages/gallery.dart @@ -50,6 +50,7 @@ class _GalleryPage extends State final List _selected = []; bool? get isAllNsfw => _data?.isAllNsfw; bool get isSelectMode => _isSelectMode; + int get gid => widget._gid; Future markGalleryAsNsfw(bool isNsfw) async { try { _markAsNsfwCancel = CancelToken(); diff --git a/pubspec.lock b/pubspec.lock index d41bf3c..a61c2cb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -300,6 +300,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_datetime_picker_plus: + dependency: "direct main" + description: + name: flutter_datetime_picker_plus + sha256: "7d82da02c4e070bb28a9107de119ad195e2319b45c786fecc13482a9ffcc51da" + url: "https://pub.dev" + source: hosted + version: "2.2.0" flutter_hooks: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index f778eca..10a9389 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: file: ^7.0.0 flutter: sdk: flutter + flutter_datetime_picker_plus: ^2.2.0 flutter_hooks: ^0.20.0 flutter_localizations: sdk: flutter