From a3868b1969f0879ae9718825eed4fc39d44252c7 Mon Sep 17 00:00:00 2001 From: wgh19 Date: Mon, 20 May 2024 15:16:35 +0800 Subject: [PATCH] novel --- assets/tr.json | 10 +- lib/components/grid.dart | 15 +- lib/components/loading.dart | 2 + lib/components/novel.dart | 84 +++ lib/foundation/app.dart | 2 + lib/main.dart | 130 +++-- lib/network/models.dart | 114 +++- lib/network/network.dart | 34 ++ lib/network/novel.dart | 152 +++++ lib/pages/comments_page.dart | 210 +++++++ lib/pages/illust_page.dart | 162 +----- lib/pages/main_page.dart | 88 ++- lib/pages/novel_bookmarks_page.dart | 53 ++ lib/pages/novel_page.dart | 669 +++++++++++++++++++++++ lib/pages/novel_ranking_page.dart | 102 ++++ lib/pages/novel_reading_page.dart | 49 ++ lib/pages/novel_recommendation_page.dart | 46 ++ lib/pages/search_page.dart | 507 +++++++++++------ lib/pages/user_info_page.dart | 111 +++- lib/utils/app_links.dart | 34 ++ 20 files changed, 2146 insertions(+), 428 deletions(-) create mode 100644 lib/components/novel.dart create mode 100644 lib/network/novel.dart create mode 100644 lib/pages/comments_page.dart create mode 100644 lib/pages/novel_bookmarks_page.dart create mode 100644 lib/pages/novel_page.dart create mode 100644 lib/pages/novel_ranking_page.dart create mode 100644 lib/pages/novel_reading_page.dart create mode 100644 lib/pages/novel_recommendation_page.dart diff --git a/assets/tr.json b/assets/tr.json index 74ffe3e..c030fd1 100644 --- a/assets/tr.json +++ b/assets/tr.json @@ -129,7 +129,10 @@ "Related": "相关", "Related artworks": "相关作品", "Related users": "相关用户", - "Replace with '-p${index}' if the work have more than one images, otherwise replace with blank.": "替换为'-p${index}'如果作品有多张图片, 否则替换为空白" + "Replace with '-p${index}' if the work have more than one images, otherwise replace with blank.": "替换为'-p${index}'如果作品有多张图片, 否则替换为空白", + "Recommendation": "推荐", + "Novel": "小说", + "Novels": "小说" }, "zh_TW": { "Search": "搜索", @@ -261,6 +264,9 @@ "Related": "相關", "Related artworks": "相關作品", "Related users": "相關用戶", - "Replace with '-p${index}' if the work have more than one images, otherwise replace with blank.": "替換為'-p${index}'如果作品有多張圖片, 否則替換為空白" + "Replace with '-p${index}' if the work have more than one images, otherwise replace with blank.": "替換為'-p${index}'如果作品有多張圖片, 否則替換為空白", + "Recommendation": "推薦", + "Novel": "小說", + "Novels": "小說" } } \ No newline at end of file diff --git a/lib/components/grid.dart b/lib/components/grid.dart index aeb9d90..bc3b308 100644 --- a/lib/components/grid.dart +++ b/lib/components/grid.dart @@ -20,14 +20,13 @@ class SliverGridViewWithFixedItemHeight extends StatelessWidget { @override Widget build(BuildContext context) { - return SliverLayoutBuilder( - builder: ((context, constraints) => SliverGrid( - delegate: delegate, - gridDelegate: SliverGridDelegateWithFixedHeight( - itemHeight: itemHeight, - maxCrossAxisExtent: maxCrossAxisExtent, - minCrossAxisExtent: minCrossAxisExtent), - ).sliverPadding(EdgeInsets.only(bottom: context.padding.bottom)))); + return SliverGrid( + delegate: delegate, + gridDelegate: SliverGridDelegateWithFixedHeight( + itemHeight: itemHeight, + maxCrossAxisExtent: maxCrossAxisExtent, + minCrossAxisExtent: minCrossAxisExtent), + ).sliverPadding(EdgeInsets.only(bottom: context.padding.bottom)); } } diff --git a/lib/components/loading.dart b/lib/components/loading.dart index 9a8b512..4f41c58 100644 --- a/lib/components/loading.dart +++ b/lib/components/loading.dart @@ -112,6 +112,8 @@ abstract class MultiPageLoadingState bool get isLoading => _isLoading || _isFirstLoading; + bool get isFirstLoading => _isFirstLoading; + void nextPage() { if(_isLoading) return; _isLoading = true; diff --git a/lib/components/novel.dart b/lib/components/novel.dart new file mode 100644 index 0000000..67f0719 --- /dev/null +++ b/lib/components/novel.dart @@ -0,0 +1,84 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:pixes/components/animated_image.dart'; +import 'package:pixes/components/md.dart'; +import 'package:pixes/foundation/app.dart'; +import 'package:pixes/foundation/image_provider.dart'; +import 'package:pixes/network/network.dart'; +import 'package:pixes/pages/novel_page.dart'; + +class NovelWidget extends StatefulWidget { + const NovelWidget(this.novel, {super.key}); + + final Novel novel; + + @override + State createState() => _NovelWidgetState(); +} + +class _NovelWidgetState extends State { + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: GestureDetector( + onTap: () { + context.to(() => NovelPage(widget.novel)); + }, + behavior: HitTestBehavior.opaque, + child: Row( + children: [ + Container( + width: 96, + height: double.infinity, + decoration: BoxDecoration( + color: ColorScheme.of(context).secondaryContainer, + borderRadius: BorderRadius.circular(4), + ), + clipBehavior: Clip.antiAlias, + child: AnimatedImage( + fit: BoxFit.cover, + filterQuality: FilterQuality.medium, + width: double.infinity, + height: double.infinity, + image: CachedImageProvider(widget.novel.image), + ), + ), + const SizedBox( + width: 12, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.novel.title, + maxLines: 2, + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox( + height: 4, + ), + Expanded( + child: Text( + widget.novel.caption.trim().replaceAll('
', '\n'), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox( + height: 4, + ), + Text( + widget.novel.author.name, + style: const TextStyle(fontSize: 12), + ) + ], + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/foundation/app.dart b/lib/foundation/app.dart index 2a04727..5e6654d 100644 --- a/lib/foundation/app.dart +++ b/lib/foundation/app.dart @@ -47,6 +47,8 @@ class _App { } final rootNavigatorKey = GlobalKey(); + + GlobalKey? mainNavigatorKey; } // ignore: non_constant_identifier_names diff --git a/lib/main.dart b/lib/main.dart index 3e89003..394fbde 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,8 @@ import "dart:ui"; +import "package:dynamic_color/dynamic_color.dart"; import "package:fluent_ui/fluent_ui.dart"; +import "package:flutter/material.dart" as md; import "package:flutter/services.dart"; import "package:pixes/appdata.dart"; import "package:pixes/components/md.dart"; @@ -12,7 +14,6 @@ import "package:pixes/pages/main_page.dart"; import "package:pixes/utils/app_links.dart"; import "package:pixes/utils/translation.dart"; import "package:window_manager/window_manager.dart"; -import 'package:system_theme/system_theme.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -20,15 +21,10 @@ void main() async { Log.error("Unhandled", "${details.exception}\n${details.stack}"); }; setSystemProxy(); - SystemTheme.fallbackColor = Colors.blue; - await SystemTheme.accentColor.load(); await App.init(); await appdata.readData(); await Translation.init(); handleLinks(); - SystemTheme.onChange.listen((event) { - StateController.findOrNull(tag: "MyApp")?.update(); - }); if (App.isDesktop) { await WindowManager.instance.ensureInitialized(); windowManager.waitUntilReadyToShow().then((_) async { @@ -54,11 +50,12 @@ class MyApp extends StatelessWidget { init: SimpleController(), tag: "MyApp", builder: (controller) { - Brightness brightness = PlatformDispatcher.instance.platformBrightness; + Brightness brightness = + PlatformDispatcher.instance.platformBrightness; - if(appdata.settings["theme"] == "Dark") { + if (appdata.settings["theme"] == "Dark") { brightness = Brightness.dark; - } else if(appdata.settings["theme"] == "Light") { + } else if (appdata.settings["theme"] == "Light") { brightness = Brightness.light; } @@ -69,54 +66,77 @@ class MyApp extends StatelessWidget { statusBarIconBrightness: brightness.opposite, systemNavigationBarIconBrightness: brightness.opposite, ), - child: FluentApp( - navigatorKey: App.rootNavigatorKey, - debugShowCheckedModeBanner: false, - title: 'pixes', - theme: FluentThemeData( - brightness: brightness, - fontFamily: App.isWindows ? 'font' : null, - accentColor: AccentColor.swatch({ - 'darkest': SystemTheme.accentColor.darkest, - 'darker': SystemTheme.accentColor.darker, - 'dark': SystemTheme.accentColor.dark, - 'normal': SystemTheme.accentColor.accent, - 'light': SystemTheme.accentColor.light, - 'lighter': SystemTheme.accentColor.lighter, - 'lightest': SystemTheme.accentColor.lightest, - })), - home: const MainPage(), - builder: (context, child) { - ErrorWidget.builder = (details) { - if (details.exception - .toString() - .contains("RenderFlex overflowed")) { - return const SizedBox.shrink(); - } - Log.error("UI", "${details.exception}\n${details.stack}"); - return Text(details.exception.toString()); - }; - if (child == null) { - throw "widget is null"; - } - - return MdTheme( - data: MdThemeData.from( - colorScheme: MdColorScheme.fromSeed( - seedColor: FluentTheme.of(context).accentColor, - brightness: FluentTheme.of(context).brightness, - ), - useMaterial3: true - ), - child: DefaultTextStyle.merge( - style: TextStyle( + child: DynamicColorBuilder( + builder: (light, dark) { + final colorScheme = + (brightness == Brightness.light ? light : dark) ?? + md.ColorScheme.fromSeed( + seedColor: Colors.blue, brightness: brightness); + return FluentApp( + navigatorKey: App.rootNavigatorKey, + debugShowCheckedModeBanner: false, + title: 'pixes', + theme: FluentThemeData( + brightness: brightness, fontFamily: App.isWindows ? 'font' : null, - ), - child: OverlayWidget(child), - ), - ); - }), + accentColor: AccentColor.swatch({ + 'darkest': darken(colorScheme.primary, 30), + 'darker': darken(colorScheme.primary, 20), + 'dark': darken(colorScheme.primary, 10), + 'normal': colorScheme.primary, + 'light': lighten(colorScheme.primary, 10), + 'lighter': lighten(colorScheme.primary, 20), + 'lightest': lighten(colorScheme.primary, 30) + })), + home: const MainPage(), + builder: (context, child) { + ErrorWidget.builder = (details) { + if (details.exception + .toString() + .contains("RenderFlex overflowed")) { + return const SizedBox.shrink(); + } + Log.error( + "UI", "${details.exception}\n${details.stack}"); + return Text(details.exception.toString()); + }; + if (child == null) { + throw "widget is null"; + } + + return MdTheme( + data: MdThemeData.from( + colorScheme: colorScheme, useMaterial3: true), + child: DefaultTextStyle.merge( + style: TextStyle( + fontFamily: App.isWindows ? 'font' : null, + ), + child: OverlayWidget(child), + ), + ); + }); + }, + ), ); }); } } + +/// from https://stackoverflow.com/a/60191441 +Color darken(Color c, [int percent = 10]) { + assert(1 <= percent && percent <= 100); + var f = 1 - percent / 100; + return Color.fromARGB(c.alpha, (c.red * f).round(), (c.green * f).round(), + (c.blue * f).round()); +} + +/// from https://stackoverflow.com/a/60191441 +Color lighten(Color c, [int percent = 10]) { + assert(1 <= percent && percent <= 100); + var p = percent / 100; + return Color.fromARGB( + c.alpha, + c.red + ((255 - c.red) * p).round(), + c.green + ((255 - c.green) * p).round(), + c.blue + ((255 - c.blue) * p).round()); +} diff --git a/lib/network/models.dart b/lib/network/models.dart index e5222ac..89db466 100644 --- a/lib/network/models.dart +++ b/lib/network/models.dart @@ -121,14 +121,14 @@ class UserDetails { pawooUrl = json['profile']['pawoo_url']; } -class IllustAuthor { +class Author { final int id; final String name; final String account; final String avatar; bool isFollowed; - IllustAuthor(this.id, this.name, this.account, this.avatar, this.isFollowed); + Author(this.id, this.name, this.account, this.avatar, this.isFollowed); } class Tag { @@ -170,7 +170,7 @@ class Illust { final List images; final String caption; final int restrict; - final IllustAuthor author; + final Author author; final List tags; final DateTime createDate; final int pageCount; @@ -210,7 +210,7 @@ class Illust { }()), caption = json['caption'], restrict = json['restrict'], - author = IllustAuthor( + author = Author( json['user']['id'], json['user']['name'], json['user']['account'], @@ -380,7 +380,8 @@ class UserPreview { avatar = json['user']['profile_image_urls']['medium'], isFollowed = json['user']['is_followed'], isBlocking = json['user']['is_access_blocking_user'] ?? false, - artworks = (json['illusts'] as List).map((e) => Illust.fromJson(e)).toList(); + artworks = + (json['illusts'] as List).map((e) => Illust.fromJson(e)).toList(); } /* @@ -420,6 +421,107 @@ class Comment { uid = json['user']['id'].toString(), name = json['user']['name'], avatar = json['user']['profile_image_urls']['medium'], - hasReplies = json['has_replies'], + hasReplies = json['has_replies'] ?? false, stampUrl = json['stamp']?['stamp_url']; } + +/* +{ + "id": 20741342, + "title": "中身が一般人のやつがれくん", + "caption": "なんか思いついたので書いてみた。
よくある芥川成り代わり。
3年くらい前の書きかけのやつをサルベージ。
じっくりは書いてないので抜け抜け。

デイリー1位ありがとうございます✨

※※※※※※※※
※※※※※※※※

以下読了後推奨の蛇足

「芥川くん」
「なんですかボス」
「君は将来的にどんな地位につきたいとかある?」
「僕はしがない一構成員ゆえ」
「ほら幹部とか隊長とか人事部とかさ。君あれこれオールマイティにできるから希望を聞いておこうと思って」
「ございます」
「なにかな?」
「僕は将来的にポートマフィア直営のいちじく農家になりたいと思います」
「なんて?」
「さらに、ゆくゆくはいちじく農家兼、いちじくの素晴らしさを世に知らしめるポートマフィア直営いちじくレストランを開きたいと」
「なんて???」", + "restrict": 0, + "x_restrict": 0, + "is_original": false, + "image_urls": { + "square_medium": "https://i.pximg.net/c/128x128/novel-cover-master/img/2023/09/27/16/14/45/ci20741342_db401e9b27afbf96f772d30759e1d104_square1200.jpg", + "medium": "https://i.pximg.net/c/176x352/novel-cover-master/img/2023/09/27/16/14/45/ci20741342_db401e9b27afbf96f772d30759e1d104_master1200.jpg", + "large": "https://i.pximg.net/c/240x480_80/novel-cover-master/img/2023/09/27/16/14/45/ci20741342_db401e9b27afbf96f772d30759e1d104_master1200.jpg" + }, + "create_date": "2023-09-27T16:14:45+09:00", + "tags": [ + { + "name": "文スト夢", + "translated_name": "Bungo Stray Dogs original/self-insert", + "added_by_uploaded_user": true + }, + { + "name": "成り代わり", + "translated_name": "取代即有角色", + "added_by_uploaded_user": true + }, + ], + "page_count": 6, + "text_length": 12550, + "user": { + "id": 9275134, + "name": "もろろ", + "account": "sleepinglife", + "profile_image_urls": { + "medium": "https://s.pximg.net/common/images/no_profile.png" + }, + "is_followed": false + }, + "series": { + "id": 11897059, + "title": "文スト夢" + }, + "is_bookmarked": false, + "total_bookmarks": 8099, + "total_view": 76112, + "visible": true, + "total_comments": 146, + "is_muted": false, + "is_mypixiv_only": false, + "is_x_restricted": false, + "novel_ai_type": 1 + } +*/ +class Novel { + final int id; + final String title; + final String caption; + final bool isOriginal; + final String image; + final DateTime createDate; + final List tags; + final int pages; + final int length; + final Author author; + final int? seriesId; + final String? seriesTitle; + bool isBookmarked; + final int totalBookmarks; + final int totalViews; + final int commentsCount; + final bool isAi; + + Novel.fromJson(Map json) + : id = json["id"], + title = json["title"], + caption = json["caption"], + isOriginal = json["is_original"], + image = json["image_urls"]["large"] ?? + json["image_urls"]["medium"] ?? + json["image_urls"]["square_medium"] ?? + "", + createDate = DateTime.parse(json["create_date"]), + tags = (json['tags'] as List) + .map((e) => Tag(e['name'], e['translated_name'])) + .toList(), + pages = json["page_count"], + length = json["text_length"], + author = Author( + json['user']['id'], + json['user']['name'], + json['user']['account'], + json['user']['profile_image_urls']['medium'], + json['user']['is_followed'] ?? false), + seriesId = json["series"]?["id"], + seriesTitle = json["series"]?["title"], + isBookmarked = json["is_bookmarked"], + totalBookmarks = json["total_bookmarks"], + totalViews = json["total_view"], + commentsCount = json["total_comments"], + isAi = json["novel_ai_type"] == 2; +} diff --git a/lib/network/network.dart b/lib/network/network.dart index 74ed64f..6fbedd0 100644 --- a/lib/network/network.dart +++ b/lib/network/network.dart @@ -14,6 +14,8 @@ import 'models.dart'; export 'models.dart'; export 'res.dart'; +part 'novel.dart'; + class Network { static const hashSalt = "28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c"; @@ -159,6 +161,38 @@ class Network { } } + Future> apiGetPlain(String path, + {Map? query}) async { + try { + if (!path.startsWith("http")) { + path = "$baseUrl$path"; + } + final res = await dio.get(path, + queryParameters: query, + options: + Options(headers: headers, validateStatus: (status) => true)); + if (res.statusCode == 200) { + return Res(res.data!); + } else if (res.statusCode == 400) { + if (res.data.toString().contains("Access Token")) { + var refresh = await refreshToken(); + if (refresh.success) { + return apiGetPlain(path, query: query); + } else { + return Res.error(refresh.errorMessage); + } + } else { + return Res.error("Invalid Status Code: ${res.statusCode}"); + } + } else { + return Res.error("Invalid Status Code: ${res.statusCode}"); + } + } catch (e, s) { + Log.error("Network", "$e\n$s"); + return Res.error(e); + } + } + Future>> apiPost(String path, {Map? query, Map? data}) async { try { diff --git a/lib/network/novel.dart b/lib/network/novel.dart new file mode 100644 index 0000000..4c987dc --- /dev/null +++ b/lib/network/novel.dart @@ -0,0 +1,152 @@ +part of "network.dart"; + +extension NovelExt on Network { + Future>> getRecommendNovels() { + return getNovelsWithNextUrl("/v1/novel/recommended"); + } + + Future>> getNovelsWithNextUrl(String nextUrl) async { + var res = await apiGet(nextUrl); + if (res.error) { + return Res.fromErrorRes(res); + } + return Res( + (res.data["novels"] as List).map((e) => Novel.fromJson(e)).toList(), + subData: res.data["next_url"]); + } + + Future>> searchNovels(String keyword, SearchOptions options) { + var url = "/v1/search/novel?" + "include_translated_tag_results=true&" + "merge_plain_keyword_results=true&" + "word=${Uri.encodeComponent(keyword)}&" + "sort=${options.sort.toParam()}&" + "search_target=${options.matchType.toParam()}&" + "search_ai_type=0"; + return getNovelsWithNextUrl(url); + } + + /// mode: day, day_male, day_female, week_rookie, week, week_ai + Future>> getNovelRanking(String mode, DateTime? date) { + var url = "/v1/novel/ranking?mode=$mode"; + if (date != null) { + url += "&date=${date.year}-${date.month}-${date.day}"; + } + return getNovelsWithNextUrl(url); + } + + Future>> getBookmarkedNovels(String uid) { + return getNovelsWithNextUrl( + "/v1/user/bookmarks/novel?user_id=$uid&restrict=public"); + } + + Future> favoriteNovel(String id) async { + var res = await apiPost("/v2/novel/bookmark/add", data: { + "novel_id": id, + "restrict": "public", + }); + if (res.error) { + return Res.fromErrorRes(res); + } + return const Res(true); + } + + Future> deleteFavoriteNovel(String id) async { + var res = await apiPost("/v1/novel/bookmark/delete", data: { + "novel_id": id, + }); + if (res.error) { + return Res.fromErrorRes(res); + } + return const Res(true); + } + + Future> getNovelContent(String id) async { + var res = await apiGetPlain( + "/webview/v2/novel?id=$id&font=default&font_size=16.0px&line_height=1.75&color=%23101010&background_color=%23EFEFEF&margin_top=56px&margin_bottom=48px&theme=light&use_block=true&viewer_version=20221031_ai"); + if (res.error) { + return Res.fromErrorRes(res); + } + try { + var html = res.data; + int start = html.indexOf("novel:"); + while (html[start] != '{') { + start++; + } + int leftCount = 0; + int end = start; + for (end = start; end < html.length; end++) { + if (html[end] == '{') { + leftCount++; + } else if (html[end] == '}') { + leftCount--; + } + if (leftCount == 0) { + end++; + break; + } + } + var json = jsonDecode(html.substring(start, end)); + return Res(json['text']); + } catch (e, s) { + Log.error( + "Data Convert", "Failed to analyze html novel content: \n$e\n$s"); + return Res.error(e); + } + } + + Future>> relatedNovels(String id) async { + var res = await apiPost("/v1/novel/related", data: { + "novel_id": id, + }); + if (res.error) { + return Res.fromErrorRes(res); + } + return Res( + (res.data["novels"] as List).map((e) => Novel.fromJson(e)).toList()); + } + + Future>> getUserNovels(String uid) { + return getNovelsWithNextUrl("/v1/user/novels?user_id=$uid"); + } + + Future>> getNovelSeries(String id, [String? nextUrl]) async { + var res = await apiGet(nextUrl ?? "/v2/novel/series?series_id=$id"); + if (res.error) { + return Res.fromErrorRes(res); + } + return Res( + (res.data["novels"] as List).map((e) => Novel.fromJson(e)).toList(), + subData: res.data["next_url"]); + } + + Future>> getNovelComments(String id, + [String? nextUrl]) async { + var res = await apiGet(nextUrl ?? "/v1/novel/comments?novel_id=$id"); + if (res.error) { + return Res.fromErrorRes(res); + } + return Res( + (res.data["comments"] as List).map((e) => Comment.fromJson(e)).toList(), + subData: res.data["next_url"]); + } + + Future> commentNovel(String id, String content) async { + var res = await apiPost("/v1/novel/comment/add", data: { + "novel_id": id, + "content": content, + }); + if (res.error) { + return Res.fromErrorRes(res); + } + return const Res(true); + } + + Future> getNovelDetail(String id) async { + var res = await apiGet("/v2/novel/detail?novel_id=$id"); + if (res.error) { + return Res.fromErrorRes(res); + } + return Res(Novel.fromJson(res.data["novel"])); + } +} diff --git a/lib/pages/comments_page.dart b/lib/pages/comments_page.dart new file mode 100644 index 0000000..5a95b5e --- /dev/null +++ b/lib/pages/comments_page.dart @@ -0,0 +1,210 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:pixes/components/animated_image.dart'; +import 'package:pixes/components/loading.dart'; +import 'package:pixes/components/page_route.dart'; +import 'package:pixes/foundation/app.dart'; +import 'package:pixes/foundation/image_provider.dart'; +import 'package:pixes/network/network.dart'; +import 'package:pixes/pages/user_info_page.dart'; +import 'package:pixes/utils/translation.dart'; + +import '../components/md.dart'; +import '../components/message.dart'; + +class CommentsPage extends StatefulWidget { + const CommentsPage(this.id, {this.isNovel = false, super.key}); + + final String id; + + final bool isNovel; + + static void show(BuildContext context, String id, {bool isNovel = false}) { + Navigator.of(context) + .push(SideBarRoute(CommentsPage(id, isNovel: isNovel))); + } + + @override + State createState() => _CommentsPageState(); +} + +class _CommentsPageState extends MultiPageLoadingState { + bool isCommenting = false; + + @override + Widget buildContent(BuildContext context, List data) { + return Stack( + children: [ + Positioned.fill(child: buildBody(context, data)), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: buildBottom(context), + ) + ], + ); + } + + Widget buildBody(BuildContext context, List data) { + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: data.length + 2, + itemBuilder: (context, index) { + if (index == 0) { + return Text("Comments".tl, style: const TextStyle(fontSize: 20)) + .paddingVertical(16) + .paddingHorizontal(12); + } else if (index == data.length + 1) { + return const SizedBox( + height: 64, + ); + } + index--; + var date = data[index].date; + var dateText = "${date.year}/${date.month}/${date.day}"; + return Card( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SizedBox( + height: 38, + width: 38, + child: ClipRRect( + borderRadius: BorderRadius.circular(38), + child: ColoredBox( + color: ColorScheme.of(context).secondaryContainer, + child: GestureDetector( + onTap: () => context.to( + () => UserInfoPage(data[index].id.toString())), + child: AnimatedImage( + image: CachedImageProvider(data[index].avatar), + width: 38, + height: 38, + fit: BoxFit.cover, + filterQuality: FilterQuality.medium, + ), + ), + ), + ), + ), + const SizedBox( + width: 8, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data[index].name, + style: const TextStyle(fontSize: 14), + ), + Text( + dateText, + style: TextStyle( + fontSize: 12, + color: ColorScheme.of(context).outline), + ) + ], + ) + ], + ), + const SizedBox( + height: 8, + ), + if (data[index].comment.isNotEmpty) + Text( + data[index].comment, + style: const TextStyle(fontSize: 16), + ), + if (data[index].stampUrl != null) + SizedBox( + height: 64, + width: 64, + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: AnimatedImage( + image: CachedImageProvider(data[index].stampUrl!), + width: 64, + height: 64, + fit: BoxFit.cover, + ), + ), + ) + ], + ), + ); + }); + } + + Widget buildBottom(BuildContext context) { + return Card( + padding: EdgeInsets.zero, + backgroundColor: + FluentTheme.of(context).micaBackgroundColor.withOpacity(0.96), + child: SizedBox( + height: 52, + child: TextBox( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + placeholder: "Comment".tl, + foregroundDecoration: BoxDecoration( + border: Border.all(color: Colors.transparent), + ), + onSubmitted: (s) { + showToast(context, message: "Sending".tl); + if (isCommenting) return; + setState(() { + isCommenting = true; + }); + if (widget.isNovel) { + Network().commentNovel(widget.id, s).then((value) { + if (value.error) { + context.showToast(message: "Network Error"); + setState(() { + isCommenting = false; + }); + } else { + isCommenting = false; + nextUrl = null; + reset(); + } + }); + } else { + Network().comment(widget.id, s).then((value) { + if (value.error) { + context.showToast(message: "Network Error"); + setState(() { + isCommenting = false; + }); + } else { + isCommenting = false; + nextUrl = null; + reset(); + } + }); + } + }, + ).paddingVertical(8).paddingHorizontal(12), + ).paddingBottom(context.padding.bottom + context.viewInsets.bottom), + ); + } + + String? nextUrl; + + @override + Future>> loadData(int page) async { + if (nextUrl == "end") { + return Res.error("No more data"); + } + var res = widget.isNovel + ? await Network().getNovelComments(widget.id, nextUrl) + : await Network().getComments(widget.id, nextUrl); + if (!res.error) { + nextUrl = res.subData; + nextUrl ??= "end"; + } + return res; + } +} diff --git a/lib/pages/illust_page.dart b/lib/pages/illust_page.dart index 5c7296b..22f1549 100644 --- a/lib/pages/illust_page.dart +++ b/lib/pages/illust_page.dart @@ -14,6 +14,7 @@ import 'package:pixes/foundation/app.dart'; import 'package:pixes/foundation/image_provider.dart'; import 'package:pixes/network/download.dart'; import 'package:pixes/network/network.dart'; +import 'package:pixes/pages/comments_page.dart'; import 'package:pixes/pages/image_page.dart'; import 'package:pixes/pages/search_page.dart'; import 'package:pixes/pages/user_info_page.dart'; @@ -672,7 +673,7 @@ class _BottomBarState extends State<_BottomBar> with TickerProviderStateMixin{ yield const SizedBox(width: 8,); yield Button( - onPressed: () => _CommentsPage.show(context, widget.illust.id.toString()), + onPressed: () => CommentsPage.show(context, widget.illust.id.toString()), child: SizedBox( height: 28, child: Row( @@ -866,165 +867,6 @@ class _BottomBarState extends State<_BottomBar> with TickerProviderStateMixin{ } } -class _CommentsPage extends StatefulWidget { - const _CommentsPage(this.id); - - final String id; - - static void show(BuildContext context, String id) { - Navigator.of(context).push(SideBarRoute(_CommentsPage(id))); - } - - @override - State<_CommentsPage> createState() => _CommentsPageState(); -} - -class _CommentsPageState extends MultiPageLoadingState<_CommentsPage, Comment> { - bool isCommenting = false; - - @override - Widget buildContent(BuildContext context, List data) { - return Stack( - children: [ - Positioned.fill(child: buildBody(context, data)), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: buildBottom(context), - ) - ], - ); - } - - Widget buildBody(BuildContext context, List data) { - return ListView.builder( - padding: EdgeInsets.zero, - itemCount: data.length + 2, - itemBuilder: (context, index) { - if(index == 0) { - return Text("Comments".tl, style: const TextStyle(fontSize: 20)).paddingVertical(16).paddingHorizontal(12); - } else if(index == data.length + 1) { - return const SizedBox(height: 64,); - } - index--; - var date = data[index].date; - var dateText = "${date.year}/${date.month}/${date.day}"; - return Card( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), - margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - SizedBox( - height: 38, - width: 38, - child: ClipRRect( - borderRadius: BorderRadius.circular(38), - child: ColoredBox( - color: ColorScheme.of(context).secondaryContainer, - child: GestureDetector( - onTap: () => context.to(() => UserInfoPage(data[index].id.toString())), - child: AnimatedImage( - image: CachedImageProvider(data[index].avatar), - width: 38, - height: 38, - fit: BoxFit.cover, - filterQuality: FilterQuality.medium, - ), - ), - ), - ), - ), - const SizedBox(width: 8,), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(data[index].name, style: const TextStyle(fontSize: 14),), - Text(dateText, style: TextStyle(fontSize: 12, color: ColorScheme.of(context).outline),) - ], - ) - ], - ), - const SizedBox(height: 8,), - if(data[index].comment.isNotEmpty) - Text(data[index].comment, style: const TextStyle(fontSize: 16),), - if(data[index].stampUrl != null) - SizedBox( - height: 64, - width: 64, - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: AnimatedImage( - image: CachedImageProvider(data[index].stampUrl!), - width: 64, - height: 64, - fit: BoxFit.cover, - ), - ), - ) - ], - ), - ); - } - ); - } - - Widget buildBottom(BuildContext context) { - return Card( - padding: EdgeInsets.zero, - backgroundColor: FluentTheme.of(context).micaBackgroundColor.withOpacity(0.96), - child: SizedBox( - height: 52, - child: TextBox( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - placeholder: "Comment".tl, - foregroundDecoration: BoxDecoration( - border: Border.all(color: Colors.transparent), - ), - onSubmitted: (s) { - showToast(context, message: "Sending".tl); - if(isCommenting) return; - setState(() { - isCommenting = true; - }); - Network().comment(widget.id, s).then((value) { - if(value.error) { - context.showToast(message: "Network Error"); - setState(() { - isCommenting = false; - }); - } else { - isCommenting = false; - nextUrl = null; - reset(); - } - }); - }, - ).paddingVertical(8).paddingHorizontal(12), - ).paddingBottom(context.padding.bottom + context.viewInsets.bottom), - ); - } - - String? nextUrl; - - @override - Future>> loadData(int page) async{ - if(nextUrl == "end") { - return Res.error("No more data"); - } - var res = await Network().getComments(widget.id, nextUrl); - if(!res.error) { - nextUrl = res.subData; - nextUrl ??= "end"; - } - return res; - } - -} - class IllustPageWithId extends StatefulWidget { const IllustPageWithId(this.id, {super.key}); diff --git a/lib/pages/main_page.dart b/lib/pages/main_page.dart index ff8864f..7e1530a 100644 --- a/lib/pages/main_page.dart +++ b/lib/pages/main_page.dart @@ -11,6 +11,9 @@ import "package:pixes/pages/bookmarks.dart"; import "package:pixes/pages/downloaded_page.dart"; import "package:pixes/pages/following_artworks.dart"; import "package:pixes/pages/history.dart"; +import "package:pixes/pages/novel_bookmarks_page.dart"; +import "package:pixes/pages/novel_ranking_page.dart"; +import "package:pixes/pages/novel_recommendation_page.dart"; import "package:pixes/pages/ranking.dart"; import "package:pixes/pages/recommendation_page.dart"; import "package:pixes/pages/login_page.dart"; @@ -45,6 +48,7 @@ class _MainPageState extends State with WindowListener { void initState() { windowManager.addListener(this); listenMouseSideButtonToBack(navigatorKey); + App.mainNavigatorKey = navigatorKey; super.initState(); } @@ -93,24 +97,37 @@ class _MainPageState extends State with WindowListener { items: [ UserPane(), PaneItem( - icon: const Icon(MdIcons.search, size: 20,), + icon: const Icon( + MdIcons.search, + size: 20, + ), title: Text('Search'.tl), body: const SizedBox.shrink(), ), PaneItem( - icon: const Icon(MdIcons.downloading, size: 20,), + icon: const Icon( + MdIcons.downloading, + size: 20, + ), title: Text('Downloading'.tl), body: const SizedBox.shrink(), ), PaneItem( - icon: const Icon(MdIcons.download, size: 20,), + icon: const Icon( + MdIcons.download, + size: 20, + ), title: Text('Downloaded'.tl), body: const SizedBox.shrink(), ), PaneItemSeparator(), - PaneItemHeader(header: Text("Artwork".tl).paddingBottom(4).paddingLeft(8)), + PaneItemHeader( + header: Text("Artwork".tl).paddingBottom(4).paddingLeft(8)), PaneItem( - icon: const Icon(MdIcons.explore_outlined, size: 20,), + icon: const Icon( + MdIcons.explore_outlined, + size: 20, + ), title: Text('Explore'.tl), body: const SizedBox.shrink(), ), @@ -134,8 +151,26 @@ class _MainPageState extends State with WindowListener { title: Text('Ranking'.tl), body: const SizedBox.shrink(), ), - ], - footerItems: [ + PaneItemSeparator(), + PaneItemHeader( + header: Text("Novel".tl).paddingBottom(4).paddingLeft(8)), + PaneItem( + icon: const Icon(MdIcons.featured_play_list_outlined, size: 20), + title: Text('Recommendation'.tl), + body: const SizedBox.shrink(), + ), + PaneItem( + icon: + const Icon(MdIcons.collections_bookmark_outlined, size: 20), + title: Text('Bookmarks'.tl), + body: const SizedBox.shrink(), + ), + PaneItem( + icon: const Icon(MdIcons.leaderboard_outlined, size: 20), + title: Text('Ranking'.tl), + body: const SizedBox.shrink(), + ), + PaneItemSeparator(), PaneItem( icon: const Icon(MdIcons.settings_outlined, size: 20), title: Text('Settings'.tl), @@ -168,6 +203,9 @@ class _MainPageState extends State with WindowListener { () => const FollowingArtworksPage(), () => const HistoryPage(), () => const RankingPage(), + () => const NovelRecommendationPage(), + () => const NovelBookmarksPage(), + () => const NovelRankingPage(), () => const SettingsPage(), ]; @@ -204,7 +242,7 @@ class _MainPageState extends State with WindowListener { style: TextStyle(fontSize: 13), ), Spacer(), - if(kDebugMode) + if (kDebugMode) Padding( padding: EdgeInsets.only(right: 138), child: Button(onPressed: debug, child: Text("Debug")), @@ -216,9 +254,11 @@ class _MainPageState extends State with WindowListener { ); }(), leading: _BackButton(navigatorKey), - actions: App.isDesktop ? WindowButtons( - key: ValueKey(windowButtonKey), - ) : null, + actions: App.isDesktop + ? WindowButtons( + key: ValueKey(windowButtonKey), + ) + : null, ); } } @@ -248,11 +288,11 @@ class _BackButtonState extends State<_BackButton> { void loop() { timer = Timer.periodic(const Duration(milliseconds: 100), (timer) { - if(!mounted) { + if (!mounted) { timer.cancel(); } else { bool enabled = navigatorKey.currentState?.canPop() == true; - if(enabled != this.enabled) { + if (enabled != this.enabled) { setState(() { this.enabled = enabled; }); @@ -293,18 +333,19 @@ class _BackButtonState extends State<_BackButton> { title: const Text("Back"), body: const SizedBox.shrink(), enabled: enabled, - ).build( - context, - false, - onPressed, - displayMode: PaneDisplayMode.compact, - ).paddingTop(2), + ) + .build( + context, + false, + onPressed, + displayMode: PaneDisplayMode.compact, + ) + .paddingTop(2), ), ); } } - class WindowButtons extends StatelessWidget { const WindowButtons({super.key}); @@ -458,7 +499,8 @@ class UserPane extends PaneItem { child: Image( height: 48, width: 48, - image: CachedImageProvider(appdata.account!.user.profile), + image: + CachedImageProvider(appdata.account!.user.profile), fit: BoxFit.fill, ), ), @@ -481,7 +523,9 @@ class UserPane extends PaneItem { fontSize: 16, fontWeight: FontWeight.w500), ), Text( - kDebugMode ? "" : appdata.account!.user.email, + kDebugMode + ? "" + : appdata.account!.user.email, style: const TextStyle(fontSize: 12), ) ], diff --git a/lib/pages/novel_bookmarks_page.dart b/lib/pages/novel_bookmarks_page.dart new file mode 100644 index 0000000..6ad9772 --- /dev/null +++ b/lib/pages/novel_bookmarks_page.dart @@ -0,0 +1,53 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:pixes/appdata.dart'; +import 'package:pixes/components/grid.dart'; +import 'package:pixes/components/loading.dart'; +import 'package:pixes/components/novel.dart'; +import 'package:pixes/components/title_bar.dart'; +import 'package:pixes/foundation/widget_utils.dart'; +import 'package:pixes/network/network.dart'; +import 'package:pixes/utils/translation.dart'; + +class NovelBookmarksPage extends StatefulWidget { + const NovelBookmarksPage({super.key}); + + @override + State createState() => _NovelBookmarksPageState(); +} + +class _NovelBookmarksPageState + extends MultiPageLoadingState { + @override + Widget buildContent(BuildContext context, List data) { + return Column( + children: [ + TitleBar(title: "Bookmarks".tl), + Expanded( + child: GridViewWithFixedItemHeight( + itemCount: data.length, + itemHeight: 164, + minCrossAxisExtent: 400, + builder: (context, index) { + if (index == data.length - 1) { + nextPage(); + } + return NovelWidget(data[index]); + }, + ).paddingHorizontal(8), + ) + ], + ); + } + + String? nextUrl; + + @override + Future>> loadData(int page) async { + if (nextUrl == "end") return Res.error("No more data"); + var res = nextUrl == null + ? await Network().getBookmarkedNovels(appdata.account!.user.id) + : await Network().getNovelsWithNextUrl(nextUrl!); + nextUrl = res.subData ?? "end"; + return res; + } +} diff --git a/lib/pages/novel_page.dart b/lib/pages/novel_page.dart new file mode 100644 index 0000000..21a986b --- /dev/null +++ b/lib/pages/novel_page.dart @@ -0,0 +1,669 @@ +import 'dart:collection'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter/gestures.dart'; +import 'package:pixes/components/animated_image.dart'; +import 'package:pixes/components/grid.dart'; +import 'package:pixes/components/loading.dart'; +import 'package:pixes/components/md.dart'; +import 'package:pixes/components/novel.dart'; +import 'package:pixes/components/title_bar.dart'; +import 'package:pixes/foundation/app.dart'; +import 'package:pixes/foundation/image_provider.dart'; +import 'package:pixes/network/network.dart'; +import 'package:pixes/pages/comments_page.dart'; +import 'package:pixes/pages/novel_reading_page.dart'; +import 'package:pixes/pages/search_page.dart'; +import 'package:pixes/pages/user_info_page.dart'; +import 'package:pixes/utils/app_links.dart'; +import 'package:pixes/utils/translation.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +const kFluentButtonPadding = 28.0; + +class NovelPage extends StatefulWidget { + const NovelPage(this.novel, {super.key}); + + final Novel novel; + + @override + State createState() => _NovelPageState(); +} + +class _NovelPageState extends State { + final scrollController = ScrollController(); + + @override + Widget build(BuildContext context) { + return Scrollbar( + controller: scrollController, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverToBoxAdapter( + child: buildTop(), + ), + SliverToBoxAdapter( + child: buildActions(), + ), + SliverToBoxAdapter( + child: buildDescription(), + ), + if (widget.novel.seriesId != null) + NovelSeriesWidget( + widget.novel.seriesId!, widget.novel.seriesTitle!) + ], + ), + ).padding(const EdgeInsets.symmetric(horizontal: 16))); + } + + Widget buildTop() { + return Card( + child: SizedBox( + height: 128, + child: Row( + children: [ + Container( + width: 96, + height: double.infinity, + decoration: BoxDecoration( + color: ColorScheme.of(context).secondaryContainer, + borderRadius: BorderRadius.circular(4), + ), + clipBehavior: Clip.antiAlias, + child: AnimatedImage( + fit: BoxFit.cover, + filterQuality: FilterQuality.medium, + width: double.infinity, + height: double.infinity, + image: CachedImageProvider(widget.novel.image)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text(widget.novel.title, + maxLines: 3, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + )), + const SizedBox(height: 4), + const Spacer(), + if (widget.novel.seriesId != null) + Text( + overflow: TextOverflow.ellipsis, + "${"Series".tl}: ${widget.novel.seriesTitle!}", + style: TextStyle( + color: ColorScheme.of(context).primary, + fontSize: 12, + ), + ).paddingVertical(4) + ], + ), + ), + ], + ), + )).paddingTop(12); + } + + Widget buildStats() { + return Container( + height: 74, + constraints: const BoxConstraints(maxWidth: 560), + padding: const EdgeInsets.only(bottom: 10), + child: Row( + children: [ + const SizedBox( + width: 2, + ), + Expanded( + child: Container( + height: 68, + decoration: BoxDecoration( + border: Border.all( + color: ColorScheme.of(context).outlineVariant, + width: 0.6), + borderRadius: BorderRadius.circular(4)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + FluentIcons.view, + size: 20, + ), + Text( + "Views".tl, + style: const TextStyle(fontSize: 12), + ) + ], + ), + const SizedBox( + width: 12, + ), + Text( + widget.novel.totalViews.toString(), + style: TextStyle( + color: ColorScheme.of(context).primary, + fontWeight: FontWeight.w500, + fontSize: 18), + ) + ], + ), + ), + ), + const SizedBox( + width: 16, + ), + Expanded( + child: Container( + height: 68, + decoration: BoxDecoration( + border: Border.all( + color: ColorScheme.of(context).outlineVariant, width: 0.6), + borderRadius: BorderRadius.circular(4)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + FluentIcons.six_point_star, + size: 20, + ), + Text( + "Favorites".tl, + style: const TextStyle(fontSize: 12), + ) + ], + ), + const SizedBox( + width: 12, + ), + Text( + widget.novel.totalBookmarks.toString(), + style: TextStyle( + color: ColorScheme.of(context).primary, + fontWeight: FontWeight.w500, + fontSize: 18), + ) + ], + ), + )), + const SizedBox( + width: 2, + ), + ], + ), + ); + } + + Widget buildAuthor() { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Card( + margin: const EdgeInsets.only(left: 2, right: 2, bottom: 12), + borderColor: ColorScheme.of(context).outlineVariant.withOpacity(0.52), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + context.to(() => UserInfoPage(widget.novel.author.id.toString())); + }, + child: SizedBox( + height: 38, + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: ColorScheme.of(context).secondaryContainer, + borderRadius: BorderRadius.circular(36), + ), + clipBehavior: Clip.antiAlias, + child: AnimatedImage( + fit: BoxFit.cover, + width: 36, + height: 36, + filterQuality: FilterQuality.medium, + image: CachedImageProvider(widget.novel.author.avatar), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.novel.author.name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + )), + Text( + widget.novel.createDate.toString().substring(0, 10), + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + ], + ), + const Spacer(), + const Icon(MdIcons.chevron_right) + ], + ), + ), + ), + ), + ); + } + + bool isAddingFavorite = false; + + Widget buildActions() { + void favorite() async { + if (isAddingFavorite) return; + setState(() { + isAddingFavorite = true; + }); + var res = widget.novel.isBookmarked + ? await Network().deleteFavoriteNovel(widget.novel.id.toString()) + : await Network().favoriteNovel(widget.novel.id.toString()); + if (res.error) { + if (mounted) { + context.showToast(message: res.errorMessage ?? "Network Error"); + } + } else { + widget.novel.isBookmarked = !widget.novel.isBookmarked; + } + setState(() { + isAddingFavorite = false; + }); + } + + return LayoutBuilder(builder: (context, constraints) { + final width = constraints.maxWidth; + return Card( + margin: const EdgeInsets.only(top: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (width < 560) buildAuthor().toAlign(Alignment.centerLeft), + if (width < 560) buildStats().toAlign(Alignment.centerLeft), + if (width >= 560) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1132), + child: Row( + children: [ + Expanded(child: buildAuthor()), + const SizedBox(width: 12), + Expanded(child: buildStats()), + ], + ), + ).toAlign(Alignment.centerLeft), + LayoutBuilder( + builder: (context, constrains) { + var width = constrains.maxWidth; + bool shouldFillSpace = width < 500; + return Row( + children: [ + FilledButton( + child: Row( + children: [ + const Icon(MdIcons.menu_book_outlined, size: 18), + const SizedBox(width: 12), + Text("Read".tl), + const Spacer(), + const Icon(MdIcons.chevron_right, size: 18) + .paddingTop(2), + ], + ) + .fixWidth(shouldFillSpace + ? width / 2 - 4 - kFluentButtonPadding + : 220) + .fixHeight(32), + onPressed: () { + context.to(() => NovelReadingPage(widget.novel)); + }), + const SizedBox(width: 16), + Button( + onPressed: favorite, + child: Row( + children: [ + if (isAddingFavorite) + const SizedBox( + width: 18, + height: 18, + child: ProgressRing( + strokeWidth: 2, + ), + ) + else if (widget.novel.isBookmarked) + Icon( + MdIcons.favorite, + size: 18, + color: ColorScheme.of(context).error, + ) + else + const Icon(MdIcons.favorite_outline, size: 18), + const SizedBox(width: 12), + Text("Favorite".tl) + ], + ) + .fixWidth(shouldFillSpace + ? width / 4 - 4 - kFluentButtonPadding + : 64) + .fixHeight(32), + ), + const SizedBox(width: 8), + Button( + child: Row( + children: [ + const Icon(MdIcons.comment, size: 18), + const SizedBox(width: 12), + Text("Comments".tl) + ], + ) + .fixWidth(shouldFillSpace + ? width / 4 - 4 - kFluentButtonPadding + : 64) + .fixHeight(32), + onPressed: () { + CommentsPage.show(context, widget.novel.id.toString(), + isNovel: true); + }), + ], + ); + }, + ).paddingHorizontal(2), + SelectableText( + "ID: ${widget.novel.id}", + style: TextStyle( + fontSize: 13, color: ColorScheme.of(context).outline), + ).paddingTop(8).paddingLeft(2), + ], + ), + ); + }); + } + + Widget buildDescription() { + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Description".tl, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + SelectableText.rich( + TextSpan(children: buildDescriptionText().toList())), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.start, + children: [ + for (final tag in widget.novel.tags) + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + context.to(() => SearchNovelResultPage(tag.name)); + }, + child: Container( + margin: const EdgeInsets.only(right: 8, bottom: 6), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: ColorScheme.of(context).primaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + tag.name, + style: const TextStyle(fontSize: 12), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + Button( + child: Row( + children: [ + const Icon(MdIcons.bookmark_outline, size: 18), + const SizedBox(width: 12), + Text("Related".tl) + ], + ).fixWidth(64).fixHeight(32), + onPressed: () { + context + .to(() => _RelatedNovelsPage(widget.novel.id.toString())); + }), + ], + ), + ).paddingTop(12); + } + + Iterable buildDescriptionText() sync* { + var text = widget.novel.caption; + text = text.replaceAll("
", "\n"); + text = text.replaceAll('\n\n', '\n'); + var labels = Queue(); + var buffer = StringBuffer(); + var style = const TextStyle(); + String? link; + Map attributes = {}; + for (int i = 0; i < text.length; i++) { + if (text[i] == '<' && text[i + 1] != '/') { + var label = + text.substring(i + 1, text.indexOf('>', i)).split(' ').first; + labels.addLast(label); + for (var part + in text.substring(i + 1, text.indexOf('>', i)).split(' ')) { + var kv = part.split('='); + if (kv.length >= 2) { + attributes[kv[0]] = + kv.join('=').substring(kv[0].length + 2).replaceAll('"', ''); + } + } + i = text.indexOf('>', i); + } else if (text[i] == '<' && text[i + 1] == '/') { + var label = text.substring(i + 2, text.indexOf('>', i)); + if (label == labels.last) { + switch (label) { + case "strong": + style = style.copyWith(fontWeight: FontWeight.bold); + case "a": + style = style.copyWith(color: ColorScheme.of(context).primary); + link = attributes["href"]; + } + labels.removeLast(); + } + i = text.indexOf('>', i); + } else { + buffer.write(text[i]); + } + + if (i + 1 >= text.length || + (labels.isEmpty && + (text[i + 1] == '<' || (i != 0 && text[i - 1] == '>')))) { + var content = buffer.toString(); + var url = link; + yield TextSpan( + text: content, + style: style, + recognizer: url != null + ? (TapGestureRecognizer() + ..onTap = () { + if (!handleLink(Uri.parse(url))) { + launchUrlString(url); + } + }) + : null); + buffer.clear(); + link = null; + attributes.clear(); + style = const TextStyle(); + } + } + } +} + +class NovelSeriesWidget extends StatefulWidget { + const NovelSeriesWidget(this.seriesId, this.title, {super.key}); + + final int seriesId; + + final String title; + + @override + State createState() => _NovelSeriesWidgetState(); +} + +class _NovelSeriesWidgetState + extends MultiPageLoadingState { + @override + Widget? buildFrame(BuildContext context, Widget child) { + return DecoratedSliver( + decoration: BoxDecoration( + color: FluentTheme.of(context).cardColor, + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: ColorScheme.of(context).outlineVariant.withOpacity(0.6), + width: 0.5, + )), + sliver: SliverMainAxisGroup(slivers: [ + SliverToBoxAdapter( + child: Text(widget.title.trim(), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + )).paddingTop(16).paddingLeft(12).paddingRight(12), + ), + const SliverPadding(padding: EdgeInsets.only(top: 8)), + child + ]), + ).sliverPadding(const EdgeInsets.only(top: 16)); + } + + @override + Widget buildLoading(BuildContext context) { + return SliverToBoxAdapter( + child: const Center( + child: ProgressRing(), + ).fixHeight(124), + ); + } + + @override + Widget buildError(BuildContext context, String error) { + return SliverToBoxAdapter( + child: Center( + child: Text(error), + ).fixHeight(124), + ); + } + + @override + Widget buildContent(BuildContext context, final List data) { + return SliverGridViewWithFixedItemHeight( + itemHeight: 164, + minCrossAxisExtent: 400, + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == data.length - 1) { + nextPage(); + } + return NovelWidget(data[index]); + }, + childCount: data.length, + ), + ).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)); + } + + String? nextUrl; + + @override + Future>> loadData(page) async { + if (nextUrl == "end") { + return Res.error("No more data"); + } + var res = + await Network().getNovelSeries(widget.seriesId.toString(), nextUrl); + if (!res.error) { + nextUrl = res.subData; + nextUrl ??= "end"; + } + return res; + } +} + +class NovelPageWithId extends StatefulWidget { + const NovelPageWithId(this.id, {super.key}); + + final String id; + + @override + State createState() => _NovelPageWithIdState(); +} + +class _NovelPageWithIdState extends LoadingState { + @override + Future> loadData() async { + return Network().getNovelDetail(widget.id); + } + + @override + Widget buildContent(BuildContext context, Novel data) { + return NovelPage(data); + } +} + +class _RelatedNovelsPage extends StatefulWidget { + const _RelatedNovelsPage(this.id, {super.key}); + + final String id; + + @override + State<_RelatedNovelsPage> createState() => __RelatedNovelsPageState(); +} + +class __RelatedNovelsPageState + extends LoadingState<_RelatedNovelsPage, List> { + @override + Widget buildContent(BuildContext context, List data) { + return Column( + children: [ + TitleBar(title: "Related Novels".tl), + Expanded( + child: GridViewWithFixedItemHeight( + itemHeight: 164, + itemCount: data.length, + minCrossAxisExtent: 400, + builder: (context, index) { + return NovelWidget(data[index]); + }, + )), + ], + ); + } + + @override + Future>> loadData() async { + return Network().relatedNovels(widget.id); + } +} diff --git a/lib/pages/novel_ranking_page.dart b/lib/pages/novel_ranking_page.dart new file mode 100644 index 0000000..bf69d10 --- /dev/null +++ b/lib/pages/novel_ranking_page.dart @@ -0,0 +1,102 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:pixes/components/loading.dart'; +import 'package:pixes/components/novel.dart'; +import 'package:pixes/components/title_bar.dart'; +import 'package:pixes/foundation/app.dart'; +import 'package:pixes/network/network.dart'; +import 'package:pixes/utils/translation.dart'; + +import '../components/grid.dart'; + +class NovelRankingPage extends StatefulWidget { + const NovelRankingPage({super.key}); + + @override + State createState() => _NovelRankingPageState(); +} + +class _NovelRankingPageState extends State { + String type = "day"; + + /// mode: day, day_male, day_female, week_rookie, week, week_ai + static const types = { + "day": "Daily", + "week": "Weekly", + "day_male": "For male", + "day_female": "For female", + "week_rookie": "Rookies", + }; + + @override + Widget build(BuildContext context) { + return ScaffoldPage( + padding: EdgeInsets.zero, + content: Column( + children: [ + buildHeader(), + Expanded( + child: _OneRankingPage(type, key: Key(type),), + ), + ], + ), + ); + } + + Widget buildHeader() { + return TitleBar( + title: "Ranking".tl, + action: DropDownButton( + title: Text(types[type]!.tl), + items: types.entries.map((e) => MenuFlyoutItem( + text: Text(e.value.tl), + onPressed: () { + setState(() { + type = e.key; + }); + }, + )).toList(), + ), + ); + } +} + +class _OneRankingPage extends StatefulWidget { + const _OneRankingPage(this.type, {super.key}); + + final String type; + + @override + State<_OneRankingPage> createState() => _OneRankingPageState(); +} + +class _OneRankingPageState extends MultiPageLoadingState<_OneRankingPage, Novel> { + @override + Widget buildContent(BuildContext context, final List data) { + return GridViewWithFixedItemHeight( + itemCount: data.length, + itemHeight: 164, + minCrossAxisExtent: 400, + builder: (context, index) { + if (index == data.length - 1) { + nextPage(); + } + return NovelWidget(data[index]); + }, + ).paddingHorizontal(8); + } + + String? nextUrl; + + @override + Future>> loadData(page) async{ + if(nextUrl == "end") { + return Res.error("No more data"); + } + var res = await Network().getNovelRanking(widget.type, null); + if(!res.error) { + nextUrl = res.subData; + nextUrl ??= "end"; + } + return res; + } +} \ No newline at end of file diff --git a/lib/pages/novel_reading_page.dart b/lib/pages/novel_reading_page.dart new file mode 100644 index 0000000..b06e868 --- /dev/null +++ b/lib/pages/novel_reading_page.dart @@ -0,0 +1,49 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:pixes/components/loading.dart'; +import 'package:pixes/network/network.dart'; + +class NovelReadingPage extends StatefulWidget { + const NovelReadingPage(this.novel, {super.key}); + + final Novel novel; + + @override + State createState() => _NovelReadingPageState(); +} + +class _NovelReadingPageState extends LoadingState { + @override + Widget buildContent(BuildContext context, String data) { + return ScaffoldPage( + padding: EdgeInsets.zero, + content: SelectionArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.novel.title, + style: const TextStyle( + fontSize: 24.0, fontWeight: FontWeight.bold)), + const SizedBox(height: 12.0), + const Divider( + style: DividerThemeData(horizontalMargin: EdgeInsets.all(0)), + ), + const SizedBox(height: 12.0), + Text(data, + style: const TextStyle( + fontSize: 16.0, + height: 1.6, + )), + ], + ), + ), + ), + ); + } + + @override + Future> loadData() { + return Network().getNovelContent(widget.novel.id.toString()); + } +} diff --git a/lib/pages/novel_recommendation_page.dart b/lib/pages/novel_recommendation_page.dart new file mode 100644 index 0000000..f5cc67c --- /dev/null +++ b/lib/pages/novel_recommendation_page.dart @@ -0,0 +1,46 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:pixes/components/grid.dart'; +import 'package:pixes/components/loading.dart'; +import 'package:pixes/components/novel.dart'; +import 'package:pixes/components/title_bar.dart'; +import 'package:pixes/foundation/app.dart'; +import 'package:pixes/network/network.dart'; +import 'package:pixes/utils/translation.dart'; + +class NovelRecommendationPage extends StatefulWidget { + const NovelRecommendationPage({super.key}); + + @override + State createState() => + _NovelRecommendationPageState(); +} + +class _NovelRecommendationPageState + extends MultiPageLoadingState { + @override + Widget buildContent(BuildContext context, List data) { + return Column( + children: [ + TitleBar(title: "Recommendation".tl), + Expanded( + child: GridViewWithFixedItemHeight( + itemCount: data.length, + itemHeight: 164, + minCrossAxisExtent: 400, + builder: (context, index) { + if (index == data.length - 1) { + nextPage(); + } + return NovelWidget(data[index]); + }, + ).paddingHorizontal(8), + ) + ], + ); + } + + @override + Future>> loadData(int page) { + return Network().getRecommendNovels(); + } +} diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart index 851a4b7..e72a9b1 100644 --- a/lib/pages/search_page.dart +++ b/lib/pages/search_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:pixes/appdata.dart'; import 'package:pixes/components/loading.dart'; import 'package:pixes/components/message.dart'; +import 'package:pixes/components/novel.dart'; import 'package:pixes/components/page_route.dart'; import 'package:pixes/components/user_preview.dart'; import 'package:pixes/foundation/app.dart'; @@ -39,11 +40,11 @@ class _SearchPageState extends State { ]; void search() { - switch(searchType) { + switch (searchType) { case 0: context.to(() => SearchResultPage(text)); case 1: - showToast(context, message: "Not implemented"); + context.to(() => SearchNovelResultPage(text)); case 2: context.to(() => SearchUserResultPage(text)); case 3: @@ -62,7 +63,9 @@ class _SearchPageState extends State { content: Column( children: [ buildSearchBar(), - const SizedBox(height: 8,), + const SizedBox( + height: 8, + ), const Expanded( child: _TrendingTagsView(), ) @@ -130,7 +133,9 @@ class _SearchPageState extends State { }, ), ), - const SizedBox(width: 4,), + const SizedBox( + width: 4, + ), Button( child: const SizedBox( height: 42, @@ -139,7 +144,9 @@ class _SearchPageState extends State { ), ), onPressed: () { - Navigator.of(context).push(SideBarRoute(const SearchSettings())); + Navigator.of(context).push(SideBarRoute(SearchSettings( + isNovel: searchType == 1, + ))); }, ) ], @@ -169,12 +176,13 @@ class _TrendingTagsView extends StatefulWidget { State<_TrendingTagsView> createState() => _TrendingTagsViewState(); } -class _TrendingTagsViewState extends LoadingState<_TrendingTagsView, List> { +class _TrendingTagsViewState + extends LoadingState<_TrendingTagsView, List> { @override Widget buildContent(BuildContext context, List data) { return MasonryGridView.builder( - padding: const EdgeInsets.symmetric(horizontal: 8.0) - + EdgeInsets.only(bottom: context.padding.bottom), + padding: const EdgeInsets.symmetric(horizontal: 8.0) + + EdgeInsets.only(bottom: context.padding.bottom), gridDelegate: const SliverSimpleGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 240, ), @@ -189,7 +197,7 @@ class _TrendingTagsViewState extends LoadingState<_TrendingTagsView, List SearchResultPage(tag.tag.name)); }, child: Stack( children: [ - Positioned.fill(child: ClipRRect( + Positioned.fill( + child: ClipRRect( borderRadius: BorderRadius.circular(4.0), child: AnimatedImage( image: CachedImageProvider(illust.images.first.medium), fit: BoxFit.cover, - width: width-16.0, - height: height-16.0, + width: width - 16.0, + height: height - 16.0, ), )), Positioned( @@ -226,10 +235,14 @@ class _TrendingTagsViewState extends LoadingState<_TrendingTagsView, List createState() => _SearchSettingsState(); } @@ -264,113 +279,139 @@ class _SearchSettingsState extends State { children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), - child: Text("Search Settings".tl, style: const TextStyle(fontSize: 18),), + child: Text( + "Search Settings".tl, + style: const TextStyle(fontSize: 18), + ), ).toAlign(Alignment.centerLeft), - buildItem(title: "Match".tl, child: DropDownButton( - title: Text(appdata.searchOptions.matchType.toString().tl), - items: KeywordMatchType.values.map((e) => - MenuFlyoutItem( - text: Text(e.toString().tl), - onPressed: () { - if(appdata.searchOptions.matchType != e) { - setState(() => appdata.searchOptions.matchType = e); - widget.onChanged?.call(); - } - } - ) - ).toList(), - )), - buildItem(title: "Favorite number".tl, child: DropDownButton( - title: Text(appdata.searchOptions.favoriteNumber.toString().tl), - items: FavoriteNumber.values.map((e) => - MenuFlyoutItem( - text: Text(e.toString().tl), - onPressed: () { - if(appdata.searchOptions.favoriteNumber != e) { - setState(() => appdata.searchOptions.favoriteNumber = e); - widget.onChanged?.call(); - } - } - ) - ).toList(), - )), - buildItem(title: "Sort".tl, child: DropDownButton( - title: Text(appdata.searchOptions.sort.toString().tl), - items: SearchSort.values.map((e) => - MenuFlyoutItem( - text: Text(e.toString().tl), - onPressed: () { - if(appdata.searchOptions.sort != e) { - setState(() => appdata.searchOptions.sort = e); - widget.onChanged?.call(); - } - } - ) - ).toList(), - )), - Card( - padding: EdgeInsets.zero, - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: SizedBox( - width: double.infinity, - child: Column( - children: [ - Text("Start Time".tl, style: const TextStyle(fontSize: 16),) - .paddingVertical(8) - .toAlign(Alignment.centerLeft) - .paddingLeft(16), - DatePicker( - selected: appdata.searchOptions.startTime, - onChanged: (t) { - if(appdata.searchOptions.startTime != t) { - setState(() => appdata.searchOptions.startTime = t); - widget.onChanged?.call(); - } - }, - ), - const SizedBox(height: 8,) - ], - ), - )), - Card( - padding: EdgeInsets.zero, - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: SizedBox( - width: double.infinity, - child: Column( - children: [ - Text("End Time".tl, style: const TextStyle(fontSize: 16),) - .paddingVertical(8) - .toAlign(Alignment.centerLeft) - .paddingLeft(16), - DatePicker( - selected: appdata.searchOptions.endTime, - onChanged: (t) { - if(appdata.searchOptions.endTime != t) { - setState(() => appdata.searchOptions.endTime = t); - widget.onChanged?.call(); - } - }, - ), - const SizedBox(height: 8,) - ], - ), + buildItem( + title: "Match".tl, + child: DropDownButton( + title: Text(appdata.searchOptions.matchType.toString().tl), + items: KeywordMatchType.values + .map((e) => MenuFlyoutItem( + text: Text(e.toString().tl), + onPressed: () { + if (appdata.searchOptions.matchType != e) { + setState(() => appdata.searchOptions.matchType = e); + widget.onChanged?.call(); + } + })) + .toList(), )), - buildItem(title: "Age limit".tl, child: DropDownButton( - title: Text(appdata.searchOptions.ageLimit.toString().tl), - items: AgeLimit.values.map((e) => - MenuFlyoutItem( - text: Text(e.toString().tl), - onPressed: () { - if(appdata.searchOptions.ageLimit != e) { - setState(() => appdata.searchOptions.ageLimit = e); - widget.onChanged?.call(); - } - } - ) - ).toList(), - )), - SizedBox(height: context.padding.bottom,) + if (!widget.isNovel) + buildItem( + title: "Favorite number".tl, + child: DropDownButton( + title: + Text(appdata.searchOptions.favoriteNumber.toString().tl), + items: FavoriteNumber.values + .map((e) => MenuFlyoutItem( + text: Text(e.toString().tl), + onPressed: () { + if (appdata.searchOptions.favoriteNumber != e) { + setState(() => + appdata.searchOptions.favoriteNumber = e); + widget.onChanged?.call(); + } + })) + .toList(), + )), + buildItem( + title: "Sort".tl, + child: DropDownButton( + title: Text(appdata.searchOptions.sort.toString().tl), + items: SearchSort.values + .map((e) => MenuFlyoutItem( + text: Text(e.toString().tl), + onPressed: () { + if (appdata.searchOptions.sort != e) { + setState(() => appdata.searchOptions.sort = e); + widget.onChanged?.call(); + } + })) + .toList(), + )), + if (!widget.isNovel) + Card( + padding: EdgeInsets.zero, + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: SizedBox( + width: double.infinity, + child: Column( + children: [ + Text( + "Start Time".tl, + style: const TextStyle(fontSize: 16), + ) + .paddingVertical(8) + .toAlign(Alignment.centerLeft) + .paddingLeft(16), + DatePicker( + selected: appdata.searchOptions.startTime, + onChanged: (t) { + if (appdata.searchOptions.startTime != t) { + setState(() => appdata.searchOptions.startTime = t); + widget.onChanged?.call(); + } + }, + ), + const SizedBox( + height: 8, + ) + ], + ), + )), + if (!widget.isNovel) + Card( + padding: EdgeInsets.zero, + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: SizedBox( + width: double.infinity, + child: Column( + children: [ + Text( + "End Time".tl, + style: const TextStyle(fontSize: 16), + ) + .paddingVertical(8) + .toAlign(Alignment.centerLeft) + .paddingLeft(16), + DatePicker( + selected: appdata.searchOptions.endTime, + onChanged: (t) { + if (appdata.searchOptions.endTime != t) { + setState(() => appdata.searchOptions.endTime = t); + widget.onChanged?.call(); + } + }, + ), + const SizedBox( + height: 8, + ) + ], + ), + )), + if (!widget.isNovel) + buildItem( + title: "Age limit".tl, + child: DropDownButton( + title: Text(appdata.searchOptions.ageLimit.toString().tl), + items: AgeLimit.values + .map((e) => MenuFlyoutItem( + text: Text(e.toString().tl), + onPressed: () { + if (appdata.searchOptions.ageLimit != e) { + setState( + () => appdata.searchOptions.ageLimit = e); + widget.onChanged?.call(); + } + })) + .toList(), + )), + SizedBox( + height: context.padding.bottom, + ) ], ), ); @@ -388,7 +429,6 @@ class _SearchSettingsState extends State { } } - class SearchResultPage extends StatefulWidget { const SearchResultPage(this.keyword, {super.key}); @@ -398,7 +438,8 @@ class SearchResultPage extends StatefulWidget { State createState() => _SearchResultPageState(); } -class _SearchResultPageState extends MultiPageLoadingState { +class _SearchResultPageState + extends MultiPageLoadingState { late String keyword = widget.keyword; late String oldKeyword = widget.keyword; @@ -406,7 +447,7 @@ class _SearchResultPageState extends MultiPageLoadingState IllustGalleryPage( - illusts: data, - initialPage: index, - nextUrl: nextUrl - )); - },); + return IllustWidget( + data[index], + onTap: () { + context.to(() => IllustGalleryPage( + illusts: data, initialPage: index, nextUrl: nextUrl)); + }, + ); }, childCount: data.length, ), ).sliverPaddingHorizontal(8), - SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom),) + SliverPadding( + padding: EdgeInsets.only(bottom: context.padding.bottom), + ) ], ); } @@ -481,7 +524,9 @@ class _SearchResultPageState extends MultiPageLoadingState isChanged = true,))); - if(isChanged) { + await Navigator.of(context) + .push(SideBarRoute(SearchSettings( + onChanged: () => isChanged = true, + ))); + if (isChanged) { reset(); } }, @@ -513,14 +559,14 @@ class _SearchResultPageState extends MultiPageLoadingState>> loadData(page) async{ - if(nextUrl == "end") { + Future>> loadData(page) async { + if (nextUrl == "end") { return Res.error("No more data"); } var res = nextUrl == null ? await Network().search(keyword, appdata.searchOptions) : await Network().getIllustsWithNextUrl(nextUrl!); - if(!res.error) { + if (!res.error) { nextUrl = res.subData; nextUrl ??= "end"; } @@ -537,30 +583,31 @@ class SearchUserResultPage extends StatefulWidget { State createState() => _SearchUserResultPageState(); } -class _SearchUserResultPageState extends MultiPageLoadingState { +class _SearchUserResultPageState + extends MultiPageLoadingState { @override Widget buildContent(BuildContext context, final List data) { return CustomScrollView( slivers: [ SliverToBoxAdapter( - child: Text("${"Search".tl}: ${widget.keyword}", - style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),) - .paddingVertical(12).paddingHorizontal(16), + child: Text( + "${"Search".tl}: ${widget.keyword}", + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold), + ).paddingVertical(12).paddingHorizontal(16), ), SliverGridViewWithFixedItemHeight( - delegate: SliverChildBuilderDelegate( - (context, index) { - if(index == data.length - 1){ - nextPage(); - } - return UserPreviewWidget(data[index]); - }, - childCount: data.length - ), + delegate: SliverChildBuilderDelegate((context, index) { + if (index == data.length - 1) { + nextPage(); + } + return UserPreviewWidget(data[index]); + }, childCount: data.length), minCrossAxisExtent: 440, itemHeight: 136, ).sliverPaddingHorizontal(8), - SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom),) + SliverPadding( + padding: EdgeInsets.only(bottom: context.padding.bottom), + ) ], ); } @@ -568,12 +615,12 @@ class _SearchUserResultPageState extends MultiPageLoadingState>> loadData(page) async{ - if(nextUrl == "end") { + Future>> loadData(page) async { + if (nextUrl == "end") { return Res.error("No more data"); } var res = await Network().searchUsers(widget.keyword, nextUrl); - if(!res.error) { + if (!res.error) { nextUrl = res.subData; nextUrl ??= "end"; } @@ -581,3 +628,141 @@ class _SearchUserResultPageState extends MultiPageLoadingState createState() => _SearchNovelResultPageState(); +} + +class _SearchNovelResultPageState + extends MultiPageLoadingState { + late String keyword = widget.keyword; + + late String oldKeyword = widget.keyword; + + late final controller = TextEditingController(text: widget.keyword); + + void search() { + if (keyword != oldKeyword) { + oldKeyword = keyword; + reset(); + } + } + + @override + Widget buildContent(BuildContext context, final List data) { + return CustomScrollView( + slivers: [ + buildSearchBar(), + SliverGridViewWithFixedItemHeight( + itemHeight: 164, + minCrossAxisExtent: 400, + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == data.length - 1) { + nextPage(); + } + return NovelWidget(data[index]); + }, + childCount: data.length, + ), + ).sliverPaddingHorizontal(8), + SliverPadding( + padding: EdgeInsets.only(bottom: context.padding.bottom), + ) + ], + ); + } + + Widget buildSearchBar() { + return SliverToBoxAdapter( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: SizedBox( + height: 42, + width: double.infinity, + child: LayoutBuilder( + builder: (context, constrains) { + return SizedBox( + height: 42, + width: constrains.maxWidth, + child: Row( + children: [ + Expanded( + child: TextBox( + controller: controller, + placeholder: "Search artworks".tl, + onChanged: (s) => keyword = s, + onSubmitted: (s) => search(), + foregroundDecoration: BoxDecoration( + border: Border.all( + color: ColorScheme.of(context) + .outlineVariant + .withOpacity(0.6)), + borderRadius: BorderRadius.circular(4)), + suffix: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: search, + child: const Icon( + FluentIcons.search, + size: 16, + ).paddingHorizontal(12), + ), + ), + ), + ), + const SizedBox( + width: 4, + ), + Button( + child: const SizedBox( + height: 42, + child: Center( + child: Icon(FluentIcons.settings), + ), + ), + onPressed: () async { + bool isChanged = false; + await Navigator.of(context) + .push(SideBarRoute(SearchSettings( + onChanged: () => isChanged = true, + isNovel: true, + ))); + if (isChanged) { + reset(); + } + }, + ) + ], + ), + ); + }, + ), + ).paddingHorizontal(16), + ), + ), + ).sliverPadding(const EdgeInsets.only(top: 12)); + } + + String? nextUrl; + + @override + Future>> loadData(page) async { + if (nextUrl == "end") { + return Res.error("No more data"); + } + var res = nextUrl == null + ? await Network().searchNovels(keyword, appdata.searchOptions) + : await Network().getNovelsWithNextUrl(nextUrl!); + if (!res.error) { + nextUrl = res.subData; + nextUrl ??= "end"; + } + return res; + } +} diff --git a/lib/pages/user_info_page.dart b/lib/pages/user_info_page.dart index a740bcd..8a23970 100644 --- a/lib/pages/user_info_page.dart +++ b/lib/pages/user_info_page.dart @@ -3,8 +3,10 @@ import 'package:flutter/gestures.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:pixes/appdata.dart'; import 'package:pixes/components/batch_download.dart'; +import 'package:pixes/components/grid.dart'; import 'package:pixes/components/loading.dart'; import 'package:pixes/components/md.dart'; +import 'package:pixes/components/novel.dart'; import 'package:pixes/components/segmented_button.dart'; import 'package:pixes/components/user_preview.dart'; import 'package:pixes/foundation/app.dart'; @@ -43,11 +45,14 @@ class _UserInfoPageState extends LoadingState { _RelatedUsers(widget.id), buildInformation(), buildArtworkHeader(), - _UserArtworks( - data.id.toString(), - page, - key: ValueKey(data.id + page), - ), + if (page == 2) + _UserNovels(widget.id) + else + _UserArtworks( + data.id.toString(), + page, + key: ValueKey(data.id + page), + ), SliverPadding( padding: EdgeInsets.only(bottom: context.padding.bottom)), ], @@ -204,6 +209,7 @@ class _UserInfoPageState extends LoadingState { options: [ SegmentedButtonOption(0, "Artworks".tl), SegmentedButtonOption(1, "Bookmarks".tl), + SegmentedButtonOption(2, "Novels".tl), ], value: page, onPressed: (value) { @@ -213,15 +219,17 @@ class _UserInfoPageState extends LoadingState { }, ), const Spacer(), - BatchDownloadButton( - request: () { - if (page == 0) { - return Network().getUserIllusts(data!.id.toString()); - } else { - return Network().getUserBookmarks(data!.id.toString()); - } - }, - ), + if (page != 2) + BatchDownloadButton( + request: () { + if (page == 0) { + return Network().getUserIllusts(data!.id.toString()); + } else { + return Network() + .getUserBookmarks(data!.id.toString()); + } + }, + ), ], ).paddingHorizontal(16)) .paddingTop(12), @@ -392,6 +400,81 @@ class _UserArtworksState extends MultiPageLoadingState<_UserArtworks, Illust> { } } +class _UserNovels extends StatefulWidget { + const _UserNovels(this.uid, {super.key}); + + final String uid; + + @override + State<_UserNovels> createState() => _UserNovelsState(); +} + +class _UserNovelsState extends MultiPageLoadingState<_UserNovels, Novel> { + @override + Widget buildLoading(BuildContext context) { + return const SliverToBoxAdapter( + child: SizedBox( + child: Center( + child: ProgressRing(), + ), + ), + ); + } + + @override + Widget buildError(context, error) { + return SliverToBoxAdapter( + child: SizedBox( + child: Center( + child: Row( + children: [ + const Icon(FluentIcons.info), + const SizedBox( + width: 4, + ), + Text(error) + ], + ), + ), + ), + ); + } + + @override + Widget buildContent(BuildContext context, List data) { + return SliverGridViewWithFixedItemHeight( + itemHeight: 164, + minCrossAxisExtent: 400, + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == data.length - 1) { + nextPage(); + } + return NovelWidget(data[index]); + }, + childCount: data.length, + ), + ).sliverPaddingHorizontal(8); + } + + String? nextUrl; + + @override + Future>> loadData(page) async { + if (nextUrl == "end") { + return Res.error("No more data"); + } + var res = nextUrl == null + ? await Network().getUserNovels(widget.uid) + : await Network().getNovelsWithNextUrl(nextUrl!); + if (!res.error) { + nextUrl = res.subData; + nextUrl ??= "end"; + } + return res; + } +} + class _RelatedUsers extends StatefulWidget { const _RelatedUsers(this.uid); diff --git a/lib/utils/app_links.dart b/lib/utils/app_links.dart index a86a781..8495e40 100644 --- a/lib/utils/app_links.dart +++ b/lib/utils/app_links.dart @@ -3,6 +3,9 @@ import 'dart:io'; import 'package:app_links/app_links.dart'; import 'package:pixes/foundation/app.dart'; import 'package:pixes/foundation/log.dart'; +import 'package:pixes/pages/illust_page.dart'; +import 'package:pixes/pages/novel_page.dart'; +import 'package:pixes/pages/user_info_page.dart'; import 'package:win32_registry/win32_registry.dart'; Future _register(String scheme) async { @@ -37,5 +40,36 @@ void handleLinks() async { if (onLink?.call(uri) == true) { return; } + handleLink(uri); }); } + +bool handleLink(Uri uri) { + if (uri.scheme == "pixiv") { + var path = uri.toString().split("/").sublist(2); + if (path.isEmpty) { + return false; + } + switch (path[0]) { + case "users": + if (path.length == 2) { + App.mainNavigatorKey?.currentContext?.to(() => UserInfoPage(path[1])); + return true; + } + case "novels": + if (path.length == 2) { + App.mainNavigatorKey?.currentContext + ?.to(() => NovelPageWithId(path[1])); + return true; + } + case "illusts": + if (path.length == 2) { + App.mainNavigatorKey?.currentContext + ?.to(() => IllustPageWithId(path[1])); + return true; + } + } + return false; + } + return false; +}