21 Commits

Author SHA1 Message Date
wgh19
5ae73bd7c8 update readme 2024-05-20 22:37:07 +08:00
wgh19
013e509ebf update readme 2024-05-20 22:35:30 +08:00
wgh19
974e2f0cc6 open novel with id 2024-05-20 22:28:02 +08:00
wgh19
1d649ebde2 update version code 2024-05-20 22:26:55 +08:00
wgh19
dd1ed690e1 improve shortcuts 2024-05-20 22:19:06 +08:00
wgh19
f33df47cd6 novel reading settings; improve ui 2024-05-20 21:58:58 +08:00
wgh19
c51df1efde add support for novel image 2024-05-20 17:42:54 +08:00
wgh19
93ce4eb94b change dependencies 2024-05-20 15:17:22 +08:00
wgh19
a3868b1969 novel 2024-05-20 15:16:35 +08:00
wgh19
2a1a668c25 improve user preview 2024-05-18 16:46:56 +08:00
wgh19
b0d740a174 update version code 2024-05-17 18:01:14 +08:00
wgh19
811b7b4ed8 add ${page} to download subpath 2024-05-17 18:00:21 +08:00
wgh19
1fecb8d55d page view 2024-05-17 17:51:50 +08:00
wgh19
67ebe4e50b related users and related artworks 2024-05-17 17:16:21 +08:00
wgh19
a9bddd7def fix language select 2024-05-17 10:02:22 +08:00
wgh19
4b8acfc3ff pause and delete all 2024-05-17 10:00:36 +08:00
wgh19
38f57584b6 improve ui 2024-05-17 09:43:00 +08:00
wgh19
8ff269c8a8 View a user's public bookmarks 2024-05-17 09:26:42 +08:00
wgh19
dde518ab6b fix comment 2024-05-16 18:01:05 +08:00
wgh19
bfad0dc176 fix proxy 2024-05-16 17:54:17 +08:00
wgh19
ed9213b12e disable sandbox 2024-05-16 17:11:50 +08:00
43 changed files with 3937 additions and 1075 deletions

View File

@@ -1,5 +1,9 @@
# pixes
Unofficial pixiv app
非官方 Pixiv app, 支持 Windows, Android, iOS, macOS
This project is under development.
主要功能均已实现
## 屏幕截图
<img src="screenshots/1.png" style="width: 400px">

View File

@@ -121,7 +121,24 @@
"Proxy": "代理",
"Appearance": "外观",
"Language": "语言",
"Theme": "主题"
"Theme": "主题",
"Pause": "暂停",
"Resume": "继续",
"Paused": "已暂停",
"Delete all": "删除全部",
"Related": "相关",
"Related artworks": "相关作品",
"Related users": "相关用户",
"Replace with '-p${index}' if the work have more than one images, otherwise replace with blank.": "替换为'-p${index}'如果作品有多张图片, 否则替换为空白",
"Recommendation": "推荐",
"Novel": "小说",
"Novels": "小说",
"Reading Settings": "阅读设置",
"Font Size": "字体大小",
"Line Height": "行高",
"Paragraph Spacing": "段间距",
"light": "浅色",
"dark": "深色"
},
"zh_TW": {
"Search": "搜索",
@@ -245,6 +262,23 @@
"Proxy": "代理",
"Appearance": "外觀",
"Language": "語言",
"Theme": "主題"
"Theme": "主題",
"Pause": "暫停",
"Resume": "繼續",
"Paused": "已暫停",
"Delete all": "刪除全部",
"Related": "相關",
"Related artworks": "相關作品",
"Related users": "相關用戶",
"Replace with '-p${index}' if the work have more than one images, otherwise replace with blank.": "替換為'-p${index}'如果作品有多張圖片, 否則替換為空白",
"Recommendation": "推薦",
"Novel": "小說",
"Novels": "小說",
"Reading Settings": "閱讀設置",
"Font Size": "字體大小",
"Line Height": "行高",
"Paragraph Spacing": "段間距",
"light": "淺色",
"dark": "深色"
}
}

View File

@@ -17,7 +17,7 @@ import Flutter
let proxyConfig = "\(host):\(port)"
result(proxyConfig)
} else {
result("")
result("no proxy")
}
}

View File

@@ -20,6 +20,9 @@ class _Appdata {
"proxy": "",
"darkMode": "System",
"language": "System",
"readingFontSize": 16.0,
"readingLineHeight": 1.5,
"readingParagraphSpacing": 8.0,
};
bool lock = false;

View File

@@ -1,48 +1,43 @@
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:pixes/foundation/app.dart';
class SliverGridViewWithFixedItemHeight extends StatelessWidget {
const SliverGridViewWithFixedItemHeight(
{required this.delegate,
required this.maxCrossAxisExtent,
required this.itemHeight,
super.key});
this.maxCrossAxisExtent = double.infinity,
this.minCrossAxisExtent = 0,
required this.itemHeight,
super.key});
final SliverChildDelegate delegate;
final double maxCrossAxisExtent;
final double minCrossAxisExtent;
final double itemHeight;
@override
Widget build(BuildContext context) {
return SliverLayoutBuilder(
builder: ((context, constraints) => SliverGrid(
delegate: delegate,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: maxCrossAxisExtent,
childAspectRatio:
calcChildAspectRatio(constraints.crossAxisExtent)),
).sliverPadding(EdgeInsets.only(bottom: context.padding.bottom))));
}
double calcChildAspectRatio(double width) {
var crossItems = width ~/ maxCrossAxisExtent;
if (width % maxCrossAxisExtent != 0) {
crossItems += 1;
}
final itemWidth = width / crossItems;
return itemWidth / itemHeight;
return SliverGrid(
delegate: delegate,
gridDelegate: SliverGridDelegateWithFixedHeight(
itemHeight: itemHeight,
maxCrossAxisExtent: maxCrossAxisExtent,
minCrossAxisExtent: minCrossAxisExtent),
).sliverPadding(EdgeInsets.only(bottom: context.padding.bottom));
}
}
class GridViewWithFixedItemHeight extends StatelessWidget {
const GridViewWithFixedItemHeight(
{ required this.builder,
required this.itemCount,
required this.maxCrossAxisExtent,
required this.itemHeight,
super.key});
{required this.builder,
required this.itemCount,
this.maxCrossAxisExtent = double.infinity,
this.minCrossAxisExtent = 0,
required this.itemHeight,
super.key});
final Widget Function(BuildContext, int) builder;
@@ -50,28 +45,69 @@ class GridViewWithFixedItemHeight extends StatelessWidget {
final double maxCrossAxisExtent;
final double minCrossAxisExtent;
final double itemHeight;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: ((context, constraints) => GridView.builder(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: maxCrossAxisExtent,
childAspectRatio:
calcChildAspectRatio(constraints.maxWidth)),
itemBuilder: builder,
itemCount: itemCount,
padding: EdgeInsets.only(bottom: context.padding.bottom),
)));
gridDelegate: SliverGridDelegateWithFixedHeight(
itemHeight: itemHeight,
maxCrossAxisExtent: maxCrossAxisExtent,
minCrossAxisExtent: minCrossAxisExtent),
itemBuilder: builder,
itemCount: itemCount,
padding: EdgeInsets.only(bottom: context.padding.bottom),
)));
}
}
class SliverGridDelegateWithFixedHeight extends SliverGridDelegate {
const SliverGridDelegateWithFixedHeight({
this.maxCrossAxisExtent = double.infinity,
this.minCrossAxisExtent = 0,
required this.itemHeight,
});
final double maxCrossAxisExtent;
final double minCrossAxisExtent;
final double itemHeight;
@override
SliverGridLayout getLayout(SliverConstraints constraints) {
var crossItemsCount = calcCrossItemsCount(constraints.crossAxisExtent);
return SliverGridRegularTileLayout(
crossAxisCount: crossItemsCount,
mainAxisStride: itemHeight,
childMainAxisExtent: itemHeight,
crossAxisStride: constraints.crossAxisExtent / crossItemsCount,
childCrossAxisExtent: constraints.crossAxisExtent / crossItemsCount,
reverseCrossAxis: false);
}
double calcChildAspectRatio(double width) {
var crossItems = width ~/ maxCrossAxisExtent;
if (width % maxCrossAxisExtent != 0) {
crossItems += 1;
int calcCrossItemsCount(double width) {
int count = 20;
var itemWidth = width / 20;
while (
!(itemWidth > minCrossAxisExtent && itemWidth < maxCrossAxisExtent)) {
count--;
itemWidth = width / count;
if (count == 1) {
return 1;
}
}
final itemWidth = width / crossItems;
return itemWidth / itemHeight;
return count;
}
}
@override
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
return oldDelegate is! SliverGridDelegateWithFixedHeight ||
oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent ||
oldDelegate.minCrossAxisExtent != minCrossAxisExtent ||
oldDelegate.itemHeight != itemHeight;
}
}

View File

