diff --git a/lib/api/client.dart b/lib/api/client.dart index b10f10a..6a8d10d 100644 --- a/lib/api/client.dart +++ b/lib/api/client.dart @@ -4,6 +4,7 @@ import 'package:dio/dio.dart'; import 'package:eh_downloader_flutter/api/file.dart'; import 'package:retrofit/retrofit.dart'; import 'api_result.dart'; +import 'gallery.dart'; import 'status.dart'; import 'token.dart'; import 'user.dart'; @@ -76,6 +77,14 @@ abstract class _EHApi { @GET('/files/{token}') // ignore: unused_element Future> _getFiles(@Path("token") String token); + + @GET('/gallery/{gid}') + Future> getGallery(@Path("gid") int gid); + @GET('/gallery/list') + Future>> listGalleries( + {@Query("all") bool? all, + @Query("offset") int? offset, + @Query("limit") int? limit}); } class EHApi extends __EHApi { diff --git a/lib/api/client.g.dart b/lib/api/client.g.dart index d4e28b5..959d356 100644 --- a/lib/api/client.g.dart +++ b/lib/api/client.g.dart @@ -408,6 +408,79 @@ class __EHApi implements _EHApi { return value; } + @override + Future> getGallery(int gid) async { + const _extra = {}; + final queryParameters = {}; + final _headers = {}; + final Map? _data = null; + final _result = await _dio.fetch>( + _setStreamType>(Options( + method: 'GET', + headers: _headers, + extra: _extra, + ) + .compose( + _dio.options, + '/gallery/${gid}', + queryParameters: queryParameters, + data: _data, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + )))); + final value = ApiResult.fromJson( + _result.data!, + (json) => GalleryData.fromJson(json as Map), + ); + return value; + } + + @override + Future>> listGalleries({ + bool? all, + int? offset, + int? limit, + }) async { + const _extra = {}; + final queryParameters = { + r'all': all, + r'offset': offset, + r'limit': limit, + }; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + final Map? _data = null; + final _result = await _dio.fetch>( + _setStreamType>>(Options( + method: 'GET', + headers: _headers, + extra: _extra, + ) + .compose( + _dio.options, + '/gallery/list', + queryParameters: queryParameters, + data: _data, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + )))); + final value = ApiResult>.fromJson( + _result.data!, + (json) => json is List + ? json + .map((i) => GMeta.fromJson(i as Map)) + .toList() + : List.empty(), + ); + return value; + } + RequestOptions _setStreamType(RequestOptions requestOptions) { if (T != dynamic && !(requestOptions.responseType == ResponseType.bytes || diff --git a/lib/api/gallery.dart b/lib/api/gallery.dart new file mode 100644 index 0000000..35221c4 --- /dev/null +++ b/lib/api/gallery.dart @@ -0,0 +1,163 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'gallery.g.dart'; + +@JsonSerializable() +class GMeta { + const GMeta({ + required this.gid, + required this.token, + required this.title, + required this.titleJpn, + required this.category, + required this.uploader, + required this.posted, + required this.filecount, + required this.filesize, + required this.expunged, + required this.rating, + this.parentGid, + this.parentToken, + this.firstGid, + this.firstToken, + }); + final int gid; + final String token; + final String title; + @JsonKey(name: 'title_jpn') + final String titleJpn; + final String category; + final String uploader; + @JsonKey(fromJson: _fromJson, toJson: _toJson) + final DateTime posted; + final int filecount; + final int filesize; + final bool expunged; + final double rating; + @JsonKey(name: 'parent_gid') + final int? parentGid; + @JsonKey(name: 'parent_token') + final String? parentToken; + @JsonKey(name: 'first_gid') + final int? firstGid; + @JsonKey(name: 'first_token') + final String? firstToken; + + static DateTime _fromJson(int posted) => + DateTime.fromMillisecondsSinceEpoch(posted * 1000); + static int _toJson(DateTime posted) => posted.millisecondsSinceEpoch ~/ 1000; + factory GMeta.fromJson(Map json) => _$GMetaFromJson(json); + Map toJson() => _$GMetaToJson(this); +} + +@JsonSerializable() +class GMetaOptional { + const GMetaOptional({ + this.gid, + this.token, + this.title, + this.titleJpn, + this.category, + this.uploader, + this.posted, + this.filecount, + this.filesize, + this.expunged, + this.rating, + this.parentGid, + this.parentToken, + this.firstGid, + this.firstToken, + }); + final int? gid; + final String? token; + final String? title; + @JsonKey(name: 'title_jpn') + final String? titleJpn; + final String? category; + final String? uploader; + @JsonKey(fromJson: _fromJson, toJson: _toJson) + final DateTime? posted; + final int? filecount; + final int? filesize; + final bool? expunged; + final double? rating; + @JsonKey(name: 'parent_gid') + final int? parentGid; + @JsonKey(name: 'parent_token') + final String? parentToken; + @JsonKey(name: 'first_gid') + final int? firstGid; + @JsonKey(name: 'first_token') + final String? firstToken; + + static DateTime? _fromJson(int? posted) => posted != null + ? DateTime.fromMillisecondsSinceEpoch(posted! * 1000) + : null; + static int? _toJson(DateTime? posted) => + posted != null ? posted.millisecondsSinceEpoch ~/ 1000 : null; + factory GMetaOptional.fromJson(Map json) => + _$GMetaOptionalFromJson(json); + Map toJson() => _$GMetaOptionalToJson(this); +} + +@JsonSerializable() +class Tag { + const Tag({ + required this.id, + required this.tag, + this.translated, + this.intro, + }); + final int id; + final String tag; + final String? translated; + final String? intro; + + factory Tag.fromJson(Map json) => _$TagFromJson(json); + Map toJson() => _$TagToJson(this); +} + +@JsonSerializable() +class ExtendedPMeta { + const ExtendedPMeta({ + required this.gid, + required this.index, + required this.token, + required this.name, + required this.width, + required this.height, + required this.isNsfw, + required this.isAd, + }); + final int gid; + final int index; + final String token; + final String name; + final int width; + final int height; + @JsonKey(name: 'is_nsfw') + final bool isNsfw; + @JsonKey(name: 'is_ad') + final bool isAd; + + factory ExtendedPMeta.fromJson(Map json) => + _$ExtendedPMetaFromJson(json); + Map toJson() => _$ExtendedPMetaToJson(this); +} + +@JsonSerializable() +class GalleryData { + const GalleryData({ + required this.meta, + required this.tags, + required this.pages, + }); + final GMeta meta; + final List tags; + final List pages; + + factory GalleryData.fromJson(Map json) => + _$GalleryDataFromJson(json); + Map toJson() => _$GalleryDataToJson(this); +} diff --git a/lib/api/gallery.g.dart b/lib/api/gallery.g.dart new file mode 100644 index 0000000..5c0723d --- /dev/null +++ b/lib/api/gallery.g.dart @@ -0,0 +1,136 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'gallery.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GMeta _$GMetaFromJson(Map json) => GMeta( + gid: json['gid'] as int, + token: json['token'] as String, + title: json['title'] as String, + titleJpn: json['title_jpn'] as String, + category: json['category'] as String, + uploader: json['uploader'] as String, + posted: GMeta._fromJson(json['posted'] as int), + filecount: json['filecount'] as int, + filesize: json['filesize'] as int, + expunged: json['expunged'] as bool, + rating: (json['rating'] as num).toDouble(), + parentGid: json['parent_gid'] as int?, + parentToken: json['parent_token'] as String?, + firstGid: json['first_gid'] as int?, + firstToken: json['first_token'] as String?, + ); + +Map _$GMetaToJson(GMeta instance) => { + 'gid': instance.gid, + 'token': instance.token, + 'title': instance.title, + 'title_jpn': instance.titleJpn, + 'category': instance.category, + 'uploader': instance.uploader, + 'posted': GMeta._toJson(instance.posted), + 'filecount': instance.filecount, + 'filesize': instance.filesize, + 'expunged': instance.expunged, + 'rating': instance.rating, + 'parent_gid': instance.parentGid, + 'parent_token': instance.parentToken, + 'first_gid': instance.firstGid, + 'first_token': instance.firstToken, + }; + +GMetaOptional _$GMetaOptionalFromJson(Map json) => + GMetaOptional( + gid: json['gid'] as int?, + token: json['token'] as String?, + title: json['title'] as String?, + titleJpn: json['title_jpn'] as String?, + category: json['category'] as String?, + uploader: json['uploader'] as String?, + posted: GMetaOptional._fromJson(json['posted'] as int?), + filecount: json['filecount'] as int?, + filesize: json['filesize'] as int?, + expunged: json['expunged'] as bool?, + rating: (json['rating'] as num?)?.toDouble(), + parentGid: json['parent_gid'] as int?, + parentToken: json['parent_token'] as String?, + firstGid: json['first_gid'] as int?, + firstToken: json['first_token'] as String?, + ); + +Map _$GMetaOptionalToJson(GMetaOptional instance) => + { + 'gid': instance.gid, + 'token': instance.token, + 'title': instance.title, + 'title_jpn': instance.titleJpn, + 'category': instance.category, + 'uploader': instance.uploader, + 'posted': GMetaOptional._toJson(instance.posted), + 'filecount': instance.filecount, + 'filesize': instance.filesize, + 'expunged': instance.expunged, + 'rating': instance.rating, + 'parent_gid': instance.parentGid, + 'parent_token': instance.parentToken, + 'first_gid': instance.firstGid, + 'first_token': instance.firstToken, + }; + +Tag _$TagFromJson(Map json) => Tag( + id: json['id'] as int, + tag: json['tag'] as String, + translated: json['translated'] as String?, + intro: json['intro'] as String?, + ); + +Map _$TagToJson(Tag instance) => { + 'id': instance.id, + 'tag': instance.tag, + 'translated': instance.translated, + 'intro': instance.intro, + }; + +ExtendedPMeta _$ExtendedPMetaFromJson(Map json) => + ExtendedPMeta( + gid: json['gid'] as int, + index: json['index'] as int, + token: json['token'] as String, + name: json['name'] as String, + width: json['width'] as int, + height: json['height'] as int, + isNsfw: json['is_nsfw'] as bool, + isAd: json['is_ad'] as bool, + ); + +Map _$ExtendedPMetaToJson(ExtendedPMeta instance) => + { + 'gid': instance.gid, + 'index': instance.index, + 'token': instance.token, + 'name': instance.name, + 'width': instance.width, + 'height': instance.height, + 'is_nsfw': instance.isNsfw, + 'is_ad': instance.isAd, + }; + +GalleryData _$GalleryDataFromJson(Map json) => GalleryData( + meta: GMeta.fromJson(json['meta'] as Map), + tags: (json['tags'] as List) + .map((e) => Tag.fromJson(e as Map)) + .toList(), + pages: (json['pages'] as List) + .map((e) => ExtendedPMeta.fromJson(e as Map)) + .toList(), + ); + +Map _$GalleryDataToJson(GalleryData instance) => + { + 'meta': instance.meta, + 'tags': instance.tags, + 'pages': instance.pages, + }; diff --git a/lib/create_root_user.dart b/lib/create_root_user.dart index d5429d2..e834364 100644 --- a/lib/create_root_user.dart +++ b/lib/create_root_user.dart @@ -181,7 +181,7 @@ class _CreateRootUserPage extends State }); _createRootUser(_username, _password).then((re) { if (re) { - auth.clear(); + clearAllStates(context); context.canPop() ? context.pop() : context.go("/"); diff --git a/lib/galleries.dart b/lib/galleries.dart new file mode 100644 index 0000000..5f6f261 --- /dev/null +++ b/lib/galleries.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:logging/logging.dart'; +import 'api/gallery.dart'; +import 'globals.dart'; + +final _log = Logger("GalleriesPage"); + +class GalleriesPage extends StatefulWidget { + const GalleriesPage({Key? key}) : super(key: key); + + static const String routeName = '/galleries'; + + @override + State createState() => _GalleriesPage(); +} + +class _GalleriesPage extends State with ThemeModeWidget { + static const int _pageSize = 20; + + final PagingController _pagingController = + PagingController(firstPageKey: 0); + + Future _fetchPage(int pageKey) async { + try { + final list = + (await api.listGalleries(offset: pageKey, limit: _pageSize)).unwrap(); + final isLastPage = list.length < _pageSize; + if (isLastPage) { + _pagingController.appendLastPage(list); + } else { + final nextPageKey = pageKey + list.length; + _pagingController.appendPage(list, nextPageKey); + } + } catch (e) { + _log.severe("Failed to load page with offset $pageKey:", e); + _pagingController.error = e; + } + } + + @override + void initState() { + _pagingController.addPageRequestListener((pageKey) { + _fetchPage(pageKey); + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + context.canPop() ? context.pop() : context.go("/"); + }, + ), + title: Text(AppLocalizations.of(context)!.galleries), + actions: [ + buildThemeModeIcon(context), + buildMoreVertSettingsButon(context), + ]), + body: PagedListView( + physics: const AlwaysScrollableScrollPhysics(), + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) { + return ListTile( + title: SelectableText(item.title), + onTap: () { + context.push("/gallery/${item.gid}"); + }, + ); + }), + )); + } + + @override + void dispose() { + _pagingController.dispose(); + super.dispose(); + } +} diff --git a/lib/globals.dart b/lib/globals.dart index ce5f2cb..8375158 100644 --- a/lib/globals.dart +++ b/lib/globals.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -92,8 +93,8 @@ bool tryInitApi(BuildContext context) { if (_api != null && _api!.baseUrl == baseUrl) { return true; } - auth.clear(); initApi(baseUrl); + clearAllStates(context); return true; } @@ -213,3 +214,24 @@ enum Lang { } } } + +final _authLog = Logger("AuthLog"); + +void clearAllStates(BuildContext context) { + auth.clear(); + checkAuth(context); +} + +void checkAuth(BuildContext context) { + if (!auth.isAuthed && !auth.checked && !auth.isChecking) { + auth.checkAuth().then((re) { + if (!re) { + if (auth.status!.noUser && prefs.getBool("skipCreateRootUser") == true) + return; + context.push(auth.status!.noUser ? "/create_root_user" : "/login"); + } + }).catchError((err) { + _authLog.log(Level.SEVERE, "Failed to check auth info:", err); + }); + } +} diff --git a/lib/home.dart b/lib/home.dart index 1cbf499..6ee0b74 100644 --- a/lib/home.dart +++ b/lib/home.dart @@ -15,20 +15,7 @@ class HomePage extends HookWidget { @override Widget build(BuildContext context) { - if (tryInitApi(context) && - !auth.isAuthed && - !auth.checked && - !auth.isChecking) { - auth.checkAuth().then((re) { - if (!re) { - if (auth.status!.noUser && - prefs.getBool("skipCreateRootUser") == true) return; - context.push(auth.status!.noUser ? "/create_root_user" : "/login"); - } - }).catchError((err) { - _log.log(Level.SEVERE, "Failed to check auth info:", err); - }); - } + tryInitApi(context); var mode = useState(MainApp.of(context).themeMode); return Scaffold( appBar: AppBar( @@ -48,8 +35,13 @@ class HomePage extends HookWidget { buildMoreVertSettingsButon(context), ], ), - body: const Center( - child: Text('Hello World!'), + body: Center( + child: TextButton( + child: Text('Hello World!'), + onPressed: () { + context.push("/galleries"); + }, + ), ), ); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 14a9080..cb031a8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,4 +1,5 @@ { + "@@locale": "en", "title": "E-Hentai Downloader Dashboard", "serverHost": "EH Downloader Server Host", "apiPath": "API Path", @@ -17,5 +18,6 @@ "settings": "Settings", "lang": "Language", "systemLang": "System Language", - "reset": "Reset" + "reset": "Reset", + "galleries": "Galleries" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a061543..6f0bb6e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,4 +1,5 @@ { + "@@locale": "zh", "title": "E-Hentai 下载器面板", "serverHost": "EH Downloader 服务器主机", "apiPath": "API 路径", @@ -17,5 +18,6 @@ "settings": "设置", "lang": "语言", "systemLang": "系统语言", - "reset": "重置" + "reset": "重置", + "galleries": "画廊" } diff --git a/lib/login.dart b/lib/login.dart index 9b3a07b..25f20a2 100644 --- a/lib/login.dart +++ b/lib/login.dart @@ -156,7 +156,7 @@ class _LoginPageState extends State with ThemeModeWidget { }); login(_username, _password).then((re) { if (re) { - auth.clear(); + clearAllStates(context); context.canPop() ? context.pop() : context.go("/"); diff --git a/lib/main.dart b/lib/main.dart index 4f539ee..8ea0fe7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import 'package:window_manager/window_manager.dart'; import 'create_root_user.dart'; +import 'galleries.dart'; import 'globals.dart'; import 'home.dart'; import 'login.dart'; @@ -19,9 +20,7 @@ final _router = GoRouter( routes: [ GoRoute( path: HomePage.routeName, - // Add const will cause checkAuth not works when pop to home page. - // ignore: prefer_const_constructors - builder: (context, state) => HomePage(), + builder: (context, state) => const HomePage(), ), GoRoute( path: SetServerPage.routeName, @@ -38,6 +37,10 @@ final _router = GoRouter( GoRoute( path: SettingsPage.routeName, builder: (context, state) => const SettingsPage(), + ), + GoRoute( + path: GalleriesPage.routeName, + builder: (context, state) => const GalleriesPage(), ) ], ); @@ -105,7 +108,7 @@ class _MainApp extends State { ThemeData _darkThemeData = ThemeData.dark(); ThemeMode get themeMode => _themeMode; Lang _lang = Lang.system; - Lang get lang => lang; + Lang get lang => _lang; @override void initState() { diff --git a/lib/set_server.dart b/lib/set_server.dart index f9f600b..7ac3d0a 100644 --- a/lib/set_server.dart +++ b/lib/set_server.dart @@ -121,8 +121,16 @@ class _SetServerPageState extends State with ThemeModeWidget { ElevatedButton( onPressed: _isValid ? () { - prefs.setString('baseUrl', _serverUrl + _apiPath); - context.go('/'); + prefs + .setString('baseUrl', _serverUrl + _apiPath) + .then((re) { + if (re) { + tryInitApi(context); + context.canPop() + ? context.pop() + : context.go("/"); + } + }); } : null, child: Text(AppLocalizations.of(context)!.save)), diff --git a/lib/settings.dart b/lib/settings.dart index 6ff3037..ce5a295 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -17,24 +17,24 @@ class SettingsPage extends StatefulWidget { } class _SettingsPage extends State with ThemeModeWidget { - Lang _ori_lang = Lang.system; + Lang _oriLang = Lang.system; Lang _lang = Lang.system; @override void initState() { super.initState(); try { - _ori_lang = Lang.values[prefs.getInt("lang") ?? 0]; - _lang = _ori_lang; + _oriLang = Lang.values[prefs.getInt("lang") ?? 0]; + _lang = _oriLang; } catch (e) { _log.warning("Failed to get lang:", e); - _ori_lang = Lang.system; + _oriLang = Lang.system; _lang = Lang.system; } } void fallback(BuildContext context) { - if (_ori_lang != _lang) { - MainApp.of(context).changeLang(_ori_lang); + if (_oriLang != _lang) { + MainApp.of(context).changeLang(_oriLang); } } @@ -51,7 +51,7 @@ class _SettingsPage extends State with ThemeModeWidget { re = false; _log.warning("Failed to save lang."); } else { - _ori_lang = _lang; + _oriLang = _lang; } return re; } diff --git a/pubspec.lock b/pubspec.lock index 88d772b..041b168 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -284,6 +284,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_staggered_grid_view: + dependency: transitive + description: + name: flutter_staggered_grid_view + sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395" + url: "https://pub.dev" + source: hosted + version: "0.7.0" flutter_test: dependency: "direct dev" description: flutter @@ -350,6 +358,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + infinite_scroll_pagination: + dependency: "direct main" + description: + name: infinite_scroll_pagination + sha256: b68bce20752fcf36c7739e60de4175494f74e99e9a69b4dd2fe3a1dd07a7f16a + url: "https://pub.dev" + source: hosted + version: "4.0.0" intl: dependency: "direct main" description: @@ -643,6 +659,14 @@ packages: description: flutter source: sdk version: "0.0.99" + sliver_tools: + dependency: transitive + description: + name: sliver_tools + sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 + url: "https://pub.dev" + source: hosted + version: "0.2.12" source_gen: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ed96154..7a6ac34 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: flutter_localizations: sdk: flutter go_router: ^10.1.0 + infinite_scroll_pagination: ^4.0.0 intl: any json_annotation: ^4.8.1 logging: ^1.2.0