This commit is contained in:
wgh19
2024-05-20 15:16:35 +08:00
parent 2a1a668c25
commit a3868b1969
20 changed files with 2146 additions and 428 deletions

View File

@@ -129,7 +129,10 @@
"Related": "相关", "Related": "相关",
"Related artworks": "相关作品", "Related artworks": "相关作品",
"Related users": "相关用户", "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": { "zh_TW": {
"Search": "搜索", "Search": "搜索",
@@ -261,6 +264,9 @@
"Related": "相關", "Related": "相關",
"Related artworks": "相關作品", "Related artworks": "相關作品",
"Related users": "相關用戶", "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": "小說"
} }
} }

View File

@@ -20,14 +20,13 @@ class SliverGridViewWithFixedItemHeight extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverLayoutBuilder( return SliverGrid(
builder: ((context, constraints) => SliverGrid( delegate: delegate,
delegate: delegate, gridDelegate: SliverGridDelegateWithFixedHeight(
gridDelegate: SliverGridDelegateWithFixedHeight( itemHeight: itemHeight,
itemHeight: itemHeight, maxCrossAxisExtent: maxCrossAxisExtent,
maxCrossAxisExtent: maxCrossAxisExtent, minCrossAxisExtent: minCrossAxisExtent),
minCrossAxisExtent: minCrossAxisExtent), ).sliverPadding(EdgeInsets.only(bottom: context.padding.bottom));
).sliverPadding(EdgeInsets.only(bottom: context.padding.bottom))));
} }
} }

View File

@@ -112,6 +112,8 @@ abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
bool get isLoading => _isLoading || _isFirstLoading; bool get isLoading => _isLoading || _isFirstLoading;
bool get isFirstLoading => _isFirstLoading;
void nextPage() { void nextPage() {
if(_isLoading) return; if(_isLoading) return;
_isLoading = true; _isLoading = true;

84
lib/components/novel.dart Normal file
View File

@@ -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<NovelWidget> createState() => _NovelWidgetState();
}
class _NovelWidgetState extends State<NovelWidget> {
@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('<br />', '\n'),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(
height: 4,
),
Text(
widget.novel.author.name,
style: const TextStyle(fontSize: 12),
)
],
),
)
],
),
),
);
}
}

View File

@@ -47,6 +47,8 @@ class _App {
} }
final rootNavigatorKey = GlobalKey<NavigatorState>(); final rootNavigatorKey = GlobalKey<NavigatorState>();
GlobalKey<NavigatorState>? mainNavigatorKey;
} }
// ignore: non_constant_identifier_names // ignore: non_constant_identifier_names

View File