@@ -10,10 +10,12 @@ import '../pages/illust_page.dart';
import 'md.dart';
class IllustWidget extends StatefulWidget {
const IllustWidget(this.illust, {super.key});
const IllustWidget(this.illust, {this.onTap, super.key});
final Illust illust;
final void Function()? onTap;
@override
State<IllustWidget> createState() => _IllustWidgetState();
}
@@ -45,7 +47,7 @@ class _IllustWidgetState extends State<IllustWidget> {
padding: EdgeInsets.zero,
margin: EdgeInsets.zero,
child: GestureDetector(
onTap: (){
onTap: widget.onTap ?? (){
context.to(() => IllustPage(widget.illust, favoriteCallback: (v) {
setState(() {
widget.illust.isBookmarked = v;

View File

@@ -13,6 +13,34 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object> extends
Widget buildContent(BuildContext context, S data);
Widget? buildFrame(BuildContext context, Widget child) => null;
Widget buildLoading() {
return const Center(
child: ProgressRing(),
);
}
void retry() {
setState(() {
isLoading = true;
error = null;
});
loadData().then((value) {
if(value.success) {
setState(() {
isLoading = false;
data = value.data;
});
} else {
setState(() {
isLoading = false;
error = value.errorMessage!;
});
}
});
}
Widget buildError() {
return Center(
child: Column(
@@ -21,25 +49,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object> extends
Text(error!),
const SizedBox(height: 12),
Button(
onPressed: () {
setState(() {
isLoading = true;
error = null;
});
loadData().then((value) {
if(value.success) {
setState(() {
isLoading = false;
data = value.data;
});
} else {
setState(() {
isLoading = false;
error = value.errorMessage!;
});
}
});
},
onPressed: retry,
child: const Text("Retry"),
)
],
@@ -69,15 +79,17 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object> extends
@override
Widget build(BuildContext context) {
Widget child;
if(isLoading){
return const Center(
child: ProgressRing(),
);
child = buildLoading();
} else if (error != null){
return buildError();
child = buildError();
} else {
return buildContent(context, data!);
child = buildContent(context, data!);
}
return buildFrame(context, child) ?? child;
}
}
@@ -94,10 +106,14 @@ abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
Future<Res<List<S>>> loadData(int page);
Widget? buildFrame(BuildContext context, Widget child) => null;
Widget buildContent(BuildContext context, final List<S> data);
bool get isLoading => _isLoading || _isFirstLoading;
bool get isFirstLoading => _isFirstLoading;
void nextPage() {
if(_isLoading) return;
_isLoading = true;
@@ -181,12 +197,16 @@ abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
@override
Widget build(BuildContext context) {
Widget child;
if(_isFirstLoading){
return buildLoading(context);
child = buildLoading(context);
} else if (_error != null){
return buildError(context, _error!);
child = buildError(context, _error!);
} else {
return buildContent(context, _data!);
child = buildContent(context, _data!);
}
return buildFrame(context, child) ?? child;
}
}

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart' as md;
typedef MdIcons = md.Icons;
typedef MdTheme = md.Theme;
typedef MdThemeData = md.ThemeData;
typedef MdColorScheme = md.ColorScheme;
class ColorScheme {
static md.ColorScheme of(md.BuildContext context) {

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

@@ -1,3 +1,5 @@
import 'dart:math';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:pixes/components/animated_image.dart';
import 'package:pixes/foundation/app.dart';
@@ -20,15 +22,15 @@ class UserPreviewWidget extends StatefulWidget {
class _UserPreviewWidgetState extends State<UserPreviewWidget> {
bool isFollowing = false;
void follow() async{
if(isFollowing) return;
void follow() async {
if (isFollowing) return;
setState(() {
isFollowing = true;
});
var method = widget.user.isFollowed ? "delete" : "add";
var res = await Network().follow(widget.user.id.toString(), method);
if(res.error) {
if(mounted) {
if (res.error) {
if (mounted) {
context.showToast(message: "Network Error");
}
} else {
@@ -43,65 +45,120 @@ class _UserPreviewWidgetState extends State<UserPreviewWidget> {
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
children: [
SizedBox(
width: 64,
height: 64,
child: ClipRRect(
borderRadius: BorderRadius.circular(64),
child: ColoredBox(
color: ColorScheme.of(context).secondaryContainer,
child: AnimatedImage(
image: CachedImageProvider(widget.user.avatar),
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
child: GestureDetector(
onTap: () => context.to(() => UserInfoPage(widget.user.id.toString())),
behavior: HitTestBehavior.translucent,
child: SizedBox.expand(
child: Row(
children: [
SizedBox(
width: 64,
height: 64,
child: ClipRRect(
borderRadius: BorderRadius.circular(64),
child: ColoredBox(
color: ColorScheme.of(context).secondaryContainer,
child: AnimatedImage(
image: CachedImageProvider(widget.user.avatar),
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
),
),
),
),
const SizedBox(
width: 12,
),
SizedBox(
width: 96,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Spacer(),
Text(widget.user.name,
maxLines: 1,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(
height: 12,
),
Row(
children: [
if (isFollowing)
Button(
onPressed: follow,
child: const SizedBox(
width: 42,
height: 24,
child: Center(
child: SizedBox.square(
dimension: 18,
child: ProgressRing(
strokeWidth: 2,
),
),
),
))
else if (!widget.user.isFollowed)
Button(onPressed: follow, child: Text("Follow".tl))
else
Button(
onPressed: follow,
child: Text(
"Unfollow".tl,
style: TextStyle(
color: ColorScheme.of(context).error),
),
),
],
),
const Spacer(),
],
),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
var count = constraints.maxWidth.toInt() ~/ 96;
var images = List.generate(
min(count, widget.user.artworks.length),
(index) => buildIllust(widget.user.artworks[index]));
return Row(
children: images,
);
},
),
),
const Icon(
FluentIcons.chevron_right,
size: 14,
)
],
),
),
),
);
}
Widget buildIllust(Illust illust) {
return SizedBox(
width: 96,
height: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: ColoredBox(
color: ColorScheme.of(context).secondaryContainer,
child: AnimatedImage(
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
image: CachedImageProvider(illust.images.first.medium),
),
),
const SizedBox(width: 12,),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.user.name, maxLines: 1, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const Spacer(),
Row(
children: [
Button(
onPressed: () => context.to(() => UserInfoPage(widget.user.id.toString(), followCallback: (v){
setState(() {
widget.user.isFollowed = v;
});
},)),
child: Text("View".tl,),
),
const SizedBox(width: 8,),
if(isFollowing)
Button(onPressed: follow, child: const SizedBox(
width: 42,
height: 24,
child: Center(
child: SizedBox.square(
dimension: 18,
child: ProgressRing(strokeWidth: 2,),
),
),
))
else if (!widget.user.isFollowed)
Button(onPressed: follow, child: Text("Follow".tl))
else
Button(
onPressed: follow,
child: Text("Unfollow".tl, style: TextStyle(color: ColorScheme.of(context).error),),
),
],
)
],
).paddingVertical(8),
)
],
),
),
);
}

View File

@@ -11,7 +11,7 @@ export "state_controller.dart";
export "navigation.dart";
class _App {
final version = "1.0.2";
final version = "1.0.4";
bool get isAndroid => Platform.isAndroid;
bool get isIOS => Platform.isIOS;
@@ -26,8 +26,8 @@ class _App {
if(appdata.settings["language"] != "System"){
return switch(appdata.settings["language"]){
"English" => const Locale("en"),
"简体中文" => const Locale("zh"),
"繁體中文" => const Locale("zh", "Hant"),
"简体中文" => const Locale("zh", "CN"),
"繁體中文" => const Locale("zh", "TW"),
_ => const Locale("en"),
};
}
@@ -47,6 +47,8 @@ class _App {
}
final rootNavigatorKey = GlobalKey<NavigatorState>();
GlobalKey<NavigatorState>? mainNavigatorKey;
}
// ignore: non_constant_identifier_names

View File

@@ -45,10 +45,10 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
}
Future<ui.Codec> _loadBufferAsync(
T key,
StreamController<ImageChunkEvent> chunkEvents,
ImageDecoderCallback decode,
) async {
T key,
StreamController<ImageChunkEvent> chunkEvents,
ImageDecoderCallback decode,
) async {
try {
int retryTime = 1;
@@ -83,11 +83,11 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
}
}
if(stop) {
if (stop) {
throw Exception("Image loading is stopped");
}
if(data!.isEmpty) {
if (data!.isEmpty) {
throw Exception("Empty image data");
}
@@ -147,13 +147,13 @@ class CachedImageProvider extends BaseImageProvider<CachedImageProvider> {
String get key => url;
@override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async{
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
chunkEvents.add(const ImageChunkEvent(
cumulativeBytesLoaded: 0,
expectedTotalBytes: 1,
));
var cached = await CacheManager().findCache(key);
if(cached != null) {
if (cached != null) {
chunkEvents.add(const ImageChunkEvent(
cumulativeBytesLoaded: 1,
expectedTotalBytes: 1,
@@ -161,30 +161,28 @@ class CachedImageProvider extends BaseImageProvider<CachedImageProvider> {
return await File(cached).readAsBytes();
}
var dio = AppDio();
final time = DateFormat("yyyy-MM-dd'T'HH:mm:ss'+00:00'").format(DateTime.now());
final time =
DateFormat("yyyy-MM-dd'T'HH:mm:ss'+00:00'").format(DateTime.now());
final hash = md5.convert(utf8.encode(time + Network.hashSalt)).toString();
var res = await dio.get<ResponseBody>(
url,
options: Options(
responseType: ResponseType.stream,
validateStatus: (status) => status != null && status < 500,
headers: {
"referer": "https://app-api.pixiv.net/",
"user-agent": "PixivAndroidApp/5.0.234 (Android 14; Pixes)",
"x-client-time": time,
"x-client-hash": hash,
"accept-enconding": "gzip",
}
)
);
if(res.statusCode != 200) {
var res = await dio.get<ResponseBody>(url,
options: Options(
responseType: ResponseType.stream,
validateStatus: (status) => status != null && status < 500,
headers: {
"referer": "https://app-api.pixiv.net/",
"user-agent": "PixivAndroidApp/5.0.234 (Android 14; Pixes)",
"x-client-time": time,
"x-client-hash": hash,
"accept-enconding": "gzip",
}));
if (res.statusCode != 200) {
throw BadRequestException("Failed to load image: ${res.statusCode}");
}
var data = <int>[];
var cachingFile = await CacheManager().openWrite(key);
await for (var chunk in res.data!.stream) {
var length = res.data!.contentLength+1;
if(length < data.length) {
var length = res.data!.contentLength + 1;
if (length < data.length) {
length = data.length + 1;
}
data.addAll(chunk);
@@ -203,3 +201,71 @@ class CachedImageProvider extends BaseImageProvider<CachedImageProvider> {
return SynchronousFuture<CachedImageProvider>(this);
}
}
class CachedNovelImageProvider
extends BaseImageProvider<CachedNovelImageProvider> {
final String novelId;
final String imageId;
CachedNovelImageProvider(this.novelId, this.imageId);
@override
String get key => "$novelId/$imageId";
@override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
chunkEvents.add(const ImageChunkEvent(
cumulativeBytesLoaded: 0,
expectedTotalBytes: 1,
));
var cached = await CacheManager().findCache(key);
if (cached != null) {
chunkEvents.add(const ImageChunkEvent(
cumulativeBytesLoaded: 1,
expectedTotalBytes: 1,
));
return await File(cached).readAsBytes();
}
var urlRes = await Network().getNovelImage(novelId, imageId);
var url = urlRes.data;
var dio = AppDio();
final time =
DateFormat("yyyy-MM-dd'T'HH:mm:ss'+00:00'").format(DateTime.now());
final hash = md5.convert(utf8.encode(time + Network.hashSalt)).toString();
var res = await dio.get<ResponseBody>(url,
options: Options(
responseType: ResponseType.stream,
validateStatus: (status) => status != null && status < 500,
headers: {
"referer": "https://app-api.pixiv.net/",
"user-agent": "PixivAndroidApp/5.0.234 (Android 14; Pixes)",
"x-client-time": time,
"x-client-hash": hash,
"accept-enconding": "gzip",
}));
if (res.statusCode != 200) {
throw BadRequestException("Failed to load image: ${res.statusCode}");
}
var data = <int>[];
var cachingFile = await CacheManager().openWrite(key);
await for (var chunk in res.data!.stream) {
var length = res.data!.contentLength + 1;
if (length < data.length) {
length = data.length + 1;
}
data.addAll(chunk);
await cachingFile.writeBytes(chunk);
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: data.length,
expectedTotalBytes: length,
));
}
await cachingFile.close();
return Uint8List.fromList(data);
}
@override
Future<CachedNovelImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture<CachedNovelImageProvider>(this);
}
}

View File

@@ -20,4 +20,6 @@ extension Navigation on BuildContext {
Size get size => MediaQuery.of(this).size;
EdgeInsets get padding => MediaQuery.of(this).padding;
EdgeInsets get viewInsets => MediaQuery.of(this).viewInsets;
}

View File

@@ -1,8 +1,11 @@
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";
import "package:pixes/components/message.dart";
import "package:pixes/foundation/app.dart";
import "package:pixes/foundation/log.dart";
@@ -11,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();
@@ -19,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 {
@@ -53,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;
}
@@ -68,40 +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";
}
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,
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 OverlayWidget(child);
}),
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

@@ -125,23 +125,22 @@ class AppDio extends DioForNative {
}
void setSystemProxy() {
HttpOverrides.global = _ProxyHttpOverrides()
..findProxy(Uri());
HttpOverrides.global = _ProxyHttpOverrides()..findProxy(Uri());
}
class _ProxyHttpOverrides extends HttpOverrides {
String proxy = "DIRECT";
String findProxy(Uri uri) {
var haveUserProxy = appdata.settings["proxy"] != null
&& appdata.settings["proxy"].toString().isNotEmpty;
if(!App.isLinux && !haveUserProxy){
var haveUserProxy = appdata.settings["proxy"] != null &&
appdata.settings["proxy"].toString().removeAllBlank.isNotEmpty;
if (!App.isLinux && !haveUserProxy) {
var channel = const MethodChannel("pixes/proxy");
channel.invokeMethod("getProxy").then((value) {
if(value.toString().toLowerCase() == "no proxy"){
if (value.toString().toLowerCase() == "no proxy") {
proxy = "DIRECT";
} else {
if(proxy.contains("https")){
if (proxy.contains("https")) {
var proxies = value.split(";");
for (String proxy in proxies) {
proxy = proxy.removeAllBlank;
@@ -154,10 +153,20 @@ class _ProxyHttpOverrides extends HttpOverrides {
}
});
} else {
if(haveUserProxy){
if (haveUserProxy) {
proxy = "PROXY ${appdata.settings["proxy"]}";
}
}
// check validation
if (proxy.startsWith("PROXY")) {
var uri = proxy.replaceFirst("PROXY", "").removeAllBlank;
if (!uri.startsWith("http")) {
uri += "http://";
}
if (!uri.isURL) {
return "DIRECT";
}
}
return proxy;
}

View File

@@ -131,6 +131,8 @@ class DownloadingTask {
subPathPatten = subPathPatten.replaceAll(r"${title}", illust.title);
subPathPatten = subPathPatten.replaceAll(r"${author}", illust.author.name);
subPathPatten = subPathPatten.replaceAll(r"${index}", index.toString());
subPathPatten = subPathPatten.replaceAll(r"${page}",
illust.images.length == 1 ? "" : "-p$index");
subPathPatten = subPathPatten.replaceAll(r"${ext}", ext);
subPathPatten = subPathPatten.replaceAll(r"${AI}", illust.isAi ? "AI" : "");
List<String> extractTags(String input) {
@@ -163,6 +165,10 @@ class DownloadingTask {
_stop = false;
_download();
}
void pause() {
_stop = true;
}
}
class DownloadManager {
@@ -276,8 +282,20 @@ class DownloadManager {
int get maxConcurrentTasks => appdata.settings["maxParallels"];
bool _paused = false;
bool get paused => _paused;
void pause() {
_paused = true;
for(var task in tasks) {
task.pause();
}
}
void run() {
_loop ??= Timer.periodic(const Duration(seconds: 1), (timer) {
if(_paused) return;
_bytesPerSecond = _currentBytes;
_currentBytes = 0;
uiUpdateCallback?.call();
@@ -349,4 +367,8 @@ class DownloadManager {
i++;
}
}
void resume() {
_paused = false;
}
}

View File

@@ -121,15 +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 {
@@ -171,9 +170,9 @@ class Illust {
final List<IllustImage> images;
final String caption;
final int restrict;
final IllustAuthor author;
final Author author;
final List<Tag> tags;
final String createDate;
final DateTime createDate;
final int pageCount;
final int width;
final int height;
@@ -211,7 +210,7 @@ class Illust {
}()),
caption = json['caption'],
restrict = json['restrict'],
author = IllustAuthor(
author = Author(
json['user']['id'],
json['user']['name'],
json['user']['account'],
@@ -220,7 +219,7 @@ class Illust {
tags = (json['tags'] as List)
.map((e) => Tag(e['name'], e['translated_name']))
.toList(),
createDate = json['create_date'],
createDate = DateTime.parse(json['create_date']),
pageCount = json['page_count'],
width = json['width'],
height = json['height'],
@@ -250,11 +249,11 @@ enum KeywordMatchType {
@override
toString() => text;
String toParam() => switch(this) {
KeywordMatchType.tagsPartialMatches => "partial_match_for_tags",
KeywordMatchType.tagsExactMatch => "exact_match_for_tags",
KeywordMatchType.titleOrDescriptionSearch => "title_and_caption"
};
String toParam() => switch (this) {
KeywordMatchType.tagsPartialMatches => "partial_match_for_tags",
KeywordMatchType.tagsExactMatch => "exact_match_for_tags",
KeywordMatchType.titleOrDescriptionSearch => "title_and_caption"
};
}
enum FavoriteNumber {
@@ -273,9 +272,11 @@ enum FavoriteNumber {
const FavoriteNumber(this.number);
@override
toString() => this == FavoriteNumber.unlimited ? "Unlimited" : "$number Bookmarks";
toString() =>
this == FavoriteNumber.unlimited ? "Unlimited" : "$number Bookmarks";
String toParam() => this == FavoriteNumber.unlimited ? "" : " ${number}users入り";
String toParam() =>
this == FavoriteNumber.unlimited ? "" : " ${number}users入り";
}
enum SearchSort {
@@ -288,37 +289,35 @@ enum SearchSort {
bool get isPremium => appdata.account?.user.isPremium == true;
static List<SearchSort> get availableValues => [
SearchSort.newToOld,
SearchSort.oldToNew,
SearchSort.popular,
if(appdata.account?.user.isPremium == true)
SearchSort.popularMale,
if(appdata.account?.user.isPremium == true)
SearchSort.popularFemale
];
SearchSort.newToOld,
SearchSort.oldToNew,
SearchSort.popular,
if (appdata.account?.user.isPremium == true) SearchSort.popularMale,
if (appdata.account?.user.isPremium == true) SearchSort.popularFemale
];
@override
toString() {
if(this == SearchSort.popular) {
if (this == SearchSort.popular) {
return isPremium ? "Popular" : "Popular(limited)";
} else if(this == SearchSort.newToOld) {
} else if (this == SearchSort.newToOld) {
return "New to old";
} else if(this == SearchSort.oldToNew){
} else if (this == SearchSort.oldToNew) {
return "Old to new";
} else if(this == SearchSort.popularMale){
} else if (this == SearchSort.popularMale) {
return "Popular(Male)";
} else {
return "Popular(Female)";
}
}
String toParam() => switch(this) {
SearchSort.newToOld => "date_desc",
SearchSort.oldToNew => "date_asc",
SearchSort.popular => "popular_desc",
SearchSort.popularMale => "popular_male_desc",
SearchSort.popularFemale => "popular_female_desc",
};
String toParam() => switch (this) {
SearchSort.newToOld => "date_desc",
SearchSort.oldToNew => "date_asc",
SearchSort.popular => "popular_desc",
SearchSort.popularMale => "popular_male_desc",
SearchSort.popularFemale => "popular_female_desc",
};
}
enum AgeLimit {
@@ -333,11 +332,11 @@ enum AgeLimit {
@override
toString() => text;
String toParam() => switch(this) {
AgeLimit.unlimited => "",
AgeLimit.allAges => " -R-18",
AgeLimit.r18 => "R-18",
};
String toParam() => switch (this) {
AgeLimit.unlimited => "",
AgeLimit.allAges => " -R-18",
AgeLimit.r18 => "R-18",
};
}
class SearchOptions {
@@ -369,17 +368,20 @@ class UserPreview {
final String avatar;
bool isFollowed;
final bool isBlocking;
final List<Illust> artworks;
UserPreview(this.id, this.name, this.account, this.avatar, this.isFollowed,
this.isBlocking);
this.isBlocking, this.artworks);
UserPreview.fromJson(Map<String, dynamic> json)
: id = json['id'],
name = json['name'],
account = json['account'],
avatar = json['profile_image_urls']['medium'],
isFollowed = json['is_followed'],
isBlocking = json['is_access_blocking_user'] ?? false;
: id = json['user']['id'],
name = json['user']['name'],
account = json['user']['account'],
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();
}
/*
@@ -402,7 +404,7 @@ class UserPreview {
}
}
*/
class Comment{
class Comment {
final String id;
final String comment;
final DateTime date;
@@ -419,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": "なんか思いついたので書いてみた。<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 'res.dart';
part 'novel.dart';
class Network {
static const hashSalt =
"28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c";
@@ -108,9 +110,9 @@ class Network {
contentType: Headers.formUrlEncodedContentType,
validateStatus: (i) => true,
headers: headers));
if(res.statusCode != 200) {
if (res.statusCode != 200) {
var data = res.data ?? "";
if(data.contains("Invalid refresh token")) {
if (data.contains("Invalid refresh token")) {
throw "Failed to refresh token. Please log out.";
}
}
@@ -132,8 +134,7 @@ class Network {
}
final res = await dio.get<Map<String, dynamic>>(path,
queryParameters: query,
options:
Options(headers: headers, validateStatus: (status) => true));
options: Options(headers: headers, validateStatus: (status) => true));
if (res.statusCode == 200) {
return Res(res.data!);
} else if (res.statusCode == 400) {
@@ -159,6 +160,37 @@ 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,
{Map<String, dynamic>? query, Map<String, dynamic>? data}) async {
try {
@@ -208,13 +240,15 @@ class Network {
}
}
static const recommendationUrl =
"/v1/illust/recommended?include_privacy_policy=true&filter=for_android&include_ranking_illusts=true";
Future<Res<List<Illust>>> getRecommendedIllusts() async {
var res = await apiGet(
"/v1/illust/recommended?include_privacy_policy=true&filter=for_android&include_ranking_illusts=true");
var res = await apiGet(recommendationUrl);
if (res.success) {
return Res((res.data["illusts"] as List)
.map((e) => Illust.fromJson(e))
.toList());
return Res(
(res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(),
subData: recommendationUrl);
} else {
return Res.error(res.errorMessage);
}
@@ -233,6 +267,19 @@ class Network {
}
}
Future<Res<List<Illust>>> getUserBookmarks(String uid,
[String? nextUrl]) async {
var res = await apiGet(
nextUrl ?? "/v1/user/bookmarks/illust?user_id=$uid&restrict=public");
if (res.success) {
return Res(
(res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(),
subData: res.data["next_url"]);
} else {
return Res.error(res.errorMessage);
}
}
Future<Res<bool>> addBookmark(String id, String method,
[String type = "public"]) async {
var res = method == "add"
@@ -298,7 +345,7 @@ class Network {
}
}
Future<Res<List<Illust>>> getIllustsWithNextUrl(String nextUrl) async{
Future<Res<List<Illust>>> getIllustsWithNextUrl(String nextUrl) async {
var res = await apiGet(nextUrl);
if (res.success) {
return Res(
@@ -309,12 +356,16 @@ class Network {
}
}
Future<Res<List<UserPreview>>> searchUsers(String keyword, [String? nextUrl]) async{
var path = nextUrl ?? "/v1/search/user?filter=for_android&word=${Uri.encodeComponent(keyword)}";
Future<Res<List<UserPreview>>> searchUsers(String keyword,
[String? nextUrl]) async {
var path = nextUrl ??
"/v1/search/user?filter=for_android&word=${Uri.encodeComponent(keyword)}";
var res = await apiGet(path);
if (res.success) {
return Res(
(res.data["user_previews"] as List).map((e) => UserPreview.fromJson(e["user"])).toList(),
(res.data["user_previews"] as List)
.map((e) => UserPreview.fromJson(e))
.toList(),
subData: res.data["next_url"]);
} else {
return Res.error(res.errorMessage);
@@ -322,7 +373,8 @@ class Network {
}
Future<Res<List<Illust>>> getUserIllusts(String uid) async {
var res = await apiGet("/v1/user/illusts?filter=for_android&user_id=$uid&type=illust");
var res = await apiGet(
"/v1/user/illusts?filter=for_android&user_id=$uid&type=illust");
if (res.success) {
return Res(
(res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(),
@@ -332,19 +384,24 @@ class Network {
}
}
Future<Res<List<UserPreview>>> getFollowing(String uid, String type, [String? nextUrl]) async {
var path = nextUrl ?? "/v1/user/following?filter=for_android&user_id=$uid&restrict=$type";
Future<Res<List<UserPreview>>> getFollowing(String uid, String type,
[String? nextUrl]) async {
var path = nextUrl ??
"/v1/user/following?filter=for_android&user_id=$uid&restrict=$type";
var res = await apiGet(path);
if (res.success) {
return Res(
(res.data["user_previews"] as List).map((e) => UserPreview.fromJson(e["user"])).toList(),
(res.data["user_previews"] as List)
.map((e) => UserPreview.fromJson(e))
.toList(),
subData: res.data["next_url"]);
} else {
return Res.error(res.errorMessage);
}
}
Future<Res<List<Illust>>> getFollowingArtworks(String restrict, [String? nextUrl]) async {
Future<Res<List<Illust>>> getFollowingArtworks(String restrict,
[String? nextUrl]) async {
var res = await apiGet(nextUrl ?? "/v2/illust/follow?restrict=$restrict");
if (res.success) {
return Res(
@@ -359,7 +416,9 @@ class Network {
var res = await apiGet("/v1/user/recommended?filter=for_android");
if (res.success) {
return Res(
(res.data["user_previews"] as List).map((e) => UserPreview.fromJson(e["user"])).toList(),
(res.data["user_previews"] as List)
.map((e) => UserPreview.fromJson(e))
.toList(),
subData: res.data["next_url"]);
} else {
return Res.error(res.errorMessage);
@@ -368,7 +427,8 @@ class Network {
/// mode: day, week, month, day_male, day_female, week_original, week_rookie, day_manga, week_manga, month_manga, day_r18_manga, day_r18
Future<Res<List<Illust>>> getRanking(String mode, [String? nextUrl]) async {
var res = await apiGet(nextUrl ?? "/v1/illust/ranking?filter=for_android&mode=$mode");
var res = await apiGet(
nextUrl ?? "/v1/illust/ranking?filter=for_android&mode=$mode");
if (res.success) {
return Res(
(res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(),
@@ -382,7 +442,9 @@ class Network {
var res = await apiGet(nextUrl ?? "/v3/illust/comments?illust_id=$id");
if (res.success) {
return Res(
(res.data["comments"] as List).map((e) => Comment.fromJson(e)).toList(),
(res.data["comments"] as List)
.map((e) => Comment.fromJson(e))
.toList(),
subData: res.data["next_url"]);
} else {
return Res.error(res.errorMessage);
@@ -409,7 +471,8 @@ class Network {
}
Future<Res<List<Illust>>> getRecommendedMangas() async {
var res = await apiGet("/v1/manga/recommended?filter=for_android&include_ranking_illusts=true&include_privacy_policy=true");
var res = await apiGet(
"/v1/manga/recommended?filter=for_android&include_ranking_illusts=true&include_privacy_policy=true");
if (res.success) {
return Res(
(res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(),
@@ -421,13 +484,14 @@ class Network {
Future<Res<List<Illust>>> getHistory(int page) async {
String param = "";
if(page > 1) {
param = "?offset=${30*(page-1)}";
if (page > 1) {
param = "?offset=${30 * (page - 1)}";
}
var res = await apiGet("/v1/user/browsing-history/illusts$param");
if (res.success) {
return Res((res.data["illusts"] as List)
.map((e) => Illust.fromJson(e)).toList());
.map((e) => Illust.fromJson(e))
.toList());
} else {
return Res.error(res.errorMessage);
}
@@ -436,23 +500,59 @@ class Network {
Future<List<Tag>> getMutedTags() async {
var res = await apiGet("/v1/mute/list");
if (res.success) {
return res.data["mute_tags"].map<Tag>((e) =>
Tag(e["tag"]["name"], e["tag"]["translated_name"]))
return res.data["mute_tags"]
.map<Tag>((e) => Tag(e["tag"]["name"], e["tag"]["translated_name"]))
.toList();
} else {
return [];
}
}
Future<Res<bool>> muteTags(List<String> muteTags, List<String> unmuteTags) async {
var res = await apiPost("/v1/mute/edit", data: {
"add_tags": muteTags,
"delete_tags": unmuteTags
});
Future<Res<bool>> muteTags(
List<String> muteTags, List<String> unmuteTags) async {
var res = await apiPost("/v1/mute/edit",
data: {"add_tags": muteTags, "delete_tags": unmuteTags});
if (res.success) {
return const Res(true);
} else {
return Res.fromErrorRes(res);
}
}
Future<Res<List<UserPreview>>> relatedUsers(String id) async {
var res =
await apiGet("/v1/user/related?filter=for_android&seed_user_id=$id");
if (res.success) {
return Res((res.data["user_previews"] as List)
.map((e) => UserPreview.fromJson(e))
.toList());
} else {
return Res.error(res.errorMessage);
}
}
Future<Res<List<Illust>>> relatedIllusts(String id) async {
var res =
await apiGet("/v2/illust/related?filter=for_android&illust_id=$id");
if (res.success) {
return Res((res.data["illusts"] as List)
.map((e) => Illust.fromJson(e))
.toList());
} else {
return Res.error(res.errorMessage);
}
}
Future<Res<String>> getNovelImage(String novelId, String imageId) async {
var res = await apiGetPlain(
"/web/v1/novel/image?novel_id=$novelId&uploaded_image_id=$imageId");
if (res.success) {
var html = res.data;
int start = html.indexOf('<img src="') + 10;
int end = html.indexOf('"', start);
return Res(html.substring(start, end));
} else {
return Res.error(res.errorMessage);
}
}
}

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

@@ -5,6 +5,7 @@ import 'package:pixes/components/segmented_button.dart';
import 'package:pixes/components/title_bar.dart';
import 'package:pixes/foundation/app.dart';
import 'package:pixes/network/network.dart';
import 'package:pixes/pages/illust_page.dart';
import 'package:pixes/utils/translation.dart';
import '../components/illust_widget.dart';
@@ -83,7 +84,13 @@ class _OneBookmarkedPageState extends MultiPageLoadingState<_OneBookmarkedPage,
if(index == data.length - 1){
nextPage();
}
return IllustWidget(data[index]);
return IllustWidget(data[index], onTap: () {
context.to(() => IllustGalleryPage(
illusts: data,
initialPage: index,
nextUrl: nextUrl
));
},);
},
);
});

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

@@ -4,6 +4,7 @@ import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:photo_view/photo_view_gallery.dart';
import 'package:pixes/components/animated_image.dart';
import 'package:pixes/components/grid.dart';
import 'package:pixes/components/md.dart';
import 'package:pixes/components/message.dart';
@@ -73,9 +74,11 @@ class _DownloadedPageState extends State<DownloadedPage> {
color: ColorScheme.of(context).secondaryContainer
),
clipBehavior: Clip.antiAlias,
child: image == null ? null : Image(
child: image == null ? null : AnimatedImage(
image: FileImage(image),
fit: BoxFit.cover,
width: 96,
height: double.infinity,
filterQuality: FilterQuality.medium,
),
),

View File

@@ -47,7 +47,38 @@ class _DownloadingPageState extends State<DownloadingPage> {
Widget buildTop() {
int bytesPerSecond = DownloadManager().bytesPerSecond;
return SliverTitleBar(title: "${"Speed".tl}: ${bytesToText(bytesPerSecond)}/s");
bool paused = DownloadManager().paused;
return SliverTitleBar(
title: paused
? "Paused".tl
:"${"Speed".tl}: ${bytesToText(bytesPerSecond)}/s",
action: SplitButton(
onInvoked: (){
if(!paused) {
DownloadManager().pause();
setState(() {});
} else {
DownloadManager().resume();
setState(() {});
}
},
flyout: MenuFlyout(
items: [
MenuFlyoutItem(text: Text("Cancel All".tl), onPressed: (){
var tasks = List.from(DownloadManager().tasks);
DownloadManager().tasks.clear();
for(var task in tasks) {
task.cancel();
}
setState(() {});
})
],
),
child: Text(paused ? "Resume".tl : "Pause".tl)
.toCenter().fixWidth(56).fixHeight(32),
),
);
}
Widget buildContent() {

View File

@@ -9,6 +9,7 @@ import '../components/illust_widget.dart';
import '../components/loading.dart';
import '../components/segmented_button.dart';
import '../network/network.dart';
import 'illust_page.dart';
class FollowingArtworksPage extends StatefulWidget {
const FollowingArtworksPage({super.key});
@@ -84,7 +85,13 @@ class _OneFollowingPageState extends MultiPageLoadingState<_OneFollowingPage, Il
if(index == data.length - 1){
nextPage();
}
return IllustWidget(data[index]);
return IllustWidget(data[index], onTap: () {
context.to(() => IllustGalleryPage(
illusts: data,
initialPage: index,
nextUrl: nextUrl
));
});
},
);
});

View File

@@ -18,7 +18,8 @@ class FollowingUsersPage extends StatefulWidget {
State<FollowingUsersPage> createState() => _FollowingUsersPageState();
}
class _FollowingUsersPageState extends MultiPageLoadingState<FollowingUsersPage, UserPreview> {
class _FollowingUsersPageState
extends MultiPageLoadingState<FollowingUsersPage, UserPreview> {
String type = "public";
@override
@@ -28,11 +29,13 @@ class _FollowingUsersPageState extends MultiPageLoadingState<FollowingUsersPage,
SliverToBoxAdapter(
child: Row(
children: [
Text("Following".tl,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),)
.paddingVertical(12).paddingLeft(16),
Text(
"Following".tl,
style:
const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
).paddingVertical(12).paddingLeft(16),
const Spacer(),
if(widget.uid == appdata.account?.user.id)
if (widget.uid == appdata.account?.user.id)
SegmentedButton(
value: type,
options: [
@@ -44,22 +47,21 @@ class _FollowingUsersPageState extends MultiPageLoadingState<FollowingUsersPage,
reset();
},
),
const SizedBox(width: 16,)
const SizedBox(
width: 16,
)
],
),
),
SliverGridViewWithFixedItemHeight(
delegate: SliverChildBuilderDelegate(
(context, index) {
if(index == data.length - 1){
nextPage();
}
return UserPreviewWidget(data[index]);
},
childCount: data.length
),
maxCrossAxisExtent: 520,
itemHeight: 114,
delegate: SliverChildBuilderDelegate((context, index) {
if (index == data.length - 1) {
nextPage();
}
return UserPreviewWidget(data[index]);
}, childCount: data.length),
minCrossAxisExtent: 440,
itemHeight: 136,
).sliverPaddingHorizontal(8)
],
);
@@ -68,12 +70,12 @@ class _FollowingUsersPageState extends MultiPageLoadingState<FollowingUsersPage,
String? nextUrl;
@override
Future<Res<List<UserPreview>>> loadData(page) async{
if(nextUrl == "end") {
Future<Res<List<UserPreview>>> loadData(page) async {
if (nextUrl == "end") {
return Res.error("No more data");
}
var res = await Network().getFollowing(widget.uid, type, nextUrl);
if(!res.error) {
if (!res.error) {
nextUrl = res.subData;
nextUrl ??= "end";
}

View File

@@ -4,11 +4,11 @@ import 'package:pixes/appdata.dart';
import 'package:pixes/components/loading.dart';
import 'package:pixes/components/title_bar.dart';
import 'package:pixes/foundation/app.dart';
import 'package:pixes/network/models.dart';
import 'package:pixes/network/network.dart';
import 'package:pixes/utils/translation.dart';
import '../components/illust_widget.dart';
import 'illust_page.dart';
class HistoryPage extends StatefulWidget {
const HistoryPage({super.key});
@@ -36,7 +36,12 @@ class _HistoryPageState extends MultiPageLoadingState<HistoryPage, Illust> {
if(index == data.length - 1){
nextPage();
}
return IllustWidget(data[index]);
return IllustWidget(data[index], onTap: () {
context.to(() => IllustGalleryPage(
illusts: data,
initialPage: index,
));
});
},
);
}),

File diff suppressed because it is too large Load Diff

View File

@@ -16,15 +16,14 @@ import 'package:share_plus/share_plus.dart';
import 'package:window_manager/window_manager.dart';
class ImagePage extends StatefulWidget {
const ImagePage(this.urls, {this.initialPage = 1, super.key});
const ImagePage(this.urls, {this.initialPage = 0, super.key});
final List<String> urls;
final int initialPage;
static show(List<String> urls, {int initialPage = 1}) {
App.rootNavigatorKey.currentState
?.push(AppPageRoute(
static show(List<String> urls, {int initialPage = 0}) {
App.rootNavigatorKey.currentState?.push(AppPageRoute(
builder: (context) => ImagePage(urls, initialPage: initialPage)));
}
@@ -69,61 +68,67 @@ class _ImagePageState extends State<ImagePage> with WindowListener {
Future<File?> getFile() async {
var image = widget.urls[currentPage];
if(image.startsWith("file://")){
if (image.startsWith("file://")) {
return File(image.replaceFirst("file://", ""));
}
var file = await CacheManager().findCache(image);
return file == null
? null
: File(file);
var key = image;
if (key.startsWith("novel:")) {
key = key.split(':').last;
}
var file = await CacheManager().findCache(key);
return file == null ? null : File(file);
}
String getExtensionName() {
var fileName = widget.urls[currentPage].split('/').last;
if(fileName.contains('.')){
if (fileName.contains('.')) {
return '.${fileName.split('.').last}';
}
return '.jpg';
}
void showMenu() {
menuController.showFlyout(builder: (context) => MenuFlyout(
items: [
MenuFlyoutItem(text: Text("Save to".tl), onPressed: () async{
var file = await getFile();
if(file != null){
var fileName = file.path.split('/').last;
if(!fileName.contains('.')){
fileName += getExtensionName();
}
saveFile(file, fileName);
}
}),
MenuFlyoutItem(text: Text("Share".tl), onPressed: () async{
var file = await getFile();
if(file != null){
var ext = getExtensionName();
var fileName = file.path.split('/').last;
if(!fileName.contains('.')){
fileName += ext;
}
var mediaType = switch(ext){
'.jpg' => 'image/jpeg',
'.jpeg' => 'image/jpeg',
'.png' => 'image/png',
'.gif' => 'image/gif',
'.webp' => 'image/webp',
_ => 'application/octet-stream'
};
Share.shareXFiles([XFile.fromData(
await file.readAsBytes(),
mimeType: mediaType,
name: fileName)]
);
}
}),
],
));
menuController.showFlyout(
builder: (context) => MenuFlyout(
items: [
MenuFlyoutItem(
text: Text("Save to".tl),
onPressed: () async {
var file = await getFile();
if (file != null) {
var fileName = file.path.split('/').last;
if (!fileName.contains('.')) {
fileName += getExtensionName();
}
saveFile(file, fileName);
}
}),
MenuFlyoutItem(
text: Text("Share".tl),
onPressed: () async {
var file = await getFile();
if (file != null) {
var ext = getExtensionName();
var fileName = file.path.split('/').last;
if (!fileName.contains('.')) {
fileName += ext;
}
var mediaType = switch (ext) {
'.jpg' => 'image/jpeg',
'.jpeg' => 'image/jpeg',
'.png' => 'image/png',
'.gif' => 'image/gif',
'.webp' => 'image/webp',
_ => 'application/octet-stream'
};
Share.shareXFiles([
XFile.fromData(await file.readAsBytes(),
mimeType: mediaType, name: fileName)
]);
}
}),
],
));
}
@override
@@ -133,12 +138,13 @@ class _ImagePageState extends State<ImagePage> with WindowListener {
color: FluentTheme.of(context).micaBackgroundColor,
child: Listener(
onPointerSignal: (event) {
if(event is PointerScrollEvent &&
if (event is PointerScrollEvent &&
!HardwareKeyboard.instance.isControlPressed) {
if(event.scrollDelta.dy > 0
&& controller.page!.toInt() < widget.urls.length - 1) {
if (event.scrollDelta.dy > 0 &&
controller.page!.toInt() < widget.urls.length - 1) {
controller.jumpToPage(controller.page!.toInt() + 1);
} else if(event.scrollDelta.dy < 0 && controller.page!.toInt() > 0){
} else if (event.scrollDelta.dy < 0 &&
controller.page!.toInt() > 0) {
controller.jumpToPage(controller.page!.toInt() - 1);
}
}
@@ -148,19 +154,17 @@ class _ImagePageState extends State<ImagePage> with WindowListener {
var height = constrains.maxHeight;
return Stack(
children: [
Positioned.fill(child: PhotoViewGallery.builder(
Positioned.fill(
child: PhotoViewGallery.builder(
pageController: controller,
backgroundDecoration: const BoxDecoration(
color: Colors.transparent
),
backgroundDecoration:
const BoxDecoration(color: Colors.transparent),
itemCount: widget.urls.length,
builder: (context, index) {
var image = widget.urls[index];
return PhotoViewGalleryPageOptions(
imageProvider: image.startsWith("file://")
? FileImage(File(image.replaceFirst("file://", "")))
: CachedImageProvider(image) as ImageProvider,
imageProvider: getImageProvider(image),
);
},
onPageChanged: (index) {
@@ -177,17 +181,22 @@ class _ImagePageState extends State<ImagePage> with WindowListener {
height: 36,
child: Row(
children: [
const SizedBox(width: 6,),
const SizedBox(
width: 6,
),
IconButton(
icon: const Icon(FluentIcons.back).paddingAll(2),
onPressed: () => context.pop()
),
onPressed: () => context.pop()),
const Expanded(
child: DragToMoveArea(child: SizedBox.expand(),),
child: DragToMoveArea(
child: SizedBox.expand(),
),
),
buildActions(),
if(App.isDesktop)
WindowButtons(key: ValueKey(windowButtonKey),),
if (App.isDesktop)
WindowButtons(
key: ValueKey(windowButtonKey),
),
],
),
),
@@ -196,7 +205,10 @@ class _ImagePageState extends State<ImagePage> with WindowListener {
left: 0,
top: height / 2 - 9,
child: IconButton(
icon: const Icon(FluentIcons.chevron_left, size: 18,),
icon: const Icon(
FluentIcons.chevron_left,
size: 18,
),
onPressed: () {
controller.previousPage(
duration: const Duration(milliseconds: 300),
@@ -239,25 +251,35 @@ class _ImagePageState extends State<ImagePage> with WindowListener {
controller: menuController,
child: width > 600
? Button(
onPressed: showMenu,
child: const Row(
children: [
Icon(
MdIcons.menu,
size: 18,
),
SizedBox(
width: 8,
),
Text('Actions'),
],
))
onPressed: showMenu,
child: const Row(
children: [
Icon(
MdIcons.menu,
size: 18,
),
SizedBox(
width: 8,
),
Text('Actions'),
],
))
: IconButton(
icon: const Icon(
MdIcons.more_horiz,
size: 20,
),
onPressed: showMenu),
icon: const Icon(
MdIcons.more_horiz,
size: 20,
),
onPressed: showMenu),
);
}
ImageProvider getImageProvider(String url) {
if (url.startsWith("file://")) {
return FileImage(File(url.replaceFirst("file://", "")));
} else if (url.startsWith("novel:")) {
var ids = url.split(':').last.split('/');
return CachedNovelImageProvider(ids[0], ids[1]);
}
return CachedImageProvider(url) as ImageProvider;
}
}

View File

@@ -2,7 +2,6 @@ import "dart:async";
import "package:fluent_ui/fluent_ui.dart";
import "package:flutter/foundation.dart";
import "package:flutter/material.dart" as md;
import "package:pixes/appdata.dart";
import "package:pixes/components/md.dart";
import "package:pixes/foundation/app.dart";
@@ -12,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";
@@ -28,6 +30,32 @@ import "downloading_page.dart";
double get _appBarHeight => App.isDesktop ? 36.0 : 48.0;
class TitleBarAction {
final IconData icon;
final String title;
final void Function() onPressed;
TitleBarAction(this.icon, this.title, this.onPressed);
}
class TitleBarController extends StateController {
TitleBarController();
final List<TitleBarAction> actions = [
if (kDebugMode) TitleBarAction(MdIcons.bug_report, "Debug", debug)
];
void addAction(TitleBarAction action) {
actions.add(action);
update();
}
void removeAction(TitleBarAction action) {
actions.remove(action);
update();
}
}
class MainPage extends StatefulWidget {
const MainPage({super.key});
@@ -44,13 +72,16 @@ class _MainPageState extends State<MainPage> with WindowListener {
@override
void initState() {
StateController.put<TitleBarController>(TitleBarController());
windowManager.addListener(this);
listenMouseSideButtonToBack(navigatorKey);
App.mainNavigatorKey = navigatorKey;
super.initState();
}
@override
void dispose() {
StateController.remove<TitleBarController>();
windowManager.removeListener(this);
super.dispose();
}
@@ -79,91 +110,115 @@ class _MainPageState extends State<MainPage> with WindowListener {
content: LoginPage(() => setState(() {})),
);
}
return md.Theme(
data: md.ThemeData.from(
useMaterial3: true,
colorScheme: md.ColorScheme.fromSeed(
seedColor: FluentTheme.of(context).accentColor.withOpacity(1),
brightness: FluentTheme.of(context).brightness,
)),
child: DefaultSelectionStyle.merge(
selectionColor: FluentTheme.of(context).selectionColor.withOpacity(0.4),
child: NavigationView(
appBar: buildAppBar(context, navigatorKey),
pane: NavigationPane(
selected: index,
onChanged: (value) {
setState(() {
index = value;
});
navigate(value);
},
items: [
UserPane(),
PaneItem(
icon: const Icon(MdIcons.search, size: 20,),
title: Text('Search'.tl),
body: const SizedBox.shrink(),
),
PaneItem(
icon: const Icon(MdIcons.downloading, size: 20,),
title: Text('Downloading'.tl),
body: const SizedBox.shrink(),
),
PaneItem(
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)),
PaneItem(
icon: const Icon(MdIcons.explore_outlined, size: 20,),
title: Text('Explore'.tl),
body: const SizedBox.shrink(),
),
PaneItem(
icon: const Icon(MdIcons.bookmark_outline, size: 20),
title: Text('Bookmarks'.tl),
body: const SizedBox.shrink(),
),
PaneItem(
icon: const Icon(MdIcons.interests_outlined, size: 20),
title: Text('Following'.tl),
body: const SizedBox.shrink(),
),
PaneItem(
icon: const Icon(MdIcons.history, size: 20),
title: Text('History'.tl),
body: const SizedBox.shrink(),
),
PaneItem(
icon: const Icon(MdIcons.leaderboard_outlined, size: 20),
title: Text('Ranking'.tl),
body: const SizedBox.shrink(),
),
],
footerItems: [
PaneItem(
icon: const Icon(MdIcons.settings_outlined, size: 20),
title: Text('Settings'.tl),
body: const SizedBox.shrink(),
),
],
return DefaultSelectionStyle.merge(
selectionColor: FluentTheme.of(context).selectionColor.withOpacity(0.4),
child: NavigationView(
appBar: buildAppBar(context, navigatorKey),
pane: NavigationPane(
selected: index,
onChanged: (value) {
setState(() {
index = value;
});
navigate(value);
},
items: [
UserPane(),
PaneItem(
icon: const Icon(
MdIcons.search,
size: 20,
),
title: Text('Search'.tl),
body: const SizedBox.shrink(),
),
paneBodyBuilder: (pane, child) => NavigatorPopHandler(
key: const Key("navigator"),
onPop: () => navigatorKey.currentState?.pop(),
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: Navigator(
key: navigatorKey,
onGenerateRoute: (settings) => AppPageRoute(
builder: (context) => const RecommendationPage()),
),
))),
));
PaneItem(
icon: const Icon(
MdIcons.downloading,
size: 20,
),
title: Text('Downloading'.tl),
body: const SizedBox.shrink(),
),
PaneItem(
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)),
PaneItem(
icon: const Icon(
MdIcons.explore_outlined,
size: 20,
),
title: Text('Explore'.tl),
body: const SizedBox.shrink(),
),
PaneItem(
icon: const Icon(MdIcons.bookmark_outline, size: 20),
title: Text('Bookmarks'.tl),
body: const SizedBox.shrink(),
),
PaneItem(
icon: const Icon(MdIcons.interests_outlined, size: 20),
title: Text('Following'.tl),
body: const SizedBox.shrink(),
),
PaneItem(
icon: const Icon(MdIcons.history, size: 20),
title: Text('History'.tl),
body: const SizedBox.shrink(),
),
PaneItem(
icon: const Icon(MdIcons.leaderboard_outlined, size: 20),
title: Text('Ranking'.tl),
body: const SizedBox.shrink(),
),
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),
body: const SizedBox.shrink(),
),
],
),
paneBodyBuilder: (pane, child) => NavigatorPopHandler(
key: const Key("navigator"),
onPop: () => navigatorKey.currentState?.pop(),
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: Navigator(
key: navigatorKey,
onGenerateRoute: (settings) => AppPageRoute(
builder: (context) => const RecommendationPage()),
),
))),
);
}
static final pageBuilders = <Widget Function()>[
@@ -176,6 +231,9 @@ class _MainPageState extends State<MainPage> with WindowListener {
() => const FollowingArtworksPage(),
() => const HistoryPage(),
() => const RankingPage(),
() => const NovelRecommendationPage(),
() => const NovelBookmarksPage(),
() => const NovelRankingPage(),
() => const SettingsPage(),
];
@@ -194,39 +252,58 @@ class _MainPageState extends State<MainPage> with WindowListener {
automaticallyImplyLeading: false,
height: _appBarHeight,
title: () {
if (!App.isDesktop) {
return const Align(
alignment: AlignmentDirectional.centerStart,
child: Text("pixes"),
);
}
return const DragToMoveArea(
child: Padding(
padding: EdgeInsets.only(bottom: 4),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Row(
children: [
Text(
"Pixes",
style: TextStyle(fontSize: 13),
),
Spacer(),
if(kDebugMode)
Padding(
padding: EdgeInsets.only(right: 138),
child: Button(onPressed: debug, child: Text("Debug")),
)
],
return StateBuilder<TitleBarController>(
builder: (controller) {
Widget content = Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Row(
children: [
if (!App.isDesktop)
const Text(
"Pixes",
style: TextStyle(fontSize: 13),
),
if (!App.isDesktop) const Spacer(),
if (App.isDesktop)
const Expanded(
child: DragToMoveArea(
child: Text(
"Pixes",
style: TextStyle(fontSize: 13),
)),
),
for (var action in controller.actions)
Button(
onPressed: action.onPressed,
child: Row(
children: [
Icon(
action.icon,
size: 18,
),
const SizedBox(width: 4),
Text(action.title),
],
),
).paddingTop(4).paddingLeft(4),
if (App.isDesktop) const SizedBox(width: 128),
],
),
),
),
),
);
return content;
},
);
}(),
leading: _BackButton(navigatorKey),
actions: App.isDesktop ? WindowButtons(
key: ValueKey(windowButtonKey),
) : null,
actions: App.isDesktop
? WindowButtons(
key: ValueKey(windowButtonKey),
)
: null,
);
}
}
@@ -256,11 +333,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;
});
@@ -301,18 +378,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});
@@ -466,7 +544,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,
),
),
@@ -489,7 +568,9 @@ class UserPane extends PaneItem {
fontSize: 16, fontWeight: FontWeight.w500),
),
Text(
kDebugMode ? "<hide due to debug>" : appdata.account!.user.email,
kDebugMode
? "<hide due to debug>"
: appdata.account!.user.email,
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;
}
}

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

@@ -0,0 +1,681 @@
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!),
SliverPadding(
padding: EdgeInsets.only(
top: 16 + MediaQuery.of(context).padding.bottom)),
],
),
).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,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(widget.novel.author.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
)),
Text(
widget.novel.createDate.toString().substring(0, 10),
style: TextStyle(
fontSize: 12,
color: ColorScheme.of(context).outline,
),
),
],
),
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(
mainAxisAlignment: constrains.maxWidth > 420
? MainAxisAlignment.start
: MainAxisAlignment.center,
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),
if (constrains.maxWidth > 420)
const SizedBox(width: 12),
if (constrains.maxWidth > 420) Text("Favorite".tl)
],
)
.fixWidth(shouldFillSpace
? width / 4 - 4 - kFluentButtonPadding
: 64)
.fixHeight(32),
),
const SizedBox(width: 8),
Button(
child: Row(
mainAxisAlignment: constrains.maxWidth > 420
? MainAxisAlignment.start
: MainAxisAlignment.center,
children: [
const Icon(MdIcons.comment, size: 18),
if (constrains.maxWidth > 420)
const SizedBox(width: 12),
if (constrains.maxWidth > 420) 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,262 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:pixes/appdata.dart';
import 'package:pixes/components/animated_image.dart';
import 'package:pixes/components/loading.dart';
import 'package:pixes/components/md.dart';
import 'package:pixes/components/page_route.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/image_page.dart';
import 'package:pixes/pages/main_page.dart';
import 'package:pixes/utils/ext.dart';
import 'package:pixes/utils/translation.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> {
TitleBarAction? action;
bool isShowingSettings = false;
@override
void initState() {
action = TitleBarAction(MdIcons.tune, "Settings", () {
if (!isShowingSettings) {
_NovelReadingSettings.show(context, () {
setState(() {});
});
isShowingSettings = true;
} else {
Navigator.of(context).pop();
isShowingSettings = false;
}
});
Future.delayed(const Duration(milliseconds: 200), () {
StateController.find<TitleBarController>().addAction(action!);
});
super.initState();
}
@override
void dispose() {
Future.delayed(const Duration(milliseconds: 200), () {
StateController.find<TitleBarController>().removeAction(action!);
});
super.dispose();
}
@override
Widget buildContent(BuildContext context, String data) {
var content = buildList(context).toList();
return ScaffoldPage(
padding: EdgeInsets.zero,
content: SelectionArea(
child: DefaultTextStyle.merge(
style: const TextStyle(fontSize: 16.0, height: 1.6),
child: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemBuilder: (context, index) {
return content[index];
},
itemCount: content.length,
),
)),
);
}
@override
Future<Res<String>> loadData() {
return Network().getNovelContent(widget.novel.id.toString());
}
Iterable<Widget> buildList(BuildContext context) sync* {
double fontSizeAdd = appdata.settings["readingFontSize"] - 16.0;
double fontHeight = appdata.settings["readingLineHeight"];
yield Text(widget.novel.title,
style: TextStyle(
fontSize: 24.0 + fontSizeAdd, fontWeight: FontWeight.bold));
yield const SizedBox(height: 12.0);
yield const Divider(
style: DividerThemeData(horizontalMargin: EdgeInsets.all(0)),
);
yield const SizedBox(height: 12.0);
var novelContent = data!.split('\n');
for (var content in novelContent) {
if (content.isEmpty) continue;
if (content.startsWith('[uploadedimage:')) {
var imageId = content.nums;
yield GestureDetector(
onTap: () {
ImagePage.show(["novel:${widget.novel.id.toString()}/$imageId"]);
},
child: SizedBox(
height: 300,
width: double.infinity,
child: AnimatedImage(
image:
CachedNovelImageProvider(widget.novel.id.toString(), imageId),
filterQuality: FilterQuality.medium,
fit: BoxFit.contain,
height: 300,
width: double.infinity,
),
),
);
} else if (content.startsWith('[chapter:')) {
var title = content.replaceLast(']', '').split(':')[1];
yield Text(title,
style: TextStyle(
fontSize: 20.0 + fontSizeAdd,
fontWeight: FontWeight.bold,
height: fontHeight))
.paddingBottom(8);
} else {
yield Text(content,
style:
TextStyle(fontSize: 16.0 + fontSizeAdd, height: fontHeight))
.paddingBottom(appdata.settings["readingParagraphSpacing"]);
}
}
}
}
class _NovelReadingSettings extends StatefulWidget {
const _NovelReadingSettings(this.callback);
final void Function() callback;
static void show(BuildContext context, void Function() callback) {
Navigator.of(context).push(SideBarRoute(_NovelReadingSettings(callback)));
}
@override
State<_NovelReadingSettings> createState() => __NovelReadingSettingsState();
}
class __NovelReadingSettingsState extends State<_NovelReadingSettings> {
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
TitleBar(title: "Reading Settings".tl),
const SizedBox(height: 8),
Card(
padding: EdgeInsets.zero,
child: ListTile(
title: Text("Font Size".tl),
subtitle: Slider(
value: appdata.settings["readingFontSize"],
onChanged: (value) {
setState(() {
appdata.settings["readingFontSize"] = value;
});
appdata.writeSettings();
widget.callback();
},
min: 12.0,
max: 24.0,
divisions: 12,
label: appdata.settings["readingFontSize"].toString(),
),
trailing: Text(appdata.settings["readingFontSize"].toString()),
),
).paddingHorizontal(8).paddingBottom(8),
Card(
padding: EdgeInsets.zero,
child: ListTile(
title: Text("Line Height".tl),
subtitle: Slider(
value: appdata.settings["readingLineHeight"],
onChanged: (value) {
setState(() {
appdata.settings["readingLineHeight"] = value;
});
appdata.writeSettings();
widget.callback();
},
min: 1.0,
max: 2.0,
divisions: 10,
label: appdata.settings["readingLineHeight"].toString(),
),
trailing: Text(appdata.settings["readingLineHeight"].toString()),
),
).paddingHorizontal(8).paddingBottom(8),
Card(
padding: EdgeInsets.zero,
child: ListTile(
title: Text("Paragraph Spacing".tl),
subtitle: Slider(
value: appdata.settings["readingParagraphSpacing"],
onChanged: (value) {
setState(() {
appdata.settings["readingParagraphSpacing"] = value;
});
appdata.writeSettings();
widget.callback();
},
min: 0.0,
max: 16.0,
divisions: 8,
label: appdata.settings["readingParagraphSpacing"].toString(),
),
trailing:
Text(appdata.settings["readingParagraphSpacing"].toString()),
),
).paddingHorizontal(8).paddingBottom(8),
// 深色模式
Card(
margin: const EdgeInsets.symmetric(horizontal: 8),
padding: EdgeInsets.zero,
child: ListTile(
title: Text("Theme".tl),
trailing: DropDownButton(
title: Text(appdata.settings["theme"] ?? "System".tl),
items: [
MenuFlyoutItem(
text: Text("System".tl),
onPressed: () {
setState(() {
appdata.settings["theme"] = "System";
});
appdata.writeData();
StateController.findOrNull(tag: "MyApp")?.update();
}),
MenuFlyoutItem(
text: Text("light".tl),
onPressed: () {
setState(() {
appdata.settings["theme"] = "Light";
});
appdata.writeData();
StateController.findOrNull(tag: "MyApp")?.update();
}),
MenuFlyoutItem(
text: Text("dark".tl),
onPressed: () {
setState(() {
appdata.settings["theme"] = "Dark";
});
appdata.writeData();
StateController.findOrNull(tag: "MyApp")?.update();
}),
]),
),
),
],
),
);
}
}

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

@@ -8,6 +8,7 @@ import '../components/illust_widget.dart';
import '../components/loading.dart';
import '../components/title_bar.dart';
import '../network/network.dart';
import 'illust_page.dart';
class RankingPage extends StatefulWidget {
const RankingPage({super.key});
@@ -97,7 +98,13 @@ class _OneRankingPageState extends MultiPageLoadingState<_OneRankingPage, Illust
if(index == data.length - 1){
nextPage();
}
return IllustWidget(data[index]);
return IllustWidget(data[index], onTap: () {
context.to(() => IllustGalleryPage(
illusts: data,
initialPage: index,
nextUrl: nextUrl
));
});
},
);
});

View File

@@ -5,6 +5,7 @@ import 'package:pixes/components/loading.dart';
import 'package:pixes/components/title_bar.dart';
import 'package:pixes/foundation/app.dart';
import 'package:pixes/network/network.dart';
import 'package:pixes/pages/illust_page.dart';
import 'package:pixes/utils/translation.dart';
import '../components/grid.dart';
@@ -28,8 +29,11 @@ class _RecommendationPageState extends State<RecommendationPage> {
buildTab(),
Expanded(
child: type != 2
? _RecommendationArtworksPage(type, key: Key(type.toString()),)
: const _RecommendationUsersPage(),
? _RecommendationArtworksPage(
type,
key: Key(type.toString()),
)
: const _RecommendationUsersPage(),
)
],
);
@@ -45,7 +49,7 @@ class _RecommendationPageState extends State<RecommendationPage> {
SegmentedButtonOption(2, "Users".tl),
],
onPressed: (key) {
if(key != type) {
if (key != type) {
setState(() {
type = key;
});
@@ -57,32 +61,42 @@ class _RecommendationPageState extends State<RecommendationPage> {
}
}
class _RecommendationArtworksPage extends StatefulWidget {
const _RecommendationArtworksPage(this.type, {super.key});
final int type;
@override
State<_RecommendationArtworksPage> createState() => _RecommendationArtworksPageState();
State<_RecommendationArtworksPage> createState() =>
_RecommendationArtworksPageState();
}
class _RecommendationArtworksPageState extends MultiPageLoadingState<_RecommendationArtworksPage, Illust> {
class _RecommendationArtworksPageState
extends MultiPageLoadingState<_RecommendationArtworksPage, Illust> {
@override
Widget buildContent(BuildContext context, final List<Illust> data) {
return LayoutBuilder(builder: (context, constrains){
return LayoutBuilder(builder: (context, constrains) {
return MasonryGridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8)
+ EdgeInsets.only(bottom: context.padding.bottom),
padding: const EdgeInsets.symmetric(horizontal: 8) +
EdgeInsets.only(bottom: context.padding.bottom),
gridDelegate: const SliverSimpleGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 240,
),
itemCount: data.length,
itemBuilder: (context, index) {
if(index == data.length - 1){
if (index == data.length - 1) {
nextPage();
}
return IllustWidget(data[index]);
return IllustWidget(
data[index],
onTap: () {
context.to(() => IllustGalleryPage(
illusts: data,
initialPage: index,
nextUrl: Network.recommendationUrl,
));
},
);
},
);
});
@@ -100,33 +114,32 @@ class _RecommendationUsersPage extends StatefulWidget {
const _RecommendationUsersPage();
@override
State<_RecommendationUsersPage> createState() => _RecommendationUsersPageState();
State<_RecommendationUsersPage> createState() =>
_RecommendationUsersPageState();
}
class _RecommendationUsersPageState extends MultiPageLoadingState<_RecommendationUsersPage, UserPreview> {
class _RecommendationUsersPageState
extends MultiPageLoadingState<_RecommendationUsersPage, UserPreview> {
@override
Widget buildContent(BuildContext context, List<UserPreview> data) {
return CustomScrollView(
slivers: [
SliverGridViewWithFixedItemHeight(
delegate: SliverChildBuilderDelegate(
(context, index) {
if(index == data.length - 1){
nextPage();
}
return UserPreviewWidget(data[index]);
},
childCount: data.length
),
maxCrossAxisExtent: 520,
itemHeight: 114,
delegate: SliverChildBuilderDelegate((context, index) {
if (index == data.length - 1) {
nextPage();
}
return UserPreviewWidget(data[index]);
}, childCount: data.length),
minCrossAxisExtent: 440,
itemHeight: 136,
).sliverPaddingHorizontal(8)
],
);
}
@override
Future<Res<List<UserPreview>>> loadData(page) async{
Future<Res<List<UserPreview>>> loadData(page) async {
var res = await Network().getRecommendationUsers();
return res;
}

View File

@@ -3,11 +3,13 @@ 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';
import 'package:pixes/network/network.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:pixes/utils/translation.dart';
@@ -39,11 +41,11 @@ class _SearchPageState extends State<SearchPage> {
];
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:
@@ -51,7 +53,7 @@ class _SearchPageState extends State<SearchPage> {
case 4:
context.to(() => UserInfoPage(text));
case 5:
showToast(context, message: "Not implemented");
context.to(() => NovelPageWithId(text));
}
}
@@ -62,7 +64,9 @@ class _SearchPageState extends State<SearchPage> {
content: Column(
children: [
buildSearchBar(),
const SizedBox(height: 8,),
const SizedBox(
height: 8,
),
const Expanded(
child: _TrendingTagsView(),
)
@@ -130,7 +134,9 @@ class _SearchPageState extends State<SearchPage> {
},
),
),
const SizedBox(width: 4,),
const SizedBox(
width: 4,
),
Button(
child: const SizedBox(
height: 42,
@@ -139,7 +145,9 @@ class _SearchPageState extends State<SearchPage> {
),
),
onPressed: () {
Navigator.of(context).push(SideBarRoute(const SearchSettings()));
Navigator.of(context).push(SideBarRoute(SearchSettings(
isNovel: searchType == 1,
)));
},
)
],
@@ -169,12 +177,13 @@ class _TrendingTagsView extends StatefulWidget {
State<_TrendingTagsView> createState() => _TrendingTagsViewState();
}
class _TrendingTagsViewState extends LoadingState<_TrendingTagsView, List<TrendingTag>> {
class _TrendingTagsViewState
extends LoadingState<_TrendingTagsView, List<TrendingTag>> {
@override
Widget buildContent(BuildContext context, List<TrendingTag> 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 +198,7 @@ class _TrendingTagsViewState extends LoadingState<_TrendingTagsView, List<Trendi
final illust = tag.illust;
var text = tag.tag.name;
if(tag.tag.translatedName != null) {
if (tag.tag.translatedName != null) {
text += "/${tag.tag.translatedName}";
}
@@ -206,18 +215,19 @@ class _TrendingTagsViewState extends LoadingState<_TrendingTagsView, List<Trendi
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: (){
onTap: () {
context.to(() => 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 +236,14 @@ class _TrendingTagsViewState extends LoadingState<_TrendingTagsView, List<Trendi
right: 0,
child: Container(
decoration: BoxDecoration(
color: FluentTheme.of(context).micaBackgroundColor.withOpacity(0.84),
borderRadius: BorderRadius.circular(4)
),
child: Text(text).paddingHorizontal(4).paddingVertical(6).paddingBottom(2),
color: FluentTheme.of(context)
.micaBackgroundColor
.withOpacity(0.84),
borderRadius: BorderRadius.circular(4)),
child: Text(text)
.paddingHorizontal(4)
.paddingVertical(6)
.paddingBottom(2),
),
)
],
@@ -248,10 +262,12 @@ class _TrendingTagsViewState extends LoadingState<_TrendingTagsView, List<Trendi
}
class SearchSettings extends StatefulWidget {
const SearchSettings({this.onChanged, super.key});
const SearchSettings({this.onChanged, this.isNovel = false, super.key});
final void Function()? onChanged;
final bool isNovel;
@override
State<SearchSettings> createState() => _SearchSettingsState();
}
@@ -264,113 +280,139 @@ class _SearchSettingsState extends State<SearchSettings> {
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 +430,6 @@ class _SearchSettingsState extends State<SearchSettings> {
}
}
class SearchResultPage extends StatefulWidget {
const SearchResultPage(this.keyword, {super.key});
@@ -398,7 +439,8 @@ class SearchResultPage extends StatefulWidget {
State<SearchResultPage> createState() => _SearchResultPageState();
}
class _SearchResultPageState extends MultiPageLoadingState<SearchResultPage, Illust> {
class _SearchResultPageState
extends MultiPageLoadingState<SearchResultPage, Illust> {
late String keyword = widget.keyword;
late String oldKeyword = widget.keyword;
@@ -406,7 +448,7 @@ class _SearchResultPageState extends MultiPageLoadingState<SearchResultPage, Ill
late final controller = TextEditingController(text: widget.keyword);
void search() {
if(keyword != oldKeyword) {
if (keyword != oldKeyword) {
oldKeyword = keyword;
reset();
}
@@ -423,15 +465,23 @@ class _SearchResultPageState extends MultiPageLoadingState<SearchResultPage, Ill
),
delegate: SliverChildBuilderDelegate(
(context, index) {
if(index == data.length - 1){
if (index == data.length - 1) {
nextPage();
}
return IllustWidget(data[index]);
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),
)
],
);
}
@@ -475,7 +525,9 @@ class _SearchResultPageState extends MultiPageLoadingState<SearchResultPage, Ill
),
),
),
const SizedBox(width: 4,),
const SizedBox(
width: 4,
),
Button(
child: const SizedBox(
height: 42,
@@ -483,12 +535,13 @@ class _SearchResultPageState extends MultiPageLoadingState<SearchResultPage, Ill
child: Icon(FluentIcons.settings),
),
),
onPressed: () async{
onPressed: () async {
bool isChanged = false;
await Navigator.of(context).push(
SideBarRoute(SearchSettings(
onChanged: () => isChanged = true,)));
if(isChanged) {
await Navigator.of(context)
.push(SideBarRoute(SearchSettings(
onChanged: () => isChanged = true,
)));
if (isChanged) {
reset();
}
},
@@ -507,14 +560,14 @@ class _SearchResultPageState extends MultiPageLoadingState<SearchResultPage, Ill
String? nextUrl;
@override
Future<Res<List<Illust>>> loadData(page) async{
if(nextUrl == "end") {
Future<Res<List<Illust>>> 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";
}
@@ -531,30 +584,31 @@ class SearchUserResultPage extends StatefulWidget {
State<SearchUserResultPage> createState() => _SearchUserResultPageState();
}
class _SearchUserResultPageState extends MultiPageLoadingState<SearchUserResultPage, UserPreview> {
class _SearchUserResultPageState
extends MultiPageLoadingState<SearchUserResultPage, UserPreview> {
@override
Widget buildContent(BuildContext context, final List<UserPreview> 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
),
maxCrossAxisExtent: 520,
itemHeight: 114,
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),
)
],
);
}
@@ -562,12 +616,12 @@ class _SearchUserResultPageState extends MultiPageLoadingState<SearchUserResultP
String? nextUrl;
@override
Future<Res<List<UserPreview>>> loadData(page) async{
if(nextUrl == "end") {
Future<Res<List<UserPreview>>> 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";
}
@@ -575,3 +629,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

@@ -408,9 +408,10 @@ ${"Some keywords will be replaced by the following rule:".tl}
\${author} -> ${"Name of the author".tl}
\${id} -> ${"Artwork ID".tl}
\${index} -> ${"Index of the image in the artwork".tl}
\${page} -> ${"Replace with '-p\${index}' if the work have more than one images, otherwise replace with blank.".tl}
\${ext} -> ${"File extension".tl}
\${AI} -> ${"Replace with 'AI' if the work was generated by AI, otherwise replace with blank".tl}
\${tag{*}} -> ${"Replace with * if the work have tag *, otherwise replace with blank.".tl}
\${tag(*)} -> ${"Replace with * if the work have tag *, otherwise replace with blank.".tl}
${"Multiple path separators will be automatically replaced with a single".tl}
""";

View File

@@ -3,8 +3,12 @@ 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';
import 'package:pixes/foundation/image_provider.dart';
import 'package:pixes/network/network.dart';
@@ -13,6 +17,7 @@ import 'package:pixes/utils/translation.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../components/illust_widget.dart';
import 'illust_page.dart';
class UserInfoPage extends StatefulWidget {
const UserInfoPage(this.id, {this.followCallback, super.key});
@@ -26,21 +31,30 @@ class UserInfoPage extends StatefulWidget {
}
class _UserInfoPageState extends LoadingState<UserInfoPage, UserDetails> {
int page = 0;
@override
Widget buildContent(BuildContext context, UserDetails data) {
return ScaffoldPage(
content: CustomScrollView(
slivers: [
buildUser(),
buildInformation(),
SliverToBoxAdapter(
child: buildHeader(
"Artworks",
action: BatchDownloadButton(
request: () => Network().getUserIllusts(widget.id))
),),
_UserArtworks(data.id.toString(), key: ValueKey(data.id),),
SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)),
child: buildHeader("Related users".tl),
),
_RelatedUsers(widget.id),
buildInformation(),
buildArtworkHeader(),
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)),
],
),
);
@@ -48,23 +62,24 @@ class _UserInfoPageState extends LoadingState<UserInfoPage, UserDetails> {
bool isFollowing = false;
void follow() async{
if(isFollowing) return;
void follow() async {
if (isFollowing) return;
String type = "";
if(!data!.isFollowed) {
if (!data!.isFollowed) {
await flyoutController.showFlyout(
navigatorKey: App.rootNavigatorKey.currentState,
builder: (context) =>
MenuFlyout(
builder: (context) => MenuFlyout(
items: [
MenuFlyoutItem(text: Text("Public".tl),
MenuFlyoutItem(
text: Text("Public".tl),
onPressed: () => type = "public"),
MenuFlyoutItem(text: Text("Private".tl),
MenuFlyoutItem(
text: Text("Private".tl),
onPressed: () => type = "private"),
],
));
}
if(type.isEmpty && !data!.isFollowed) {
if (type.isEmpty && !data!.isFollowed) {
return;
}
setState(() {
@@ -72,8 +87,8 @@ class _UserInfoPageState extends LoadingState<UserInfoPage, UserDetails> {
});
var method = data!.isFollowed ? "delete" : "add";
var res = await Network().follow(data!.id.toString(), method, type);
if(res.error) {
if(mounted) {
if (res.error) {
if (mounted) {
context.showToast(message: "Network Error");
}
} else {
@@ -96,7 +111,8 @@ class _UserInfoPageState extends LoadingState<UserInfoPage, UserDetails> {
height: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(64),
border: Border.all(color: ColorScheme.of(context).outlineVariant, width: 0.6)),
border: Border.all(
color: ColorScheme.of(context).outlineVariant, width: 0.6)),
child: ClipRRect(
borderRadius: BorderRadius.circular(64),
child: Image(
@@ -105,47 +121,60 @@ class _UserInfoPageState extends LoadingState<UserInfoPage, UserDetails> {
height: 64,
fit: BoxFit.cover,
),
),),
),
),
const SizedBox(height: 8),
Text(data!.name, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
Text(data!.name,
style:
const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text.rich(
TextSpan(
children: [
TextSpan(text: 'Follows: '.tl),
TextSpan(
text: '${data!.totalFollowUsers}',
recognizer: TapGestureRecognizer()
..onTap = (() => context.to(() => FollowingUsersPage(widget.id))),
style: TextStyle(fontWeight: FontWeight.bold, color: FluentTheme.of(context).accentColor)
),
text: '${data!.totalFollowUsers}',
recognizer: TapGestureRecognizer()
..onTap = (() =>
context.to(() => FollowingUsersPage(widget.id))),
style: TextStyle(
fontWeight: FontWeight.bold,
color: FluentTheme.of(context).accentColor)),
],
),
style: const TextStyle(fontSize: 14),
),
if(widget.id != appdata.account?.user.id)
const SizedBox(height: 8,),
if(widget.id != appdata.account?.user.id)
if(isFollowing)
Button(onPressed: follow, child: const SizedBox(
width: 42,
height: 24,
child: Center(
child: SizedBox.square(
dimension: 18,
child: ProgressRing(strokeWidth: 2,),
),
),
))
if (widget.id != appdata.account?.user.id)
const SizedBox(
height: 8,
),
if (widget.id != appdata.account?.user.id)
if (isFollowing)
Button(
onPressed: follow,
child: const SizedBox(
width: 42,
height: 24,
child: Center(
child: SizedBox.square(
dimension: 18,
child: ProgressRing(
strokeWidth: 2,
),
),
),
))
else if (!data!.isFollowed)
FlyoutTarget(
controller: flyoutController,
child: Button(onPressed: follow, child: Text("Follow".tl))
)
controller: flyoutController,
child: Button(onPressed: follow, child: Text("Follow".tl)))
else
Button(
onPressed: follow,
child: Text("Unfollow".tl, style: TextStyle(color: ColorScheme.of(context).error),),
child: Text(
"Unfollow".tl,
style: TextStyle(color: ColorScheme.of(context).error),
),
),
],
),
@@ -154,31 +183,78 @@ class _UserInfoPageState extends LoadingState<UserInfoPage, UserDetails> {
Widget buildHeader(String title, {Widget? action}) {
return SizedBox(
width: double.infinity,
height: 38,
child: Row(
children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.w600),
).toAlign(Alignment.centerLeft),
const Spacer(),
if(action != null)
action.toAlign(Alignment.centerRight)
],
).paddingHorizontal(16)).paddingTop(8);
width: double.infinity,
height: 38,
child: Row(
children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.w600),
).toAlign(Alignment.centerLeft),
const Spacer(),
if (action != null) action.toAlign(Alignment.centerRight)
],
).paddingHorizontal(16))
.paddingTop(8);
}
Widget buildArtworkHeader() {
return SliverToBoxAdapter(
child: SizedBox(
width: double.infinity,
height: 38,
child: Row(
children: [
SegmentedButton<int>(
options: [
SegmentedButtonOption(0, "Artworks".tl),
SegmentedButtonOption(1, "Bookmarks".tl),
SegmentedButtonOption(2, "Novels".tl),
],
value: page,
onPressed: (value) {
setState(() {
page = value;
});
},
),
const Spacer(),
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),
);
}
Widget buildInformation() {
Widget buildItem({IconData? icon, required String title, required String? content, Widget? trailing}) {
if(content == null || content.isEmpty) {
Widget buildItem(
{IconData? icon,
required String title,
required String? content,
Widget? trailing}) {
if (content == null || content.isEmpty) {
return const SizedBox.shrink();
}
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
padding: EdgeInsets.zero,
child: ListTile(
leading: icon == null ? null : Icon(icon, size: 20,),
leading: icon == null
? null
: Icon(
icon,
size: 20,
),
title: Text(title),
subtitle: SelectableText(content),
trailing: trailing,
@@ -190,30 +266,46 @@ class _UserInfoPageState extends LoadingState<UserInfoPage, UserDetails> {
child: Column(
children: [
buildHeader("Information".tl),
buildItem(icon: MdIcons.comment_outlined, title: "Introduction".tl, content: data!.comment),
buildItem(icon: MdIcons.cake_outlined, title: "Birthday".tl, content: data!.birth),
buildItem(icon: MdIcons.location_city_outlined, title: "Region", content: data!.region),
buildItem(icon: MdIcons.work_outline, title: "Job".tl, content: data!.job),
buildItem(icon: MdIcons.person_2_outlined, title: "Gender".tl, content: data!.gender),
buildItem(
icon: MdIcons.comment_outlined,
title: "Introduction".tl,
content: data!.comment),
buildItem(
icon: MdIcons.cake_outlined,
title: "Birthday".tl,
content: data!.birth),
buildItem(
icon: MdIcons.location_city_outlined,
title: "Region",
content: data!.region),
buildItem(
icon: MdIcons.work_outline, title: "Job".tl, content: data!.job),
buildItem(
icon: MdIcons.person_2_outlined,
title: "Gender".tl,
content: data!.gender),
buildHeader("Social Network".tl),
buildItem(title: "Webpage",
buildItem(
title: "Webpage",
content: data!.webpage,
trailing: IconButton(
icon: const Icon(MdIcons.open_in_new, size: 18),
onPressed: () => launchUrlString(data!.twitterUrl!)
)),
buildItem(title: "Twitter",
onPressed: () => launchUrlString(data!.twitterUrl!))),
buildItem(
title: "Twitter",
content: data!.twitterUrl,
trailing: IconButton(
icon: const Icon(MdIcons.open_in_new, size: 18),
onPressed: () => launchUrlString(data!.twitterUrl!)
)),
buildItem(title: "pawoo",
onPressed: () => launchUrlString(data!.twitterUrl!))),
buildItem(
title: "pawoo",
content: data!.pawooUrl,
trailing: IconButton(
icon: const Icon(MdIcons.open_in_new, size: 18,),
onPressed: () => launchUrlString(data!.pawooUrl!)
)),
icon: const Icon(
MdIcons.open_in_new,
size: 18,
),
onPressed: () => launchUrlString(data!.pawooUrl!))),
],
),
);
@@ -226,10 +318,12 @@ class _UserInfoPageState extends LoadingState<UserInfoPage, UserDetails> {
}
class _UserArtworks extends StatefulWidget {
const _UserArtworks(this.uid, {super.key});
const _UserArtworks(this.uid, this.type, {super.key});
final String uid;
final int type;
@override
State<_UserArtworks> createState() => _UserArtworksState();
}
@@ -254,7 +348,9 @@ class _UserArtworksState extends MultiPageLoadingState<_UserArtworks, Illust> {
child: Row(
children: [
const Icon(FluentIcons.info),
const SizedBox(width: 4,),
const SizedBox(
width: 4,
),
Text(error)
],
),
@@ -270,11 +366,14 @@ class _UserArtworksState extends MultiPageLoadingState<_UserArtworks, Illust> {
maxCrossAxisExtent: 240,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
if(index == data.length - 1){
(context, index) {
if (index == data.length - 1) {
nextPage();
}
return IllustWidget(data[index]);
return IllustWidget(data[index], onTap: () {
context.to(() => IllustGalleryPage(
illusts: data, initialPage: index, nextUrl: nextUrl));
});
},
childCount: data.length,
),
@@ -284,14 +383,16 @@ class _UserArtworksState extends MultiPageLoadingState<_UserArtworks, Illust> {
String? nextUrl;
@override
Future<Res<List<Illust>>> loadData(page) async{
if(nextUrl == "end") {
Future<Res<List<Illust>>> loadData(page) async {
if (nextUrl == "end") {
return Res.error("No more data");
}
var res = nextUrl == null
? await Network().getUserIllusts(widget.uid)
? (widget.type == 0
? await Network().getUserIllusts(widget.uid)
: await Network().getUserBookmarks(widget.uid))
: await Network().getIllustsWithNextUrl(nextUrl!);
if(!res.error) {
if (!res.error) {
nextUrl = res.subData;
nextUrl ??= "end";
}
@@ -299,3 +400,135 @@ 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 {
const _RelatedUsers(this.uid);
final String uid;
@override
State<_RelatedUsers> createState() => _RelatedUsersState();
}
class _RelatedUsersState
extends LoadingState<_RelatedUsers, List<UserPreview>> {
@override
Widget buildFrame(BuildContext context, Widget child) {
return SliverToBoxAdapter(
child: SizedBox(
height: 146,
width: double.infinity,
child: child,
),
);
}
final ScrollController _controller = ScrollController();
@override
Widget buildContent(BuildContext context, List<UserPreview> data) {
Widget content = Scrollbar(
controller: _controller,
child: ListView.builder(
controller: _controller,
padding: const EdgeInsets.only(bottom: 8, left: 8),
primary: false,
scrollDirection: Axis.horizontal,
itemCount: data.length,
itemBuilder: (context, index) {
return UserPreviewWidget(data[index]).fixWidth(342);
},
));
if (MediaQuery.of(context).size.width > 500) {
content = ScrollbarTheme.merge(
data: const ScrollbarThemeData(
thickness: 6,
hoveringThickness: 6,
mainAxisMargin: 4,
hoveringPadding: EdgeInsets.zero,
padding: EdgeInsets.zero,
hoveringMainAxisMargin: 4),
child: content);
}
return content;
}
@override
Future<Res<List<UserPreview>>> loadData() {
return Network().relatedUsers(widget.uid);
}
}

View File

@@ -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<void> _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;
}

View File

@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<false/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.2+3
version: 1.0.4+104
environment:
sdk: '>=3.3.4 <4.0.0'
@@ -36,7 +36,7 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons.
window_manager: ^0.3.8
fluent_ui: ^4.8.7
system_theme: ^2.3.1
dynamic_color: ^1.7.0
dio: ^5.4.3
crypto:
intl:

BIN
screenshots/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB