This commit is contained in:
2023-09-03 12:22:27 +08:00
parent ade2bb2113
commit 95b8435f78
16 changed files with 555 additions and 34 deletions

View File

@@ -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<ApiResult<EhFiles>> _getFiles(@Path("token") String token);
@GET('/gallery/{gid}')
Future<ApiResult<GalleryData>> getGallery(@Path("gid") int gid);
@GET('/gallery/list')
Future<ApiResult<List<GMeta>>> listGalleries(
{@Query("all") bool? all,
@Query("offset") int? offset,
@Query("limit") int? limit});
}
class EHApi extends __EHApi {

View File

@@ -408,6 +408,79 @@ class __EHApi implements _EHApi {
return value;
}
@override
Future<ApiResult<GalleryData>> getGallery(int gid) async {
const _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{};
final _headers = <String, dynamic>{};
final Map<String, dynamic>? _data = null;
final _result = await _dio.fetch<Map<String, dynamic>>(
_setStreamType<ApiResult<GalleryData>>(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<GalleryData>.fromJson(
_result.data!,
(json) => GalleryData.fromJson(json as Map<String, dynamic>),
);
return value;
}
@override
Future<ApiResult<List<GMeta>>> listGalleries({
bool? all,
int? offset,
int? limit,
}) async {
const _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{
r'all': all,
r'offset': offset,
r'limit': limit,
};
queryParameters.removeWhere((k, v) => v == null);
final _headers = <String, dynamic>{};
final Map<String, dynamic>? _data = null;
final _result = await _dio.fetch<Map<String, dynamic>>(
_setStreamType<ApiResult<List<GMeta>>>(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<List<GMeta>>.fromJson(
_result.data!,
(json) => json is List<dynamic>
? json
.map<GMeta>((i) => GMeta.fromJson(i as Map<String, dynamic>))
.toList()
: List.empty(),
);
return value;
}
RequestOptions _setStreamType<T>(RequestOptions requestOptions) {
if (T != dynamic &&
!(requestOptions.responseType == ResponseType.bytes ||

163
lib/api/gallery.dart Normal file
View File

@@ -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<String, dynamic> json) => _$GMetaFromJson(json);
Map<String, dynamic> 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<String, dynamic> json) =>
_$GMetaOptionalFromJson(json);
Map<String, dynamic> 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<String, dynamic> json) => _$TagFromJson(json);
Map<String, dynamic> 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<String, dynamic> json) =>
_$ExtendedPMetaFromJson(json);
Map<String, dynamic> toJson() => _$ExtendedPMetaToJson(this);
}
@JsonSerializable()
class GalleryData {
const GalleryData({
required this.meta,
required this.tags,
required this.pages,
});
final GMeta meta;
final List<Tag> tags;
final List<ExtendedPMeta> pages;
factory GalleryData.fromJson(Map<String, dynamic> json) =>
_$GalleryDataFromJson(json);
Map<String, dynamic> toJson() => _$GalleryDataToJson(this);
}

136
lib/api/gallery.g.dart Normal file
View File

@@ -0,0 +1,136 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'gallery.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
GMeta _$GMetaFromJson(Map<String, dynamic> 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<String, dynamic> _$GMetaToJson(GMeta instance) => <String, dynamic>{
'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<String, dynamic> 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<String, dynamic> _$GMetaOptionalToJson(GMetaOptional instance) =>
<String, dynamic>{
'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<String, dynamic> json) => Tag(
id: json['id'] as int,
tag: json['tag'] as String,
translated: json['translated'] as String?,
intro: json['intro'] as String?,
);
Map<String, dynamic> _$TagToJson(Tag instance) => <String, dynamic>{
'id': instance.id,
'tag': instance.tag,
'translated': instance.translated,
'intro': instance.intro,
};
ExtendedPMeta _$ExtendedPMetaFromJson(Map<String, dynamic> 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<String, dynamic> _$ExtendedPMetaToJson(ExtendedPMeta instance) =>
<String, dynamic>{
'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<String, dynamic> json) => GalleryData(
meta: GMeta.fromJson(json['meta'] as Map<String, dynamic>),
tags: (json['tags'] as List<dynamic>)
.map((e) => Tag.fromJson(e as Map<String, dynamic>))
.toList(),
pages: (json['pages'] as List<dynamic>)
.map((e) => ExtendedPMeta.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$GalleryDataToJson(GalleryData instance) =>
<String, dynamic>{
'meta': instance.meta,
'tags': instance.tags,
'pages': instance.pages,
};

View File

@@ -181,7 +181,7 @@ class _CreateRootUserPage extends State<CreateRootUserPage>
});
_createRootUser(_username, _password).then((re) {
if (re) {
auth.clear();
clearAllStates(context);
context.canPop()
? context.pop()
: context.go("/");

86
lib/galleries.dart Normal file
View File

@@ -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<GalleriesPage> createState() => _GalleriesPage();
}
class _GalleriesPage extends State<GalleriesPage> with ThemeModeWidget {
static const int _pageSize = 20;
final PagingController<int, GMeta> _pagingController =
PagingController(firstPageKey: 0);
Future<void> _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<int, GMeta>(
physics: const AlwaysScrollableScrollPhysics(),
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<GMeta>(
itemBuilder: (context, item, index) {
return ListTile(
title: SelectableText(item.title),
onTap: () {
context.push("/gallery/${item.gid}");
},
);
}),
));
}
@override
void dispose() {
_pagingController.dispose();
super.dispose();
}
}

View File

@@ -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);
});
}
}

View File

@@ -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");
},
),
),
);
}

View File

@@ -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"
}

View File

@@ -1,4 +1,5 @@
{
"@@locale": "zh",
"title": "E-Hentai 下载器面板",
"serverHost": "EH Downloader 服务器主机",
"apiPath": "API 路径",
@@ -17,5 +18,6 @@
"settings": "设置",
"lang": "语言",
"systemLang": "系统语言",
"reset": "重置"
"reset": "重置",
"galleries": "画廊"
}

View File

@@ -156,7 +156,7 @@ class _LoginPageState extends State<LoginPage> with ThemeModeWidget {
});
login(_username, _password).then((re) {
if (re) {
auth.clear();
clearAllStates(context);
context.canPop()
? context.pop()
: context.go("/");

View File

@@ -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<MainApp> {
ThemeData _darkThemeData = ThemeData.dark();
ThemeMode get themeMode => _themeMode;
Lang _lang = Lang.system;
Lang get lang => lang;
Lang get lang => _lang;
@override
void initState() {

View File

@@ -121,8 +121,16 @@ class _SetServerPageState extends State<SetServerPage> 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)),

View File

@@ -17,24 +17,24 @@ class SettingsPage extends StatefulWidget {
}
class _SettingsPage extends State<SettingsPage> 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<SettingsPage> with ThemeModeWidget {
re = false;
_log.warning("Failed to save lang.");
} else {
_ori_lang = _lang;
_oriLang = _lang;
}
return re;
}