@@ -1,6 +1,8 @@
import "dart:ui"; import "dart:ui";
import "package:dynamic_color/dynamic_color.dart";
import "package:fluent_ui/fluent_ui.dart"; import "package:fluent_ui/fluent_ui.dart";
import "package:flutter/material.dart" as md;
import "package:flutter/services.dart"; import "package:flutter/services.dart";
import "package:pixes/appdata.dart"; import "package:pixes/appdata.dart";
import "package:pixes/components/md.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/app_links.dart";
import "package:pixes/utils/translation.dart"; import "package:pixes/utils/translation.dart";
import "package:window_manager/window_manager.dart"; import "package:window_manager/window_manager.dart";
import 'package:system_theme/system_theme.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -20,15 +21,10 @@ void main() async {
Log.error("Unhandled", "${details.exception}\n${details.stack}"); Log.error("Unhandled", "${details.exception}\n${details.stack}");
}; };
setSystemProxy(); setSystemProxy();
SystemTheme.fallbackColor = Colors.blue;
await SystemTheme.accentColor.load();
await App.init(); await App.init();
await appdata.readData(); await appdata.readData();
await Translation.init(); await Translation.init();
handleLinks(); handleLinks();
SystemTheme.onChange.listen((event) {
StateController.findOrNull(tag: "MyApp")?.update();
});
if (App.isDesktop) { if (App.isDesktop) {
await WindowManager.instance.ensureInitialized(); await WindowManager.instance.ensureInitialized();
windowManager.waitUntilReadyToShow().then((_) async { windowManager.waitUntilReadyToShow().then((_) async {
@@ -54,11 +50,12 @@ class MyApp extends StatelessWidget {
init: SimpleController(), init: SimpleController(),
tag: "MyApp", tag: "MyApp",
builder: (controller) { 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; brightness = Brightness.dark;
} else if(appdata.settings["theme"] == "Light") { } else if (appdata.settings["theme"] == "Light") {
brightness = Brightness.light; brightness = Brightness.light;
} }
@@ -69,54 +66,77 @@ class MyApp extends StatelessWidget {
statusBarIconBrightness: brightness.opposite, statusBarIconBrightness: brightness.opposite,
systemNavigationBarIconBrightness: brightness.opposite, systemNavigationBarIconBrightness: brightness.opposite,
), ),
child: FluentApp( child: DynamicColorBuilder(
navigatorKey: App.rootNavigatorKey, builder: (light, dark) {
debugShowCheckedModeBanner: false, final colorScheme =
title: 'pixes', (brightness == Brightness.light ? light : dark) ??
theme: FluentThemeData( md.ColorScheme.fromSeed(
brightness: brightness, seedColor: Colors.blue, brightness: brightness);
fontFamily: App.isWindows ? 'font' : null, return FluentApp(
accentColor: AccentColor.swatch({ navigatorKey: App.rootNavigatorKey,
'darkest': SystemTheme.accentColor.darkest, debugShowCheckedModeBanner: false,
'darker': SystemTheme.accentColor.darker, title: 'pixes',
'dark': SystemTheme.accentColor.dark, theme: FluentThemeData(
'normal': SystemTheme.accentColor.accent, brightness: brightness,
'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(
fontFamily: App.isWindows ? 'font' : null, fontFamily: App.isWindows ? 'font' : null,
), accentColor: AccentColor.swatch({
child: OverlayWidget(child), '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());
}

View File

@@ -121,14 +121,14 @@ class UserDetails {
pawooUrl = json['profile']['pawoo_url']; pawooUrl = json['profile']['pawoo_url'];
} }
class IllustAuthor { class Author {
final int id; final int id;
final String name; final String name;
final String account; final String account;
final String avatar; final String avatar;
bool isFollowed; 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 { class Tag {
@@ -170,7 +170,7 @@ class Illust {
final List<IllustImage> images; final List<IllustImage> images;
final String caption; final String caption;
final int restrict; final int restrict;
final IllustAuthor author; final Author author;
final List<Tag> tags; final List<Tag> tags;
final DateTime createDate; final DateTime createDate;
final int pageCount; final int pageCount;
@@ -210,7 +210,7 @@ class Illust {
}()), }()),
caption = json['caption'], caption = json['caption'],
restrict = json['restrict'], restrict = json['restrict'],
author = IllustAuthor( author = Author(
json['user']['id'], json['user']['id'],
json['user']['name'], json['user']['name'],
json['user']['account'], json['user']['account'],
@@ -380,7 +380,8 @@ class UserPreview {
avatar = json['user']['profile_image_urls']['medium'], avatar = json['user']['profile_image_urls']['medium'],
isFollowed = json['user']['is_followed'], isFollowed = json['user']['is_followed'],
isBlocking = json['user']['is_access_blocking_user'] ?? false, 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(), uid = json['user']['id'].toString(),
name = json['user']['name'], name = json['user']['name'],
avatar = json['user']['profile_image_urls']['medium'], avatar = json['user']['profile_image_urls']['medium'],
hasReplies = json['has_replies'], hasReplies = json['has_replies'] ?? false,
stampUrl = json['stamp']?['stamp_url']; stampUrl = json['stamp']?['stamp_url'];
} }
/*
{
"id": 20741342,
"title": "中身が一般人のやつがれくん",
"caption": "なんか思いついたので書いてみた。<br />よくある芥川成り代わり。<br />3年くらい前の書きかけのやつをサルベージ。<br />じっくりは書いてないので抜け抜け。<br /><br />デイリー1位ありがとうございます✨<br /><br />※※※※※※※※<br />※※※※※※※※<br /><br />以下読了後推奨の蛇足<br /><br />「芥川くん」<br />「なんですかボス」<br />「君は将来的にどんな地位につきたいとかある?」<br />「僕はしがない一構成員ゆえ」<br />「ほら幹部とか隊長とか人事部とかさ。君あれこれオールマイティにできるから希望を聞いておこうと思って」<br />「ございます」<br />「なにかな?」<br />「僕は将来的にポートマフィア直営のいちじく農家になりたいと思います」<br />「なんて?」<br />「さらに、ゆくゆくはいちじく農家兼、いちじくの素晴らしさを世に知らしめるポートマフィア直営いちじくレストランを開きたいと」<br />「なんて???」",
"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<Tag> 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<String, dynamic> 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;
}

View File

@@ -14,6 +14,8 @@ import 'models.dart';
export 'models.dart'; export 'models.dart';
export 'res.dart'; export 'res.dart';
part 'novel.dart';
class Network { class Network {
static const hashSalt = static const hashSalt =
"28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c"; "28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c";
@@ -159,6 +161,38 @@ class Network {
} }
} }
Future<Res<String>> apiGetPlain(String path,
{Map<String, dynamic>? query}) async {
try {
if (!path.startsWith("http")) {
path = "$baseUrl$path";
}
final res = await dio.get<String>(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<Res<Map<String, dynamic>>> apiPost(String path, Future<Res<Map<String, dynamic>>> apiPost(String path,
{Map<String, dynamic>? query, Map<String, dynamic>? data}) async { {Map<String, dynamic>? query, Map<String, dynamic>? data}) async {
try { try {

152
lib/network/novel.dart Normal file
View File

@@ -0,0 +1,152 @@
part of "network.dart";
extension NovelExt on Network {
Future<Res<List<Novel>>> getRecommendNovels() {
return getNovelsWithNextUrl("/v1/novel/recommended");
}
Future<Res<List<Novel>>> 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<Res<List<Novel>>> 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<Res<List<Novel>>> 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<Res<List<Novel>>> getBookmarkedNovels(String uid) {
return getNovelsWithNextUrl(
"/v1/user/bookmarks/novel?user_id=$uid&restrict=public");
}
Future<Res<bool>> 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<Res<bool>> 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<Res<String>> 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<Res<List<Novel>>> 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<Res<List<Novel>>> getUserNovels(String uid) {
return getNovelsWithNextUrl("/v1/user/novels?user_id=$uid");
}
Future<Res<List<Novel>>> 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<Res<List<Comment>>> 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<Res<bool>> 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<Res<Novel>> 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"]));
}
}

View File

@@ -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<CommentsPage> createState() => _CommentsPageState();
}
class _CommentsPageState extends MultiPageLoadingState<CommentsPage, Comment> {
bool isCommenting = false;
@override
Widget buildContent(BuildContext context, List<Comment> 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<Comment> 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<Res<List<Comment>>> 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;
}
}

View File

@@ -14,6 +14,7 @@ import 'package:pixes/foundation/app.dart';
import 'package:pixes/foundation/image_provider.dart'; import 'package:pixes/foundation/image_provider.dart';
import 'package:pixes/network/download.dart'; import 'package:pixes/network/download.dart';
import 'package:pixes/network/network.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/image_page.dart';
import 'package:pixes/pages/search_page.dart'; import 'package:pixes/pages/search_page.dart';
import 'package:pixes/pages/user_info_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 const SizedBox(width: 8,);
yield Button( yield Button(
onPressed: () => _CommentsPage.show(context, widget.illust.id.toString()), onPressed: () => CommentsPage.show(context, widget.illust.id.toString()),
child: SizedBox( child: SizedBox(
height: 28, height: 28,
child: Row( 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<Comment> 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<Comment> 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<Res<List<Comment>>> 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 { class IllustPageWithId extends StatefulWidget {
const IllustPageWithId(this.id, {super.key}); const IllustPageWithId(this.id, {super.key});

View File

@@ -11,6 +11,9 @@ import "package:pixes/pages/bookmarks.dart";
import "package:pixes/pages/downloaded_page.dart"; import "package:pixes/pages/downloaded_page.dart";
import "package:pixes/pages/following_artworks.dart"; import "package:pixes/pages/following_artworks.dart";
import "package:pixes/pages/history.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/ranking.dart";
import "package:pixes/pages/recommendation_page.dart"; import "package:pixes/pages/recommendation_page.dart";
import "package:pixes/pages/login_page.dart"; import "package:pixes/pages/login_page.dart";
@@ -45,6 +48,7 @@ class _MainPageState extends State<MainPage> with WindowListener {
void initState() { void initState() {
windowManager.addListener(this); windowManager.addListener(this);
listenMouseSideButtonToBack(navigatorKey); listenMouseSideButtonToBack(navigatorKey);
App.mainNavigatorKey = navigatorKey;
super.initState(); super.initState();
} }
@@ -93,24 +97,37 @@ class _MainPageState extends State<MainPage> with WindowListener {
items: [ items: [
UserPane(), UserPane(),
PaneItem( PaneItem(
icon: const Icon(MdIcons.search, size: 20,), icon: const Icon(
MdIcons.search,
size: 20,
),
title: Text('Search'.tl), title: Text('Search'.tl),
body: const SizedBox.shrink(), body: const SizedBox.shrink(),
), ),
PaneItem( PaneItem(
icon: const Icon(MdIcons.downloading, size: 20,), icon: const Icon(
MdIcons.downloading,
size: 20,
),
title: Text('Downloading'.tl), title: Text('Downloading'.tl),
body: const SizedBox.shrink(), body: const SizedBox.shrink(),
), ),
PaneItem( PaneItem(
icon: const Icon(MdIcons.download, size: 20,), icon: const Icon(
MdIcons.download,
size: 20,
),
title: Text('Downloaded'.tl), title: Text('Downloaded'.tl),
body: const SizedBox.shrink(), body: const SizedBox.shrink(),
), ),
PaneItemSeparator(), PaneItemSeparator(),
PaneItemHeader(header: Text("Artwork".tl).paddingBottom(4).paddingLeft(8)), PaneItemHeader(
header: Text("Artwork".tl).paddingBottom(4).paddingLeft(8)),
PaneItem( PaneItem(
icon: const Icon(MdIcons.explore_outlined, size: 20,), icon: const Icon(
MdIcons.explore_outlined,
size: 20,
),
title: Text('Explore'.tl), title: Text('Explore'.tl),
body: const SizedBox.shrink(), body: const SizedBox.shrink(),
), ),
@@ -134,8 +151,26 @@ class _MainPageState extends State<MainPage> with WindowListener {
title: Text('Ranking'.tl), title: Text('Ranking'.tl),
body: const SizedBox.shrink(), body: const SizedBox.shrink(),
), ),
], PaneItemSeparator(),
footerItems: [ 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( PaneItem(
icon: const Icon(MdIcons.settings_outlined, size: 20), icon: const Icon(MdIcons.settings_outlined, size: 20),
title: Text('Settings'.tl), title: Text('Settings'.tl),
@@ -168,6 +203,9 @@ class _MainPageState extends State<MainPage> with WindowListener {
() => const FollowingArtworksPage(), () => const FollowingArtworksPage(),
() => const HistoryPage(), () => const HistoryPage(),
() => const RankingPage(), () => const RankingPage(),
() => const NovelRecommendationPage(),
() => const NovelBookmarksPage(),
() => const NovelRankingPage(),
() => const SettingsPage(), () => const SettingsPage(),
]; ];
@@ -204,7 +242,7 @@ class _MainPageState extends State<MainPage> with WindowListener {
style: TextStyle(fontSize: 13), style: TextStyle(fontSize: 13),
), ),
Spacer(), Spacer(),
if(kDebugMode) if (kDebugMode)
Padding( Padding(
padding: EdgeInsets.only(right: 138), padding: EdgeInsets.only(right: 138),
child: Button(onPressed: debug, child: Text("Debug")), child: Button(onPressed: debug, child: Text("Debug")),
@@ -216,9 +254,11 @@ class _MainPageState extends State<MainPage> with WindowListener {
); );
}(), }(),
leading: _BackButton(navigatorKey), leading: _BackButton(navigatorKey),
actions: App.isDesktop ? WindowButtons( actions: App.isDesktop
key: ValueKey(windowButtonKey), ? WindowButtons(
) : null, key: ValueKey(windowButtonKey),
)
: null,
); );
} }
} }
@@ -248,11 +288,11 @@ class _BackButtonState extends State<_BackButton> {
void loop() { void loop() {
timer = Timer.periodic(const Duration(milliseconds: 100), (timer) { timer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
if(!mounted) { if (!mounted) {
timer.cancel(); timer.cancel();
} else { } else {
bool enabled = navigatorKey.currentState?.canPop() == true; bool enabled = navigatorKey.currentState?.canPop() == true;
if(enabled != this.enabled) { if (enabled != this.enabled) {
setState(() { setState(() {
this.enabled = enabled; this.enabled = enabled;
}); });
@@ -293,18 +333,19 @@ class _BackButtonState extends State<_BackButton> {
title: const Text("Back"), title: const Text("Back"),
body: const SizedBox.shrink(), body: const SizedBox.shrink(),
enabled: enabled, enabled: enabled,
).build( )
context, .build(
false, context,
onPressed, false,
displayMode: PaneDisplayMode.compact, onPressed,
).paddingTop(2), displayMode: PaneDisplayMode.compact,
)
.paddingTop(2),
), ),
); );
} }
} }
class WindowButtons extends StatelessWidget { class WindowButtons extends StatelessWidget {
const WindowButtons({super.key}); const WindowButtons({super.key});
@@ -458,7 +499,8 @@ class UserPane extends PaneItem {
child: Image( child: Image(
height: 48, height: 48,
width: 48, width: 48,
image: CachedImageProvider(appdata.account!.user.profile), image:
CachedImageProvider(appdata.account!.user.profile),
fit: BoxFit.fill, fit: BoxFit.fill,
), ),
), ),
@@ -481,7 +523,9 @@ class UserPane extends PaneItem {
fontSize: 16, fontWeight: FontWeight.w500), fontSize: 16, fontWeight: FontWeight.w500),
), ),
Text( Text(
kDebugMode ? "<hide due to debug>" : appdata.account!.user.email, kDebugMode
? "<hide due to debug>"
: appdata.account!.user.email,
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
) )
], ],

View File

@@ -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<NovelBookmarksPage> createState() => _NovelBookmarksPageState();
}
class _NovelBookmarksPageState
extends MultiPageLoadingState<NovelBookmarksPage, Novel> {
@override
Widget buildContent(BuildContext context, List<Novel> 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<Res<List<Novel>>> 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;
}
}

669
lib/pages/novel_page.dart Normal file
View File

@@ -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<NovelPage> createState() => _NovelPageState();
}
class _NovelPageState extends State<NovelPage> {
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<TextSpan> buildDescriptionText() sync* {
var text = widget.novel.caption;
text = text.replaceAll("<br />", "\n");
text = text.replaceAll('\n\n', '\n');
var labels = Queue<String>();
var buffer = StringBuffer();
var style = const TextStyle();
String? link;
Map<String, String> 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<NovelSeriesWidget> createState() => _NovelSeriesWidgetState();
}
class _NovelSeriesWidgetState
extends MultiPageLoadingState<NovelSeriesWidget, Novel> {
@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<Novel> 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<Res<List<Novel>>> 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<NovelPageWithId> createState() => _NovelPageWithIdState();
}
class _NovelPageWithIdState extends LoadingState<NovelPageWithId, Novel> {
@override
Future<Res<Novel>> 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<Novel>> {
@override
Widget buildContent(BuildContext context, List<Novel> 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<Res<List<Novel>>> loadData() async {
return Network().relatedNovels(widget.id);
}
}

View File

@@ -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<NovelRankingPage> createState() => _NovelRankingPageState();
}
class _NovelRankingPageState extends State<NovelRankingPage> {
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<Novel> 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<Res<List<Novel>>> 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;
}
}

View File

@@ -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<NovelReadingPage> createState() => _NovelReadingPageState();
}
class _NovelReadingPageState extends LoadingState<NovelReadingPage, String> {
@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<Res<String>> loadData() {
return Network().getNovelContent(widget.novel.id.toString());
}
}

View File

@@ -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<NovelRecommendationPage> createState() =>
_NovelRecommendationPageState();
}
class _NovelRecommendationPageState
extends MultiPageLoadingState<NovelRecommendationPage, Novel> {
@override
Widget buildContent(BuildContext context, List<Novel> 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<Res<List<Novel>>> loadData(int page) {
return Network().getRecommendNovels();
}
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:pixes/appdata.dart'; import 'package:pixes/appdata.dart';
import 'package:pixes/components/loading.dart'; import 'package:pixes/components/loading.dart';
import 'package:pixes/components/message.dart'; import 'package:pixes/components/message.dart';
import 'package:pixes/components/novel.dart';
import 'package:pixes/components/page_route.dart'; import 'package:pixes/components/page_route.dart';
import 'package:pixes/components/user_preview.dart'; import 'package:pixes/components/user_preview.dart';
import 'package:pixes/foundation/app.dart'; import 'package:pixes/foundation/app.dart';
@@ -39,11 +40,11 @@ class _SearchPageState extends State<SearchPage> {
]; ];
void search() { void search() {
switch(searchType) { switch (searchType) {
case 0: case 0:
context.to(() => SearchResultPage(text)); context.to(() => SearchResultPage(text));
case 1: case 1:
showToast(context, message: "Not implemented"); context.to(() => SearchNovelResultPage(text));
case 2: case 2:
context.to(() => SearchUserResultPage(text)); context.to(() => SearchUserResultPage(text));
case 3: case 3:
@@ -62,7 +63,9 @@ class _SearchPageState extends State<SearchPage> {
content: Column( content: Column(
children: [ children: [
buildSearchBar(), buildSearchBar(),
const SizedBox(height: 8,), const SizedBox(
height: 8,
),
const Expanded( const Expanded(
child: _TrendingTagsView(), child: _TrendingTagsView(),
) )
@@ -130,7 +133,9 @@ class _SearchPageState extends State<SearchPage> {
}, },
), ),
), ),
const SizedBox(width: 4,), const SizedBox(
width: 4,
),
Button( Button(
child: const SizedBox( child: const SizedBox(
height: 42, height: 42,
@@ -139,7 +144,9 @@ class _SearchPageState extends State<SearchPage> {
), ),
), ),
onPressed: () { 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(); State<_TrendingTagsView> createState() => _TrendingTagsViewState();
} }
class _TrendingTagsViewState extends LoadingState<_TrendingTagsView, List<TrendingTag>> { class _TrendingTagsViewState
extends LoadingState<_TrendingTagsView, List<TrendingTag>> {
@override @override
Widget buildContent(BuildContext context, List<TrendingTag> data) { Widget buildContent(BuildContext context, List<TrendingTag> data) {
return MasonryGridView.builder( return MasonryGridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8.0) padding: const EdgeInsets.symmetric(horizontal: 8.0) +
+ EdgeInsets.only(bottom: context.padding.bottom), EdgeInsets.only(bottom: context.padding.bottom),
gridDelegate: const SliverSimpleGridDelegateWithMaxCrossAxisExtent( gridDelegate: const SliverSimpleGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 240, maxCrossAxisExtent: 240,
), ),
@@ -189,7 +197,7 @@ class _TrendingTagsViewState extends LoadingState<_TrendingTagsView, List<Trendi
final illust = tag.illust; final illust = tag.illust;
var text = tag.tag.name; var text = tag.tag.name;
if(tag.tag.translatedName != null) { if (tag.tag.translatedName != null) {
text += "/${tag.tag.translatedName}"; text += "/${tag.tag.translatedName}";
} }
@@ -206,18 +214,19 @@ class _TrendingTagsViewState extends LoadingState<_TrendingTagsView, List<Trendi
child: MouseRegion( child: MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
child: GestureDetector( child: GestureDetector(
onTap: (){ onTap: () {
context.to(() => SearchResultPage(tag.tag.name)); context.to(() => SearchResultPage(tag.tag.name));
}, },
child: Stack( child: Stack(
children: [ children: [
Positioned.fill(child: ClipRRect( Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(4.0), borderRadius: BorderRadius.circular(4.0),
child: AnimatedImage( child: AnimatedImage(
image: CachedImageProvider(illust.images.first.medium), image: CachedImageProvider(illust.images.first.medium),
fit: BoxFit.cover, fit: BoxFit.cover,
width: width-16.0, width: width - 16.0,
height: height-16.0, height: height - 16.0,
), ),
)), )),
Positioned( Positioned(
@@ -226,10 +235,14 @@ class _TrendingTagsViewState extends LoadingState<_TrendingTagsView, List<Trendi
right: 0, right: 0,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: FluentTheme.of(context).micaBackgroundColor.withOpacity(0.84), color: FluentTheme.of(context)
borderRadius: BorderRadius.circular(4) .micaBackgroundColor
), .withOpacity(0.84),
child: Text(text).paddingHorizontal(4).paddingVertical(6).paddingBottom(2), borderRadius: BorderRadius.circular(4)),
child: Text(text)
.paddingHorizontal(4)
.paddingVertical(6)
.paddingBottom(2),
), ),
) )
], ],
@@ -248,10 +261,12 @@ class _TrendingTagsViewState extends LoadingState<_TrendingTagsView, List<Trendi
} }
class SearchSettings extends StatefulWidget { class SearchSettings extends StatefulWidget {
const SearchSettings({this.onChanged, super.key}); const SearchSettings({this.onChanged, this.isNovel = false, super.key});
final void Function()? onChanged; final void Function()? onChanged;
final bool isNovel;
@override @override
State<SearchSettings> createState() => _SearchSettingsState(); State<SearchSettings> createState() => _SearchSettingsState();
} }
@@ -264,113 +279,139 @@ class _SearchSettingsState extends State<SearchSettings> {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), 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), ).toAlign(Alignment.centerLeft),
buildItem(title: "Match".tl, child: DropDownButton( buildItem(
title: Text(appdata.searchOptions.matchType.toString().tl), title: "Match".tl,
items: KeywordMatchType.values.map((e) => child: DropDownButton(
MenuFlyoutItem( title: Text(appdata.searchOptions.matchType.toString().tl),
text: Text(e.toString().tl), items: KeywordMatchType.values
onPressed: () { .map((e) => MenuFlyoutItem(
if(appdata.searchOptions.matchType != e) { text: Text(e.toString().tl),
setState(() => appdata.searchOptions.matchType = e); onPressed: () {
widget.onChanged?.call(); if (appdata.searchOptions.matchType != e) {
} setState(() => appdata.searchOptions.matchType = e);
} widget.onChanged?.call();
) }
).toList(), }))
)), .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: "Age limit".tl, child: DropDownButton( if (!widget.isNovel)
title: Text(appdata.searchOptions.ageLimit.toString().tl), buildItem(
items: AgeLimit.values.map((e) => title: "Favorite number".tl,
MenuFlyoutItem( child: DropDownButton(
text: Text(e.toString().tl), title:
onPressed: () { Text(appdata.searchOptions.favoriteNumber.toString().tl),
if(appdata.searchOptions.ageLimit != e) { items: FavoriteNumber.values
setState(() => appdata.searchOptions.ageLimit = e); .map((e) => MenuFlyoutItem(
widget.onChanged?.call(); text: Text(e.toString().tl),
} onPressed: () {
} if (appdata.searchOptions.favoriteNumber != e) {
) setState(() =>
).toList(), appdata.searchOptions.favoriteNumber = e);
)), widget.onChanged?.call();
SizedBox(height: context.padding.bottom,) }
}))
.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<SearchSettings> {
} }
} }
class SearchResultPage extends StatefulWidget { class SearchResultPage extends StatefulWidget {
const SearchResultPage(this.keyword, {super.key}); const SearchResultPage(this.keyword, {super.key});
@@ -398,7 +438,8 @@ class SearchResultPage extends StatefulWidget {
State<SearchResultPage> createState() => _SearchResultPageState(); State<SearchResultPage> createState() => _SearchResultPageState();
} }
class _SearchResultPageState extends MultiPageLoadingState<SearchResultPage, Illust> { class _SearchResultPageState
extends MultiPageLoadingState<SearchResultPage, Illust> {
late String keyword = widget.keyword; late String keyword = widget.keyword;
late String oldKeyword = widget.keyword; late String oldKeyword = widget.keyword;
@@ -406,7 +447,7 @@ class _SearchResultPageState extends MultiPageLoadingState<SearchResultPage, Ill
late final controller = TextEditingController(text: widget.keyword); late final controller = TextEditingController(text: widget.keyword);
void search() { void search() {
if(keyword != oldKeyword) { if (keyword != oldKeyword) {
oldKeyword = keyword; oldKeyword = keyword;
reset(); reset();
} }
@@ -423,21 +464,23 @@ class _SearchResultPageState extends MultiPageLoadingState<SearchResultPage, Ill
), ),
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
if(index == data.length - 1){ if (index == data.length - 1) {
nextPage(); nextPage();
} }
return IllustWidget(data[index], onTap: () { return IllustWidget(
context.to(() => IllustGalleryPage( data[index],
illusts: data, onTap: () {
initialPage: index, context.to(() => IllustGalleryPage(
nextUrl: nextUrl illusts: data, initialPage: index, nextUrl: nextUrl));
)); },
},); );
}, },
childCount: data.length, childCount: data.length,
), ),
).sliverPaddingHorizontal(8), ).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<SearchResultPage, Ill
), ),
), ),
), ),
const SizedBox(width: 4,), const SizedBox(
width: 4,
),
Button( Button(
child: const SizedBox( child: const SizedBox(
height: 42, height: 42,
@@ -489,12 +534,13 @@ class _SearchResultPageState extends MultiPageLoadingState<SearchResultPage, Ill
child: Icon(FluentIcons.settings), child: Icon(FluentIcons.settings),
), ),
), ),
onPressed: () async{ onPressed: () async {
bool isChanged = false; bool isChanged = false;
await Navigator.of(context).push( await Navigator.of(context)
SideBarRoute(SearchSettings( .push(SideBarRoute(SearchSettings(
onChanged: () => isChanged = true,))); onChanged: () => isChanged = true,
if(isChanged) { )));
if (isChanged) {
reset(); reset();
} }
}, },
@@ -513,14 +559,14 @@ class _SearchResultPageState extends MultiPageLoadingState<SearchResultPage, Ill
String? nextUrl; String? nextUrl;
@override @override
Future<Res<List<Illust>>> loadData(page) async{ Future<Res<List<Illust>>> loadData(page) async {
if(nextUrl == "end") { if (nextUrl == "end") {
return Res.error("No more data"); return Res.error("No more data");
} }
var res = nextUrl == null var res = nextUrl == null
? await Network().search(keyword, appdata.searchOptions) ? await Network().search(keyword, appdata.searchOptions)
: await Network().getIllustsWithNextUrl(nextUrl!); : await Network().getIllustsWithNextUrl(nextUrl!);
if(!res.error) { if (!res.error) {
nextUrl = res.subData; nextUrl = res.subData;
nextUrl ??= "end"; nextUrl ??= "end";
} }
@@ -537,30 +583,31 @@ class SearchUserResultPage extends StatefulWidget {
State<SearchUserResultPage> createState() => _SearchUserResultPageState(); State<SearchUserResultPage> createState() => _SearchUserResultPageState();
} }
class _SearchUserResultPageState extends MultiPageLoadingState<SearchUserResultPage, UserPreview> { class _SearchUserResultPageState
extends MultiPageLoadingState<SearchUserResultPage, UserPreview> {
@override @override
Widget buildContent(BuildContext context, final List<UserPreview> data) { Widget buildContent(BuildContext context, final List<UserPreview> data) {
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(
child: Text("${"Search".tl}: ${widget.keyword}", child: Text(
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),) "${"Search".tl}: ${widget.keyword}",
.paddingVertical(12).paddingHorizontal(16), style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
).paddingVertical(12).paddingHorizontal(16),
), ),
SliverGridViewWithFixedItemHeight( SliverGridViewWithFixedItemHeight(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate((context, index) {
(context, index) { if (index == data.length - 1) {
if(index == data.length - 1){ nextPage();
nextPage(); }
} return UserPreviewWidget(data[index]);
return UserPreviewWidget(data[index]); }, childCount: data.length),
},
childCount: data.length
),
minCrossAxisExtent: 440, minCrossAxisExtent: 440,
itemHeight: 136, itemHeight: 136,
).sliverPaddingHorizontal(8), ).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<SearchUserResultP
String? nextUrl; String? nextUrl;
@override @override
Future<Res<List<UserPreview>>> loadData(page) async{ Future<Res<List<UserPreview>>> loadData(page) async {
if(nextUrl == "end") { if (nextUrl == "end") {
return Res.error("No more data"); return Res.error("No more data");
} }
var res = await Network().searchUsers(widget.keyword, nextUrl); var res = await Network().searchUsers(widget.keyword, nextUrl);
if(!res.error) { if (!res.error) {
nextUrl = res.subData; nextUrl = res.subData;
nextUrl ??= "end"; nextUrl ??= "end";
} }
@@ -581,3 +628,141 @@ class _SearchUserResultPageState extends MultiPageLoadingState<SearchUserResultP
} }
} }
class SearchNovelResultPage extends StatefulWidget {
const SearchNovelResultPage(this.keyword, {super.key});
final String keyword;
@override
State<SearchNovelResultPage> createState() => _SearchNovelResultPageState();
}
class _SearchNovelResultPageState
extends MultiPageLoadingState<SearchNovelResultPage, Novel> {
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<Novel> 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<Res<List<Novel>>> 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;
}
}

View File

@@ -3,8 +3,10 @@ import 'package:flutter/gestures.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:pixes/appdata.dart'; import 'package:pixes/appdata.dart';
import 'package:pixes/components/batch_download.dart'; import 'package:pixes/components/batch_download.dart';
import 'package:pixes/components/grid.dart';
import 'package:pixes/components/loading.dart'; import 'package:pixes/components/loading.dart';
import 'package:pixes/components/md.dart'; import 'package:pixes/components/md.dart';
import 'package:pixes/components/novel.dart';
import 'package:pixes/components/segmented_button.dart'; import 'package:pixes/components/segmented_button.dart';
import 'package:pixes/components/user_preview.dart'; import 'package:pixes/components/user_preview.dart';
import 'package:pixes/foundation/app.dart'; import 'package:pixes/foundation/app.dart';
@@ -43,11 +45,14 @@ class _UserInfoPageState extends LoadingState<UserInfoPage, UserDetails> {
_RelatedUsers(widget.id), _RelatedUsers(widget.id),
buildInformation(), buildInformation(),
buildArtworkHeader(), buildArtworkHeader(),
_UserArtworks( if (page == 2)
data.id.toString(), _UserNovels(widget.id)
page, else
key: ValueKey(data.id + page), _UserArtworks(
), data.id.toString(),
page,
key: ValueKey(data.id + page),
),
SliverPadding( SliverPadding(
padding: EdgeInsets.only(bottom: context.padding.bottom)), padding: EdgeInsets.only(bottom: context.padding.bottom)),
], ],
@@ -204,6 +209,7 @@ class _UserInfoPageState extends LoadingState<UserInfoPage, UserDetails> {
options: [ options: [
SegmentedButtonOption(0, "Artworks".tl), SegmentedButtonOption(0, "Artworks".tl),
SegmentedButtonOption(1, "Bookmarks".tl), SegmentedButtonOption(1, "Bookmarks".tl),
SegmentedButtonOption(2, "Novels".tl),
], ],
value: page, value: page,
onPressed: (value) { onPressed: (value) {
@@ -213,15 +219,17 @@ class _UserInfoPageState extends LoadingState<UserInfoPage, UserDetails> {
}, },
), ),
const Spacer(), const Spacer(),
BatchDownloadButton( if (page != 2)
request: () { BatchDownloadButton(
if (page == 0) { request: () {
return Network().getUserIllusts(data!.id.toString()); if (page == 0) {
} else { return Network().getUserIllusts(data!.id.toString());
return Network().getUserBookmarks(data!.id.toString()); } else {
} return Network()
}, .getUserBookmarks(data!.id.toString());
), }
},
),
], ],
).paddingHorizontal(16)) ).paddingHorizontal(16))
.paddingTop(12), .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<Novel> 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<Res<List<Novel>>> 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 { class _RelatedUsers extends StatefulWidget {
const _RelatedUsers(this.uid); const _RelatedUsers(this.uid);

View File

@@ -3,6 +3,9 @@ import 'dart:io';
import 'package:app_links/app_links.dart'; import 'package:app_links/app_links.dart';
import 'package:pixes/foundation/app.dart'; import 'package:pixes/foundation/app.dart';
import 'package:pixes/foundation/log.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'; import 'package:win32_registry/win32_registry.dart';
Future<void> _register(String scheme) async { Future<void> _register(String scheme) async {
@@ -37,5 +40,36 @@ void handleLinks() async {
if (onLink?.call(uri) == true) { if (onLink?.call(uri) == true) {
return; 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;
}