49 Commits

Author SHA1 Message Date
wgh19
49da2b772b fix display error 2024-06-01 22:13:22 +08:00
wgh19
4dc1ec8784 improve ui 2024-06-01 22:03:31 +08:00
wgh19
1fad3694cf open link 2024-06-01 16:44:25 +08:00
wgh136
39eb5c836e fix macos build 2024-05-31 23:52:14 +08:00
wgh136
9603706fc6 fix macos build 2024-05-31 23:41:15 +08:00
wgh19
343e993627 fix macos build 2024-05-31 20:26:42 +08:00
wgh19
246f96bdbf update macos&windows build 2024-05-31 20:06:28 +08:00
wgh19
61c6ed0e1b fix init 2024-05-31 19:58:06 +08:00
wgh19
fd63b02b60 update version code 2024-05-31 19:40:03 +08:00
wgh19
9275024050 add a setting for original image 2024-05-31 19:32:46 +08:00
wgh19
5e53c57755 improve progress display 2024-05-31 18:39:28 +08:00
wgh19
6a95fb37ed font 2024-05-31 17:46:59 +08:00
wgh19
20829e1ad5 improve text 2024-05-31 17:44:50 +08:00
wgh19
cb356dbf71 shortcuts 2024-05-31 17:38:27 +08:00
wgh19
9ad6207bd5 fix drag issue 2024-05-31 15:28:04 +08:00
wgh19
676e7508c7 update icon 2024-05-31 15:23:26 +08:00
wgh19
1652a93772 window effect for windows 2024-05-31 15:21:58 +08:00
wgh19
e6d015a2bc deep link for android 2024-05-31 11:45:54 +08:00
wgh19
35dd9dee5f fix window placement 2024-05-27 14:48:07 +08:00
wgh19
b34a8342d2 fix ui 2024-05-23 13:55:44 +08:00
nyne
0ed17edd3e Create LICENSE 2024-05-23 13:17:02 +08:00
wgh19
187e5f9a09 fix ui; update windows build 2024-05-23 11:56:49 +08:00
wgh19
9505b78ae4 update version code 2024-05-22 20:59:52 +08:00
wgh19
1d49f1c387 follow and favorite callbacks 2024-05-22 20:59:15 +08:00
wgh19
7641cc8f5c block tags and authors 2024-05-22 20:40:35 +08:00
wgh19
de26cba0fa Restore window placement on startup 2024-05-22 12:49:58 +08:00
wgh19
471b319891 block tags 2024-05-22 09:30:31 +08:00
wgh136
1a224114fc fix & improve DownloadedPage 2024-05-21 14:59:09 +08:00
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
60 changed files with 5699 additions and 1548 deletions

15
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
"cSpell.words": [
"appdata",
"Bungo",
"gjzr",
"microtask",
"mypixiv",
"pawoo",
"Rorigod",
"sleepinglife",
"Ugoira",
"vocaloidhm",
"vsync"
]
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 nyne
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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

@@ -33,6 +33,15 @@
<!-- Accepts URIs that begin with "example://gizmos” -->
<data android:scheme="pixiv"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="www.pixiv.net" android:pathPrefix="/users"/>
<data android:scheme="https" android:host="www.pixiv.net" android:pathPrefix="/novel"/>
<data android:scheme="https" android:host="www.pixiv.net" android:pathPrefix="/tags"/>
<data android:scheme="https" android:host="www.pixiv.net" android:pathPrefix="/artworks"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@@ -3,14 +3,14 @@
"Search": "搜索",
"Downloading": "下载中",
"Downloaded": "已下载",
"Artwork": "作品",
"Artwork": "插画",
"Explore": "探索",
"Bookmarks": "收藏",
"Following": "关注",
"History": "历史",
"Ranking": "排行",
"Settings": "设置",
"Artworks": "作品",
"Artworks": "插画",
"Mangas": "漫画",
"Users": "用户",
"Search artwork": "搜索作品",
@@ -64,9 +64,9 @@
"Weekly Manga": "每周漫画",
"Monthly Manga": "每月漫画",
"R18": "R18",
"Account": "账",
"Account": "账",
"Logout": "登出",
"Account Settings": "账设置",
"Account Settings": "账设置",
"Edit": "编辑",
"Download": "下载",
"Manage": "管理",
@@ -121,7 +121,48 @@
"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": "深色",
"block": "屏蔽",
"Block": "屏蔽",
"Block(Account)": "屏蔽(账号)",
"Block(Local)": "屏蔽(本地)",
"Add": "添加",
"Submit": "提交",
"Local": "本地",
"Both": "同时",
"This artwork is blocked": "此作品已被屏蔽",
"Delete Invalid Items": "删除无效项目",
"Private Favorite": "私人收藏",
"Shortcuts": "快捷键",
"Page down": "向下翻页",
"Page up": "向上翻页",
"Next work": "下一作品",
"Previous work": "上一作品",
"Add to favorites": "添加收藏",
"Follow the artist": "关注画师",
"Manga": "漫画",
"Actions": "操作",
"Current quantity": "当前数量",
"Display the original image on the details page": "在详情页显示原图",
"Open link": "打开链接",
"Read": "阅读"
},
"zh_TW": {
"Search": "搜索",
@@ -245,6 +286,47 @@
"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": "深色",
"block": "屏蔽",
"Block": "屏蔽",
"Block(Account)": "屏蔽(賬戶)",
"Block(Local)": "屏蔽(本地)",
"Add": "添加",
"Submit": "提交",
"Local": "本地",
"Both": "同時",
"This artwork is blocked": "此作品已被屏蔽",
"Delete Invalid Items": "刪除無效項目",
"Private Favorite": "私人收藏",
"Shortcuts": "快捷鍵",
"Page down": "向下翻頁",
"Page up": "向上翻頁",
"Next work": "下一作品",
"Previous work": "上一作品",
"Add to favorites": "添加收藏",
"Follow the artist": "關注畫師",
"Manga": "漫畫",
"Actions": "操作",
"Current quantity": "當前數量",
"Display the original image on the details page": "在詳情頁顯示原圖",
"Open link": "打開鏈接",
"Read": "閱讀"
}
}

View File

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

View File

@@ -20,6 +20,20 @@ class _Appdata {
"proxy": "",
"darkMode": "System",
"language": "System",
"readingFontSize": 16.0,
"readingLineHeight": 1.5,
"readingParagraphSpacing": 8.0,
"blockTags": [],
"shortcuts": <int>[
LogicalKeyboardKey.arrowDown.keyId,
LogicalKeyboardKey.arrowUp.keyId,
LogicalKeyboardKey.arrowRight.keyId,
LogicalKeyboardKey.arrowLeft.keyId,
LogicalKeyboardKey.enter.keyId,
LogicalKeyboardKey.keyD.keyId,
LogicalKeyboardKey.keyF.keyId,
],
"showOriginalImage": false,
};
bool lock = false;

View File

@@ -15,7 +15,10 @@ class BatchDownloadButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Button(
child: const Icon(MdIcons.download, size: 20,),
child: const Icon(
MdIcons.download,
size: 20,
),
onPressed: () {
showDialog(
context: context,
@@ -40,6 +43,8 @@ class _DownloadDialog extends StatefulWidget {
class _DownloadDialogState extends State<_DownloadDialog> {
int maxCount = 30;
int currentCount = 0;
bool loading = false;
bool cancel = false;
@@ -53,15 +58,18 @@ class _DownloadDialogState extends State<_DownloadDialog> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${"Maximum number of downloads".tl}:'),
const SizedBox(height: 16,),
if (!loading) Text('${"Maximum number of downloads".tl}:'),
if (loading) Text("${"Current quantity".tl}: $currentCount"),
const SizedBox(
height: 16,
),
SizedBox(
height: 42,
width: 196,
child: NumberBox(
value: maxCount,
onChanged: (value) {
if(!loading) {
if (!loading) {
setState(() => maxCount = value ?? maxCount);
}
},
@@ -71,35 +79,39 @@ class _DownloadDialogState extends State<_DownloadDialog> {
largeChange: 30,
clearButton: false,
),
)
),
],
).paddingVertical(8),
),
actions: [
Button(child: Text("Cancel".tl), onPressed: () {
cancel = true;
context.pop();
}),
if(!loading)
Button(
child: Text("Cancel".tl),
onPressed: () {
cancel = true;
context.pop();
}),
if (!loading)
FilledButton(onPressed: load, child: Text("Continue".tl))
else
FilledButton(onPressed: (){}, child: const SizedBox(
height: 20,
width: 64,
child: Center(
child: SizedBox.square(
dimension: 18,
child: ProgressRing(
strokeWidth: 1.6,
FilledButton(
onPressed: () {},
child: const SizedBox(
height: 20,
width: 64,
child: Center(
child: SizedBox.square(
dimension: 18,
child: ProgressRing(
strokeWidth: 1.6,
),
),
),
),
),
))
))
],
);
}
void load() async{
void load() async {
setState(() {
loading = true;
});
@@ -109,17 +121,17 @@ class _DownloadDialogState extends State<_DownloadDialog> {
List<Illust> all = [];
String? nextUrl;
int retryCount = 0;
while(nextUrl != "end" && all.length < maxCount) {
if(nextUrl != null) {
while (nextUrl != "end" && all.length < maxCount) {
if (nextUrl != null) {
request = Network().getIllustsWithNextUrl(nextUrl);
}
var res = await request;
if(cancel || !mounted) {
if (cancel || !mounted) {
return;
}
if(res.error) {
if (res.error) {
retryCount++;
if(retryCount > 3) {
if (retryCount > 3) {
setState(() {
loading = false;
});
@@ -130,15 +142,17 @@ class _DownloadDialogState extends State<_DownloadDialog> {
continue;
}
all.addAll(res.data);
setState(() {
currentCount = all.length;
});
nextUrl = res.subData ?? "end";
}
int i = 0;
for(var illust in all) {
if(i > maxCount) return;
for (var illust in all) {
if (i > maxCount) break;
DownloadManager().addDownloadingTask(illust);
i++;
}
context.pop();
}
}

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,80 @@ 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;
if(minCrossAxisExtent == 0) {
count = 1;
itemWidth = width;
while(itemWidth > maxCrossAxisExtent) {
count++;
itemWidth = width / count;
}
return count;
}
final itemWidth = width / crossItems;
return itemWidth / itemHeight;
while (
!(itemWidth > minCrossAxisExtent && itemWidth < maxCrossAxisExtent)) {
count--;
itemWidth = width / count;
if (count == 1) {
return 1;
}
}
return count;
}
}
@override
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
return oldDelegate is! SliverGridDelegateWithFixedHeight ||
oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent ||
oldDelegate.minCrossAxisExtent != minCrossAxisExtent ||
oldDelegate.itemHeight != itemHeight;
}
}

View File

@@ -9,11 +9,17 @@ import '../network/network.dart';
import '../pages/illust_page.dart';
import 'md.dart';
typedef UpdateFavoriteFunc = void Function(bool v);
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;
static Map<String, UpdateFavoriteFunc> favoriteCallbacks = {};
@override
State<IllustWidget> createState() => _IllustWidgetState();
}
@@ -24,6 +30,22 @@ class _IllustWidgetState extends State<IllustWidget> {
final contextController = FlyoutController();
final contextAttachKey = GlobalKey();
@override
void initState() {
IllustWidget.favoriteCallbacks[widget.illust.id.toString()] = (v) {
setState(() {
widget.illust.isBookmarked = v;
});
};
super.initState();
}
@override
void dispose() {
IllustWidget.favoriteCallbacks.remove(widget.illust.id.toString());
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constrains) {
@@ -37,52 +59,58 @@ class _IllustWidgetState extends State<IllustWidget> {
height: height,
child: Stack(
children: [
Positioned.fill(child: Container(
Positioned.fill(
child: Container(
width: width,
height: height,
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
padding:
const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
child: Card(
padding: EdgeInsets.zero,
margin: EdgeInsets.zero,
child: GestureDetector(
onTap: (){
context.to(() => IllustPage(widget.illust, favoriteCallback: (v) {
setState(() {
widget.illust.isBookmarked = v;
});
},));
},
onTap: widget.onTap ??
() {
context.to(() => IllustPage(widget.illust));
},
onSecondaryTapUp: showMenu,
child: ClipRRect(
borderRadius: BorderRadius.circular(4.0),
child: AnimatedImage(
image: CachedImageProvider(widget.illust.images.first.medium),
image: CachedImageProvider(
widget.illust.images.first.medium),
fit: BoxFit.cover,
width: width-16.0,
height: height-16.0,
width: width - 16.0,
height: height - 16.0,
),
),
),
),
)),
if(widget.illust.images.length > 1)
if (widget.illust.images.length > 1)
Positioned(
top: 12,
left: 12,
child: Container(
width: 28,
height: 20,
decoration: BoxDecoration(
color: FluentTheme.of(context).cardColor,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: ColorScheme.of(context).outlineVariant, width: 0.6),
),
child: Center(
child: Text("${widget.illust.images.length}P",
style: const TextStyle(fontSize: 12),),
)),
width: 28,
height: 20,
decoration: BoxDecoration(
color: FluentTheme.of(context)
.micaBackgroundColor
.withOpacity(0.72),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: ColorScheme.of(context).outlineVariant,
width: 0.6),
),
child: Center(
child: Text(
"${widget.illust.images.length}P",
style: const TextStyle(fontSize: 12),
),
)),
),
if(widget.illust.isAi)
if (widget.illust.isAi)
Positioned(
bottom: 12,
left: 12,
@@ -90,16 +118,22 @@ class _IllustWidgetState extends State<IllustWidget> {
width: 28,
height: 20,
decoration: BoxDecoration(
color: ColorScheme.of(context).errorContainer.withOpacity(0.8),
color: ColorScheme.of(context)
.errorContainer
.withOpacity(0.8),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: ColorScheme.of(context).outlineVariant, width: 0.6),
border: Border.all(
color: ColorScheme.of(context).outlineVariant,
width: 0.6),
),
child: const Center(
child: Text("AI",
style: TextStyle(fontSize: 12),),
child: Text(
"AI",
style: TextStyle(fontSize: 12),
),
)),
),
if(widget.illust.isUgoira)
if (widget.illust.isUgoira)
Positioned(
bottom: 12,
left: 12,
@@ -107,16 +141,22 @@ class _IllustWidgetState extends State<IllustWidget> {
width: 28,
height: 20,
decoration: BoxDecoration(
color: ColorScheme.of(context).primaryContainer.withOpacity(0.8),
color: ColorScheme.of(context)
.primaryContainer
.withOpacity(0.8),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: ColorScheme.of(context).outlineVariant, width: 0.6),
border: Border.all(
color: ColorScheme.of(context).outlineVariant,
width: 0.6),
),
child: const Center(
child: Text("GIF",
style: TextStyle(fontSize: 12),),
child: Text(
"GIF",
style: TextStyle(fontSize: 12),
),
)),
),
if(widget.illust.isR18)
if (widget.illust.isR18)
Positioned(
bottom: 12,
right: 12,
@@ -126,14 +166,18 @@ class _IllustWidgetState extends State<IllustWidget> {
decoration: BoxDecoration(
color: ColorScheme.of(context).errorContainer,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: ColorScheme.of(context).outlineVariant, width: 0.6),
border: Border.all(
color: ColorScheme.of(context).outlineVariant,
width: 0.6),
),
child: const Center(
child: Text("R18",
style: TextStyle(fontSize: 12),),
child: Text(
"R18",
style: TextStyle(fontSize: 12),
),
)),
),
if(widget.illust.isR18G)
if (widget.illust.isR18G)
Positioned(
bottom: 12,
right: 12,
@@ -143,11 +187,15 @@ class _IllustWidgetState extends State<IllustWidget> {
decoration: BoxDecoration(
color: ColorScheme.of(context).errorContainer,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: ColorScheme.of(context).outlineVariant, width: 0.6),
border: Border.all(
color: ColorScheme.of(context).outlineVariant,
width: 0.6),
),
child: const Center(
child: Text("R18G",
style: TextStyle(fontSize: 12),),
child: Text(
"R18G",
style: TextStyle(fontSize: 12),
),
)),
),
Positioned(
@@ -178,35 +226,38 @@ class _IllustWidgetState extends State<IllustWidget> {
builder: (context) {
return MenuFlyout(
items: [
MenuFlyoutItem(text: Text("View".tl), onPressed: (){
context.to(() => IllustPage(widget.illust, favoriteCallback: (v) {
setState(() {
widget.illust.isBookmarked = v;
});
},));
}),
MenuFlyoutItem(text: Text("Private Favorite".tl), onPressed: (){
favorite("private");
}),
MenuFlyoutItem(text: Text("Download".tl), onPressed: (){
context.showToast(message: "Added");
DownloadManager().addDownloadingTask(widget.illust);
}),
MenuFlyoutItem(
text: Text("View".tl),
onPressed: () {
context.to(() => IllustPage(widget.illust));
}),
MenuFlyoutItem(
text: Text("Private Favorite".tl),
onPressed: () {
favorite("private");
}),
MenuFlyoutItem(
text: Text("Download".tl),
onPressed: () {
context.showToast(message: "Added");
DownloadManager().addDownloadingTask(widget.illust);
}),
],
);
},
);
}
void favorite([String type = "public"]) async{
if(isBookmarking) return;
void favorite([String type = "public"]) async {
if (isBookmarking) return;
setState(() {
isBookmarking = true;
});
var method = widget.illust.isBookmarked ? "delete" : "add";
var res = await Network().addBookmark(widget.illust.id.toString(), method, type);
if(res.error) {
if(mounted) {
var res =
await Network().addBookmark(widget.illust.id.toString(), method, type);
if (res.error) {
if (mounted) {
context.showToast(message: "Network Error");
}
} else {
@@ -219,16 +270,18 @@ class _IllustWidgetState extends State<IllustWidget> {
Widget buildButton() {
Widget child;
if(isBookmarking) {
if (isBookmarking) {
child = const SizedBox(
width: 14,
height: 14,
child: ProgressRing(strokeWidth: 1.6,),
child: ProgressRing(
strokeWidth: 1.6,
),
);
} else if(widget.illust.isBookmarked) {
} else if (widget.illust.isBookmarked) {
child = Icon(
MdIcons.favorite,
color: ColorScheme.of(context).error,
color: Colors.red,
size: 22,
);
} else {

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pixes/foundation/app.dart';
typedef KeyEventHandler = void Function(LogicalKeyboardKey key);
class KeyEventListener extends StatefulWidget {
const KeyEventListener({required this.child, super.key});
final Widget child;
static KeyEventListenerState? of(BuildContext context) {
return context.findAncestorStateOfType<KeyEventListenerState>();
}
@override
State<KeyEventListener> createState() => KeyEventListenerState();
}
class KeyEventListenerState extends State<KeyEventListener> {
final focusNode = FocusNode();
final List<KeyEventHandler> _handlers = [];
void addHandler(KeyEventHandler handler) {
_handlers.add(handler);
}
void removeHandler(KeyEventHandler handler) {
_handlers.remove(handler);
}
void removeAll() {
_handlers.clear();
}
@override
Widget build(BuildContext context) {
return Focus(
focusNode: focusNode,
autofocus: true,
onKeyEvent: (node, event) {
if (event is! KeyUpEvent) return KeyEventResult.ignored;
if (event.logicalKey == LogicalKeyboardKey.escape) {
if (App.rootNavigatorKey.currentState?.canPop() ?? false) {
App.rootNavigatorKey.currentState?.pop();
}
if (App.mainNavigatorKey?.currentState?.canPop() ?? false) {
App.mainNavigatorKey?.currentState?.pop();
}
return KeyEventResult.handled;
}
for (var handler in _handlers) {
handler(event.logicalKey);
}
return KeyEventResult.handled;
},
child: widget.child,
);
}
}

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 buildContent(BuildContext context, final List<S> data);
Widget? buildFrame(BuildContext context, Widget child) => null;
Widget buildContent(BuildContext context, 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

@@ -328,11 +328,15 @@ class SideBarRoute<T> extends PopupRoute<T> {
bottom: 0,
child: Container(
decoration: BoxDecoration(
color: FluentTheme.of(context).micaBackgroundColor.withOpacity(0.98),
borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), bottomLeft: Radius.circular(4))
),
constraints: BoxConstraints(maxWidth: min(_kSideBarWidth,
MediaQuery.of(context).size.width)),
color: FluentTheme.of(context)
.micaBackgroundColor
.withOpacity(0.98),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
bottomLeft: Radius.circular(4))),
constraints: BoxConstraints(
maxWidth:
min(_kSideBarWidth, MediaQuery.of(context).size.width)),
width: double.infinity,
child: child,
),

View File

@@ -1,34 +1,57 @@
import 'dart:math';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:pixes/components/animated_image.dart';
import 'package:pixes/foundation/app.dart';
import 'package:pixes/foundation/image_provider.dart';
import 'package:pixes/pages/illust_page.dart';
import 'package:pixes/pages/user_info_page.dart';
import 'package:pixes/utils/translation.dart';
import '../network/network.dart';
import 'md.dart';
typedef UpdateFollowCallback = void Function(bool isFollowed);
class UserPreviewWidget extends StatefulWidget {
const UserPreviewWidget(this.user, {super.key});
final UserPreview user;
static Map<String, UpdateFollowCallback> followCallbacks = {};
@override
State<UserPreviewWidget> createState() => _UserPreviewWidgetState();
}
class _UserPreviewWidgetState extends State<UserPreviewWidget> {
@override
void initState() {
UserPreviewWidget.followCallbacks[widget.user.id.toString()] = (v) {
setState(() {
widget.user.isFollowed = v;
});
};
super.initState();
}
@override
void dispose() {
UserPreviewWidget.followCallbacks.remove(widget.user.id.toString());
super.dispose();
}
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 {
@@ -37,71 +60,129 @@ class _UserPreviewWidgetState extends State<UserPreviewWidget> {
setState(() {
isFollowing = false;
});
UserInfoPage.followCallbacks[widget.user.id.toString()]
?.call(widget.user.isFollowed);
IllustPage.updateFollow(widget.user.id.toString(), widget.user.isFollowed);
}
@override
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

@@ -1,6 +1,7 @@
import 'dart:io';
import 'dart:ui';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:path_provider/path_provider.dart';
@@ -11,11 +12,13 @@ export "state_controller.dart";
export "navigation.dart";
class _App {
final version = "1.0.2";
final version = "1.0.6";
bool get isAndroid => Platform.isAndroid;
bool get isIOS => Platform.isIOS;
bool get isWindows => Platform.isWindows;
int? _windowsVersion;
int get windowsVersion => _windowsVersion!;
bool get isLinux => Platform.isLinux;
bool get isMacOS => Platform.isMacOS;
bool get isDesktop =>
@@ -23,16 +26,17 @@ class _App {
bool get isMobile => Platform.isAndroid || Platform.isIOS;
Locale get locale {
if(appdata.settings["language"] != "System"){
return switch(appdata.settings["language"]){
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"),
};
}
Locale deviceLocale = PlatformDispatcher.instance.locale;
if (deviceLocale.languageCode == "zh" && deviceLocale.scriptCode == "Hant") {
if (deviceLocale.languageCode == "zh" &&
deviceLocale.scriptCode == "Hant") {
deviceLocale = const Locale("zh", "TW");
}
return deviceLocale;
@@ -41,12 +45,29 @@ class _App {
late String dataPath;
late String cachePath;
init() async{
init() async {
cachePath = (await getApplicationCacheDirectory()).path;
dataPath = (await getApplicationSupportDirectory()).path;
if (App.isWindows) {
final deviceInfoPlugin = DeviceInfoPlugin();
final deviceInfo = await deviceInfoPlugin.windowsInfo;
if (deviceInfo.majorVersion <= 6) {
if (deviceInfo.minorVersion < 2) {
_windowsVersion = 7;
} else {
_windowsVersion = 8;
}
} else if (deviceInfo.buildNumber < 22000) {
_windowsVersion = 10;
} else {
_windowsVersion = 11;
}
}
}
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,17 +1,24 @@
import "dart:ui";
import "package:dynamic_color/dynamic_color.dart";
import "package:fluent_ui/fluent_ui.dart";
import "package:flutter/foundation.dart";
import "package:flutter/material.dart" as md;
import "package:flutter/services.dart";
import "package:flutter_acrylic/flutter_acrylic.dart" as flutter_acrylic;
import "package:pixes/appdata.dart";
import "package:pixes/components/keyboard.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";
import "package:pixes/network/app_dio.dart";
import "package:pixes/pages/main_page.dart";
import "package:pixes/utils/app_links.dart";
import "package:pixes/utils/loop.dart";
import "package:pixes/utils/translation.dart";
import "package:pixes/utils/window.dart";
import "package:window_manager/window_manager.dart";
import 'package:system_theme/system_theme.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -19,16 +26,15 @@ 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 flutter_acrylic.Window.initialize();
if (App.isWindows) {
await flutter_acrylic.Window.hideWindowControls();
}
await WindowManager.instance.ensureInitialized();
windowManager.waitUntilReadyToShow().then((_) async {
await windowManager.setTitleBarStyle(
@@ -36,10 +42,13 @@ void main() async {
windowButtonVisibility: false,
);
await windowManager.setMinimumSize(const Size(500, 600));
var placement = await WindowPlacement.loadFromFile();
await placement.applyToWindow();
await windowManager.show();
await windowManager.setSkipTaskbar(false);
Loop.register(WindowPlacement.loop);
});
}
Loop.start();
runApp(const MyApp());
}
@@ -49,15 +58,17 @@ class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
var windowsFont = kDebugMode ? "微软雅黑" : "font";
return StateBuilder<SimpleController>(
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 +79,111 @@ 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 ? windowsFont : 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)
}),
focusTheme: const FocusThemeData(
primaryBorder: BorderSide.none,
secondaryBorder: BorderSide.none,
)),
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);
}),
Widget widget = MdTheme(
data: MdThemeData.from(
colorScheme: colorScheme, useMaterial3: true),
child: DefaultTextStyle.merge(
style: TextStyle(
fontFamily: App.isWindows ? 'font' : null,
),
child: OverlayWidget(child),
),
);
if (App.isWindows) {
if (App.windowsVersion == 11) {
flutter_acrylic.Window.setEffect(
effect: flutter_acrylic.WindowEffect.mica,
dark: FluentTheme.of(context).brightness ==
Brightness.dark);
widget = NavigationPaneTheme(
data: const NavigationPaneThemeData(
backgroundColor: Colors.transparent,
),
child: widget,
);
} else if (App.windowsVersion == 10) {
flutter_acrylic.Window.setEffect(
effect: flutter_acrylic.WindowEffect.acrylic,
dark: FluentTheme.of(context).brightness ==
Brightness.dark);
widget = NavigationPaneTheme(
data: NavigationPaneThemeData(
backgroundColor: FluentTheme.of(context)
.micaBackgroundColor
.withOpacity(0.72),
),
child: widget,
);
}
}
return KeyEventListener(child: widget);
});
},
),
);
});
}
}
/// 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,28 @@ class DownloadManager {
i++;
}
}
Future<void> checkAndClearInvalidItems() async{
var illusts = listAll();
var shouldDelete = <DownloadedIllust>[];
for(var item in illusts) {
var paths = getImagePaths(item.illustId);
var validPaths = <String>[];
for(var path in paths) {
if(await File(path).exists()) {
validPaths.add(path);
}
}
if(validPaths.isEmpty) {
shouldDelete.add(item);
}
}
for(var item in shouldDelete) {
delete(item);
}
}
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;
@@ -182,6 +181,7 @@ class Illust {
bool isBookmarked;
final bool isAi;
final bool isUgoira;
final bool isBlocked;
bool get isR18 => tags.contains(const Tag("R-18", null));
@@ -211,7 +211,7 @@ class Illust {
}()),
caption = json['caption'],
restrict = json['restrict'],
author = IllustAuthor(
author = Author(
json['user']['id'],
json['user']['name'],
json['user']['account'],
@@ -220,7 +220,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'],
@@ -228,7 +228,8 @@ class Illust {
totalBookmarks = json['total_bookmarks'],
isBookmarked = json['is_bookmarked'],
isAi = json['illust_ai_type'] == 2,
isUgoira = json['type'] == "ugoira";
isUgoira = json['type'] == "ugoira",
isBlocked = json['is_muted'] ?? false;
}
class TrendingTag {
@@ -250,11 +251,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 +274,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 +291,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 +334,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 +370,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 +406,7 @@ class UserPreview {
}
}
*/
class Comment{
class Comment {
final String id;
final String comment;
final DateTime date;
@@ -419,6 +423,129 @@ 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;
}
class MuteList {
List<Tag> tags;
List<Author> authors;
int limit;
MuteList(this.tags, this.authors, this.limit);
static MuteList? fromJson(Map<String, dynamic> data) {
return MuteList(
(data['muted_tags'] as List)
.map((e) => Tag(e['tag'], e['tag_translation']))
.toList(),
(data['muted_users'] as List)
.map((e) => Author(e['user_id'], e['user_name'], e['user_account'],
e['user_profile_image_urls']['medium'], false))
.toList(),
data['mute_limit_count']);
}
}

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,52 @@ 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);
}
}
String? encodeFormData(Map<String, dynamic>? data) {
if (data == null) return null;
StringBuffer buffer = StringBuffer();
data.forEach((key, value) {
if (value is List) {
for (var element in value) {
buffer.write("$key[]=$element&");
}
} else {
buffer.write("$key=$value&");
}
});
return buffer.toString();
}
Future<Res<Map<String, dynamic>>> apiPost(String path,
{Map<String, dynamic>? query, Map<String, dynamic>? data}) async {
try {
@@ -167,7 +214,7 @@ class Network {
}
final res = await dio.post<Map<String, dynamic>>(path,
queryParameters: query,
data: data,
data: encodeFormData(data),
options: Options(
headers: headers,
validateStatus: (status) => true,
@@ -208,13 +255,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 +282,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 +360,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 +371,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 +388,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 +399,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 +431,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 +442,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 +457,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 +486,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,38 +499,78 @@ 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);
}
}
Future<List<Tag>> getMutedTags() async {
Future<Res<MuteList>> getMuteList() 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"]))
.toList();
return Res(MuteList.fromJson(res.data));
} else {
return [];
return Res.error(res.errorMessage);
}
}
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>> editMute(List<String> addTags, List<String> addUsers,
List<String> deleteTags, List<String> deleteUsers) async {
var res = await apiPost("/v1/mute/edit",
data: {
"add_tags": addTags,
"add_user_ids": addUsers,
"delete_tags": deleteTags,
"delete_user_ids": deleteUsers
}..removeWhere((key, value) => value.isEmpty));
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,10 +4,9 @@ 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';
import 'package:pixes/components/page_route.dart';
import 'package:pixes/components/title_bar.dart';
import 'package:pixes/foundation/app.dart';
import 'package:pixes/network/download.dart';
@@ -32,7 +31,8 @@ class _DownloadedPageState extends State<DownloadedPage> {
void loadData() {
illusts = DownloadManager().listAll();
flyoutControllers = List.generate(illusts.length, (index) => FlyoutController());
flyoutControllers =
List.generate(illusts.length, (index) => FlyoutController());
}
@override
@@ -45,7 +45,18 @@ class _DownloadedPageState extends State<DownloadedPage> {
Widget build(BuildContext context) {
return Column(
children: [
TitleBar(title: "Downloaded".tl),
TitleBar(
title: "Downloaded".tl,
action: Button(
child: Text("Delete Invalid Items".tl),
onPressed: () async {
await DownloadManager().checkAndClearInvalidItems();
setState(() {
loadData();
});
},
),
),
Expanded(
child: buildBody(),
),
@@ -55,133 +66,134 @@ class _DownloadedPageState extends State<DownloadedPage> {
Widget buildBody() {
return GridViewWithFixedItemHeight(
itemCount: illusts.length,
itemHeight: 152,
maxCrossAxisExtent: 742,
builder: (context, index) {
var image = DownloadManager().getImage(illusts[index].illustId, 0);
return Card(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Row(
children: [
Container(
width: 96,
height: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: ColorScheme.of(context).secondaryContainer
),
clipBehavior: Clip.antiAlias,
child: image == null ? null : Image(
image: FileImage(image),
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
illusts[index].title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
illusts[index].author,
style: const TextStyle(
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
"${illusts[index].imageCount}P",
style: const TextStyle(
fontSize: 12,
),
),
const Spacer(),
Row(
children: [
const Spacer(),
Button(
child: Text("View".tl).fixWidth(42),
onPressed: () {
var images = DownloadManager().getImagePaths(
illusts[index].illustId);
if(images.isEmpty) {
showToast(context, message: "No images found".tl);
return;
}
App.rootNavigatorKey.currentState?.push(
AppPageRoute(builder: (context) {
return _DownloadedIllustViewPage(images);
}));
},
),
const SizedBox(width: 6),
Button(
child: Text("Info".tl).fixWidth(42),
onPressed: () {
context.to(() => IllustPageWithId(
illusts[index].illustId.toString()));
},
),
const SizedBox(width: 6),
FlyoutTarget(
controller: flyoutControllers[index],
child: Button(
child: Text("Delete".tl).fixWidth(42),
onPressed: () {
flyoutControllers[index].showFlyout(
navigatorKey: App.rootNavigatorKey.currentState,
builder: (context) {
return FlyoutContent(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Are you sure you want to delete?'.tl,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12.0),
Button(
onPressed: () {
Flyout.of(context).close();
DownloadManager().delete(illusts[index]);
setState(() {
illusts.removeAt(index);
flyoutControllers.removeAt(index);
});
},
child: Text('Yes'.tl),
),
],
),
);
});
},
itemCount: illusts.length,
itemHeight: 152,
maxCrossAxisExtent: 742,
builder: (context, index) {
var image = DownloadManager().getImage(illusts[index].illustId, 0);
return Card(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
App.rootNavigatorKey.currentContext?.to(() =>
_DownloadedIllustViewPage(DownloadManager()
.getImagePaths(illusts[index].illustId)));
},
child: Row(
children: [
Container(
width: 96,
height: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: ColorScheme.of(context).secondaryContainer),
clipBehavior: Clip.antiAlias,
child: image == null
? null
: AnimatedImage(
image: FileImage(image),
fit: BoxFit.cover,
width: 96,
height: double.infinity,
filterQuality: FilterQuality.medium,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
illusts[index].title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
illusts[index].author,
style: const TextStyle(
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
"${illusts[index].imageCount}P",
style: const TextStyle(
fontSize: 12,
),
),
const Spacer(),
Row(
children: [
const Spacer(),
Button(
child: Text("Info".tl).fixWidth(42),
onPressed: () {
context.to(() => IllustPageWithId(
illusts[index].illustId.toString()));
},
),
const SizedBox(width: 6),
FlyoutTarget(
controller: flyoutControllers[index],
child: Button(
child: Text("Delete".tl).fixWidth(42),
onPressed: () {
flyoutControllers[index].showFlyout(
navigatorKey:
App.rootNavigatorKey.currentState,
builder: (context) {
return FlyoutContent(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'Are you sure you want to delete?'
.tl,
style: const TextStyle(
fontWeight:
FontWeight.bold),
),
const SizedBox(height: 12.0),
Button(
onPressed: () {
Flyout.of(context).close();
DownloadManager()
.delete(illusts[index]);
setState(() {
illusts.removeAt(index);
flyoutControllers
.removeAt(index);
});
},
child: Text('Yes'.tl),
),
],
),
);
});
},
),
),
],
),
],
),
],
),
),
],
),
],
),
);
}
).paddingHorizontal(8);
),
);
}).paddingHorizontal(8);
}
}
@@ -191,10 +203,12 @@ class _DownloadedIllustViewPage extends StatefulWidget {
final List<String> imagePaths;
@override
State<_DownloadedIllustViewPage> createState() => _DownloadedIllustViewPageState();
State<_DownloadedIllustViewPage> createState() =>
_DownloadedIllustViewPageState();
}
class _DownloadedIllustViewPageState extends State<_DownloadedIllustViewPage> with WindowListener{
class _DownloadedIllustViewPageState extends State<_DownloadedIllustViewPage>
with WindowListener {
int windowButtonKey = 0;
@override
@@ -231,38 +245,47 @@ class _DownloadedIllustViewPageState extends State<_DownloadedIllustViewPage> wi
Future<File?> getFile() async {
var file = File(widget.imagePaths[currentPage]);
if(file.existsSync()) {
if (file.existsSync()) {
return file;
}
return null;
}
void showMenu() {
menuController.showFlyout(builder: (context) => MenuFlyout(
items: [
MenuFlyoutItem(text: Text("Save to".tl), onPressed: () async{
var file = await getFile();
if(file != null){
saveFile(file);
}
}),
MenuFlyoutItem(text: Text("Share".tl), onPressed: () async{
var file = await getFile();
if(file != null){
var ext = file.path.split('.').last;
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(file.path, mimeType: mediaType, name: file.path.split('/').last)]);
}
}),
],
));
menuController.showFlyout(
builder: (context) => MenuFlyout(
items: [
MenuFlyoutItem(
text: Text("Save to".tl),
onPressed: () async {
var file = await getFile();
if (file != null) {
saveFile(file);
}
}),
MenuFlyoutItem(
text: Text("Share".tl),
onPressed: () async {
var file = await getFile();
if (file != null) {
var ext = file.path.split('.').last;
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(file.path,
mimeType: mediaType,
name: file.path.split('/').last)
]);
}
}),
],
));
}
@override
@@ -272,12 +295,13 @@ class _DownloadedIllustViewPageState extends State<_DownloadedIllustViewPage> wi
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.imagePaths.length - 1) {
if (event.scrollDelta.dy > 0 &&
controller.page!.toInt() < widget.imagePaths.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);
}
}
@@ -287,11 +311,11 @@ class _DownloadedIllustViewPageState extends State<_DownloadedIllustViewPage> wi
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.imagePaths.length,
builder: (context, index) {
return PhotoViewGalleryPageOptions(
@@ -312,17 +336,22 @@ class _DownloadedIllustViewPageState extends State<_DownloadedIllustViewPage> wi
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),
),
],
),
),
@@ -331,7 +360,10 @@ class _DownloadedIllustViewPageState extends State<_DownloadedIllustViewPage> wi
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),
@@ -374,26 +406,25 @@ class _DownloadedIllustViewPageState extends State<_DownloadedIllustViewPage> wi
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),
);
}
}

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

@@ -2,6 +2,7 @@ import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:pixes/components/title_bar.dart';
import 'package:pixes/foundation/app.dart';
import 'package:pixes/utils/block.dart';
import 'package:pixes/utils/translation.dart';
import '../components/batch_download.dart';
@@ -9,6 +10,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});
@@ -26,7 +28,10 @@ class _FollowingArtworksPageState extends State<FollowingArtworksPage> {
children: [
buildTab(),
Expanded(
child: _OneFollowingPage(restrict, key: Key(restrict),),
child: _OneFollowingPage(
restrict,
key: Key(restrict),
),
)
],
);
@@ -37,8 +42,11 @@ class _FollowingArtworksPageState extends State<FollowingArtworksPage> {
title: "Following".tl,
action: Row(
children: [
BatchDownloadButton(request: () => Network().getFollowingArtworks(restrict)),
const SizedBox(width: 8,),
BatchDownloadButton(
request: () => Network().getFollowingArtworks(restrict)),
const SizedBox(
width: 8,
),
SegmentedButton(
options: [
SegmentedButtonOption("all", "All".tl),
@@ -46,7 +54,7 @@ class _FollowingArtworksPageState extends State<FollowingArtworksPage> {
SegmentedButtonOption("private", "Private".tl),
],
onPressed: (key) {
if(key != restrict) {
if (key != restrict) {
setState(() {
restrict = key;
});
@@ -69,22 +77,27 @@ class _OneFollowingPage extends StatefulWidget {
State<_OneFollowingPage> createState() => _OneFollowingPageState();
}
class _OneFollowingPageState extends MultiPageLoadingState<_OneFollowingPage, Illust> {
class _OneFollowingPageState
extends MultiPageLoadingState<_OneFollowingPage, Illust> {
@override
Widget buildContent(BuildContext context, final List<Illust> data) {
return LayoutBuilder(builder: (context, constrains){
Widget buildContent(BuildContext context, List<Illust> data) {
checkIllusts(data);
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: nextUrl));
});
},
);
});
@@ -93,16 +106,15 @@ class _OneFollowingPageState extends MultiPageLoadingState<_OneFollowingPage, Il
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 = await Network().getFollowingArtworks(widget.restrict, nextUrl);
if(!res.error) {
if (!res.error) {
nextUrl = res.subData;
nextUrl ??= "end";
}
return res;
}
}

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,47 +181,57 @@ 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),
),
],
),
),
),
Positioned(
left: 0,
top: height / 2 - 9,
child: IconButton(
icon: const Icon(FluentIcons.chevron_left, size: 18,),
onPressed: () {
controller.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
},
).paddingAll(8),
),
Positioned(
right: 0,
top: height / 2 - 9,
child: IconButton(
icon: const Icon(FluentIcons.chevron_right, size: 18),
onPressed: () {
controller.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
},
).paddingAll(8),
),
if (currentPage != 0)
Positioned(
left: 0,
top: height / 2 - 9,
child: IconButton(
icon: const Icon(
FluentIcons.chevron_left,
size: 18,
),
onPressed: () {
controller.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
},
).paddingAll(8),
),
if (currentPage != widget.urls.length - 1)
Positioned(
right: 0,
top: height / 2 - 9,
child: IconButton(
icon: const Icon(FluentIcons.chevron_right, size: 18),
onPressed: () {
controller.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
},
).paddingAll(8),
),
Positioned(
left: 12,
bottom: 8,
@@ -239,25 +253,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: Row(
children: [
const Icon(
MdIcons.menu,
size: 18,
),
const SizedBox(
width: 8,
),
Text('Actions'.tl),
],
))
: 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,12 +11,16 @@ 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";
import "package:pixes/pages/search_page.dart";
import "package:pixes/pages/settings_page.dart";
import "package:pixes/pages/user_info_page.dart";
import "package:pixes/utils/loop.dart";
import "package:pixes/utils/mouse_listener.dart";
import "package:pixes/utils/translation.dart";
import "package:window_manager/window_manager.dart";
@@ -28,6 +31,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 +73,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 +111,117 @@ 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}/${"Manga".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 +234,9 @@ class _MainPageState extends State<MainPage> with WindowListener {
() => const FollowingArtworksPage(),
() => const HistoryPage(),
() => const RankingPage(),
() => const NovelRecommendationPage(),
() => const NovelBookmarksPage(),
() => const NovelRankingPage(),
() => const SettingsPage(),
];
@@ -194,39 +255,64 @@ 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: SizedBox(
height: double.infinity,
child: DragToMoveArea(
child: Align(
alignment: Alignment.centerLeft,
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,
);
}
}
@@ -250,28 +336,22 @@ class _BackButtonState extends State<_BackButton> {
@override
void initState() {
enabled = navigatorKey.currentState?.canPop() == true;
loop();
Loop.register(loop);
super.initState();
}
void loop() {
timer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
if(!mounted) {
timer.cancel();
} else {
bool enabled = navigatorKey.currentState?.canPop() == true;
if(enabled != this.enabled) {
setState(() {
this.enabled = enabled;
});
}
}
});
bool enabled = navigatorKey.currentState?.canPop() == true;
if (enabled != this.enabled) {
setState(() {
this.enabled = enabled;
});
}
}
@override
void dispose() {
timer?.cancel();
Loop.remove(loop);
super.dispose();
}
@@ -301,18 +381,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 +547,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 +571,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,264 @@
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".tl, () {
if (!isShowingSettings) {
_NovelReadingSettings.show(context, () {
setState(() {});
}).then((value) {
isShowingSettings = false;
});
isShowingSettings = true;
} else {
Navigator.of(context).pop();
}
});
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 Future show(BuildContext context, void Function() callback) {
return 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

@@ -1,6 +1,7 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:pixes/foundation/app.dart';
import 'package:pixes/utils/block.dart';
import 'package:pixes/utils/translation.dart';
import '../components/batch_download.dart';
@@ -8,6 +9,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});
@@ -85,6 +87,7 @@ class _OneRankingPage extends StatefulWidget {
class _OneRankingPageState extends MultiPageLoadingState<_OneRankingPage, Illust> {
@override
Widget buildContent(BuildContext context, final List<Illust> data) {
checkIllusts(data);
return LayoutBuilder(builder: (context, constrains){
return MasonryGridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8)
@@ -97,7 +100,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,8 @@ 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/block.dart';
import 'package:pixes/utils/translation.dart';
import '../components/grid.dart';
@@ -28,8 +30,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 +50,7 @@ class _RecommendationPageState extends State<RecommendationPage> {
SegmentedButtonOption(2, "Users".tl),
],
onPressed: (key) {
if(key != type) {
if (key != type) {
setState(() {
type = key;
});
@@ -57,32 +62,43 @@ 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){
checkIllusts(data);
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 +116,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

@@ -2,13 +2,17 @@ import 'package:fluent_ui/fluent_ui.dart';
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/app_links.dart';
import 'package:pixes/utils/block.dart';
import 'package:pixes/utils/ext.dart';
import 'package:pixes/utils/translation.dart';
import '../components/animated_image.dart';
@@ -39,11 +43,17 @@ class _SearchPageState extends State<SearchPage> {
];
void search() {
switch(searchType) {
if (text.isURL && handleLink(Uri.parse(text))) {
return;
} else if ("https://$text".isURL &&
handleLink(Uri.parse("https://$text"))) {
return;
}
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 +61,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 +72,9 @@ class _SearchPageState extends State<SearchPage> {
content: Column(
children: [
buildSearchBar(),
const SizedBox(height: 8,),
const SizedBox(
height: 8,
),
const Expanded(
child: _TrendingTagsView(),
)
@@ -88,7 +100,8 @@ class _SearchPageState extends State<SearchPage> {
children: [
Expanded(
child: TextBox(
placeholder: searchTypes[searchType].tl,
placeholder:
'${searchTypes[searchType].tl} / ${"Open link".tl}',
onChanged: (s) => text = s,
onSubmitted: (s) => search(),
foregroundDecoration: BoxDecoration(
@@ -123,14 +136,15 @@ class _SearchPageState extends State<SearchPage> {
),
onPressed: () {
optionController.showFlyout(
navigatorKey: App.rootNavigatorKey.currentState,
placementMode: FlyoutPlacementMode.bottomCenter,
builder: buildSearchOption,
);
placementMode: FlyoutPlacementMode.bottomCenter,
builder: buildSearchOption,
barrierColor: Colors.transparent);
},
),
),
const SizedBox(width: 4,),
const SizedBox(
width: 4,
),
Button(
child: const SizedBox(
height: 42,
@@ -139,7 +153,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 +185,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 +206,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 +223,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 +244,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 +270,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 +288,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 +438,6 @@ class _SearchSettingsState extends State<SearchSettings> {
}
}
class SearchResultPage extends StatefulWidget {
const SearchResultPage(this.keyword, {super.key});
@@ -398,7 +447,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 +456,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();
}
@@ -414,6 +464,7 @@ class _SearchResultPageState extends MultiPageLoadingState<SearchResultPage, Ill
@override
Widget buildContent(BuildContext context, final List<Illust> data) {
checkIllusts(data);
return CustomScrollView(
slivers: [
buildSearchBar(),
@@ -423,15 +474,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 +534,9 @@ class _SearchResultPageState extends MultiPageLoadingState<SearchResultPage, Ill
),
),
),
const SizedBox(width: 4,),
const SizedBox(
width: 4,
),
Button(
child: const SizedBox(
height: 42,
@@ -483,12 +544,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 +569,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 +593,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 +625,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 +638,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

@@ -1,7 +1,9 @@
import 'dart:io';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/services.dart';
import 'package:pixes/appdata.dart';
import 'package:pixes/components/keyboard.dart';
import 'package:pixes/components/md.dart';
import 'package:pixes/components/message.dart';
import 'package:pixes/components/page_route.dart';
@@ -131,16 +133,16 @@ class _SettingsPageState extends State<SettingsPage> {
return;
}
context.to(() => _SetSingleFieldPage(
"Download Path".tl,
"downloadPath",
check: (text) {
if(!Directory(text).havePermission()) {
return "No permission".tl;
} else {
return null;
}
},
));
"Download Path".tl,
"downloadPath",
check: (text) {
if (!Directory(text).havePermission()) {
return "No permission".tl;
} else {
return null;
}
},
));
}),
),
buildItem(
@@ -182,23 +184,30 @@ class _SettingsPageState extends State<SettingsPage> {
buildItem(
title: "Github",
action: IconButton(
icon: const Icon(MdIcons.open_in_new, size: 18,),
icon: const Icon(
MdIcons.open_in_new,
size: 18,
),
onPressed: () =>
launchUrlString("https://github.com/wgh136/pixes"),
)),
buildItem(
title: "Telegram",
action: IconButton(
icon: const Icon(MdIcons.open_in_new, size: 18,),
onPressed: () =>
launchUrlString("https://t.me/pica_group"),
icon: const Icon(
MdIcons.open_in_new,
size: 18,
),
onPressed: () => launchUrlString("https://t.me/pica_group"),
)),
buildItem(
title: "Logs",
action: IconButton(
icon: const Icon(MdIcons.open_in_new, size: 18,),
onPressed: () => context.to(() => const LogsPage())
)),
icon: const Icon(
MdIcons.open_in_new,
size: 18,
),
onPressed: () => context.to(() => const LogsPage()))),
],
),
);
@@ -214,11 +223,45 @@ class _SettingsPageState extends State<SettingsPage> {
child: Text("Edit".tl).fixWidth(64),
onPressed: () {
context.to(() => _SetSingleFieldPage(
"Http ${"Proxy".tl}",
"proxy",
));
"Http ${"Proxy".tl}",
"proxy",
));
},
)),
buildItem(
title: "Block(Account)".tl,
action: Button(
child: Text("Edit".tl).fixWidth(64),
onPressed: () {
launchUrlString("https://www.pixiv.net/setting_mute.php");
},
)),
buildItem(
title: "Block(Local)".tl,
action: Button(
child: Text("Edit".tl).fixWidth(64),
onPressed: () {
context.to(() => const _BlockTagsPage());
},
)),
buildItem(
title: "Shortcuts".tl,
action: Button(
child: Text("Edit".tl).fixWidth(64),
onPressed: () {
context.to(() => const ShortcutsSettings());
},
)),
buildItem(
title: "Display the original image on the details page".tl,
action: ToggleSwitch(
checked: appdata.settings['showOriginalImage'],
onChanged: (value) {
setState(() {
appdata.settings['showOriginalImage'] = value;
});
appdata.writeData();
})),
],
),
);
@@ -231,63 +274,77 @@ class _SettingsPageState extends State<SettingsPage> {
buildItem(
title: "Theme".tl,
action: 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();
}),
])),
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();
}),
])),
buildItem(
title: "Language".tl,
action: DropDownButton(
title: Text(appdata.settings["language"] ?? "System"),
items: [
MenuFlyoutItem(text: const Text("System"), onPressed: () {
setState(() {
appdata.settings["language"] = "System";
});
appdata.writeData();
StateController.findOrNull(tag: "MyApp")?.update();
}),
MenuFlyoutItem(text: const Text("English"), onPressed: () {
setState(() {
appdata.settings["language"] = "English";
});
appdata.writeData();
StateController.findOrNull(tag: "MyApp")?.update();
}),
MenuFlyoutItem(text: const Text("简体中文"), onPressed: () {
setState(() {
appdata.settings["language"] = "简体中文";
});
appdata.writeData();
StateController.findOrNull(tag: "MyApp")?.update();
}),
MenuFlyoutItem(text: const Text("繁體中文"), onPressed: () {
setState(() {
appdata.settings["language"] = "繁體中文";
});
appdata.writeData();
StateController.findOrNull(tag: "MyApp")?.update();
}),
MenuFlyoutItem(
text: const Text("System"),
onPressed: () {
setState(() {
appdata.settings["language"] = "System";
});
appdata.writeData();
StateController.findOrNull(tag: "MyApp")?.update();
}),
MenuFlyoutItem(
text: const Text("English"),
onPressed: () {
setState(() {
appdata.settings["language"] = "English";
});
appdata.writeData();
StateController.findOrNull(tag: "MyApp")?.update();
}),
MenuFlyoutItem(
text: const Text("简体中文"),
onPressed: () {
setState(() {
appdata.settings["language"] = "简体中文";
});
appdata.writeData();
StateController.findOrNull(tag: "MyApp")?.update();
}),
MenuFlyoutItem(
text: const Text("繁體中文"),
onPressed: () {
setState(() {
appdata.settings["language"] = "繁體中文";
});
appdata.writeData();
StateController.findOrNull(tag: "MyApp")?.update();
}),
])),
],
),
@@ -408,10 +465,174 @@ ${"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}
""";
}
class _BlockTagsPage extends StatefulWidget {
const _BlockTagsPage();
@override
State<_BlockTagsPage> createState() => __BlockTagsPageState();
}
class __BlockTagsPageState extends State<_BlockTagsPage> {
@override
Widget build(BuildContext context) {
return Column(
children: [
TitleBar(
title: "Block".tl,
action: FilledButton(
child: Text("Add".tl),
onPressed: () {
var controller = TextEditingController();
void finish(BuildContext context) {
var text = controller.text;
if (text.isNotEmpty &&
!(appdata.settings["blockTags"] as List).contains(text)) {
setState(() {
appdata.settings["blockTags"].add(text);
});
appdata.writeSettings();
}
context.pop();
}
showDialog(
context: context,
barrierDismissible: true,
builder: (context) {
return ContentDialog(
title: Text("Add".tl),
content: SizedBox(
width: 300,
height: 32,
child: TextBox(
controller: controller,
onSubmitted: (v) => finish(context),
),
),
actions: [
FilledButton(
child: Text("Submit".tl),
onPressed: () {
finish(context);
})
],
);
});
},
),
),
Expanded(
child: ListView.builder(
itemCount: appdata.settings["blockTags"].length,
itemBuilder: (context, index) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: EdgeInsets.zero,
child: ListTile(
title: Text(appdata.settings["blockTags"][index]),
trailing: Button(
child: Text("Delete".tl),
onPressed: () {
setState(() {
(appdata.settings["blockTags"] as List).removeAt(index);
});
appdata.writeSettings();
},
),
),
);
},
),
)
],
);
}
}
class ShortcutsSettings extends StatefulWidget {
const ShortcutsSettings({super.key});
@override
State<ShortcutsSettings> createState() => _ShortcutsSettingsState();
}
class _ShortcutsSettingsState extends State<ShortcutsSettings> {
int listening = -1;
KeyEventListenerState? listener;
@override
void initState() {
listener = KeyEventListener.of(context);
super.initState();
}
@override
void dispose() {
listener?.removeAll();
super.dispose();
}
final settings = <String>[
"Page down",
"Page up",
"Next work",
"Previous work",
"Add to favorites",
"Download",
"Follow the artist",
];
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(children: [
TitleBar(title: "Shortcuts".tl),
...settings.map((e) => buildItem(e, settings.indexOf(e)))
]),
);
}
Widget buildItem(String text, int index) {
var keyText = listening == index
? "Waiting..."
: LogicalKeyboardKey(appdata.settings['shortcuts'][index]).keyLabel;
return Card(
padding: EdgeInsets.zero,
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
child: ListTile(
title: Text(text.tl),
trailing: Button(
child: Text(keyText),
onPressed: () {
if (listening != -1) {
listener?.removeAll();
}
setState(() {
listening = index;
});
listener?.addHandler((key) {
if (key == LogicalKeyboardKey.escape) return;
setState(() {
appdata.settings['shortcuts'][index] = key.keyId;
listening = -1;
appdata.writeData();
});
Future.microtask(() => listener?.removeAll());
});
},
),
),
);
}
}

View File

@@ -3,44 +3,76 @@ 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';
import 'package:pixes/pages/following_users_page.dart';
import 'package:pixes/utils/block.dart';
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});
const UserInfoPage(this.id, {super.key});
final String id;
final void Function(bool)? followCallback;
static Map<String, UpdateFollowCallback> followCallbacks = {};
@override
State<UserInfoPage> createState() => _UserInfoPageState();
}
class _UserInfoPageState extends LoadingState<UserInfoPage, UserDetails> {
@override
void initState() {
UserInfoPage.followCallbacks[widget.id] = (v) {
if (data == null) return;
setState(() {
data!.isFollowed = v;
});
};
super.initState();
}
@override
void dispose() {
UserInfoPage.followCallbacks.remove(widget.id);
super.dispose();
}
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 +80,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,13 +105,15 @@ 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 {
data!.isFollowed = !data!.isFollowed;
widget.followCallback?.call(data!.isFollowed);
UserPreviewWidget.followCallbacks[data!.id.toString()]
?.call(data!.isFollowed);
IllustPage.updateFollow(data!.id.toString(), data!.isFollowed);
}
setState(() {
isFollowing = false;
@@ -96,7 +131,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 +141,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 +203,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 +286,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 +338,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 +368,9 @@ class _UserArtworksState extends MultiPageLoadingState<_UserArtworks, Illust> {
child: Row(
children: [
const Icon(FluentIcons.info),
const SizedBox(width: 4,),
const SizedBox(
width: 4,
),
Text(error)
],
),
@@ -264,17 +380,21 @@ class _UserArtworksState extends MultiPageLoadingState<_UserArtworks, Illust> {
}
@override
Widget buildContent(BuildContext context, final List<Illust> data) {
Widget buildContent(BuildContext context, List<Illust> data) {
checkIllusts(data);
return SliverMasonryGrid(
gridDelegate: const SliverSimpleGridDelegateWithMaxCrossAxisExtent(
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 +404,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 +421,136 @@ 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 MediaQuery.removePadding(
context: context, removeBottom: true, child: content);
}
@override
Future<Res<List<UserPreview>>> loadData() {
return Network().relatedUsers(widget.uid);
}
}

View File

@@ -3,6 +3,11 @@ 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/search_page.dart';
import 'package:pixes/pages/user_info_page.dart';
import 'package:pixes/utils/ext.dart';
import 'package:win32_registry/win32_registry.dart';
Future<void> _register(String scheme) async {
@@ -28,14 +33,78 @@ Future<void> _register(String scheme) async {
bool Function(Uri uri)? onLink;
bool _firstLink = true;
void handleLinks() async {
if (App.isWindows) {
await _register("pixiv");
}
AppLinks().uriLinkStream.listen((uri) {
AppLinks().uriLinkStream.listen((uri) async {
if (_firstLink) {
await Future.delayed(const Duration(milliseconds: 200));
}
_firstLink = false;
Log.info("App Link", uri.toString());
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;
} else if (uri.scheme == "https") {
var path = uri.toString().split("/").sublist(3);
switch (path[0]) {
case "users":
if (path.length >= 2) {
App.mainNavigatorKey?.currentContext?.to(() => UserInfoPage(path[1]));
return true;
}
case "novel":
if (path.length == 2) {
App.mainNavigatorKey?.currentContext
?.to(() => NovelPageWithId(path[1].nums));
return true;
}
case "artworks":
if (path.length == 2) {
App.mainNavigatorKey?.currentContext
?.to(() => IllustPageWithId(path[1]));
return true;
}
case "tags":
if (path.length == 2) {
App.mainNavigatorKey?.currentContext
?.to(() => SearchResultPage(path[1]));
return true;
}
}
}
return false;
}

23
lib/utils/block.dart Normal file
View File

@@ -0,0 +1,23 @@
import 'package:pixes/appdata.dart';
import 'package:pixes/network/models.dart';
List<Illust> checkIllusts(List<Illust> illusts) {
illusts.removeWhere((illust) {
if (illust.isBlocked) {
return true;
}
if (appdata.settings["blockTags"] == null) {
return false;
}
if (appdata.settings["blockTags"].contains("user:${illust.author.name}")) {
return true;
}
for (var tag in illust.tags) {
if ((appdata.settings["blockTags"] as List).contains(tag.name)) {
return true;
}
}
return false;
});
return illusts;
}

21
lib/utils/loop.dart Normal file
View File

@@ -0,0 +1,21 @@
import 'dart:async';
class Loop {
static final List<void Function()> _callbacks = [];
static void start() {
Timer.periodic(const Duration(milliseconds: 100), (timer) {
for(var func in _callbacks) {
func.call();
}
});
}
static void register(void Function() func) {
_callbacks.add(func);
}
static void remove(void Function() func) {
_callbacks.remove(func);
}
}

75
lib/utils/window.dart Normal file
View File

@@ -0,0 +1,75 @@
import 'dart:convert';
import 'dart:ui';
import 'dart:io';
import 'package:pixes/foundation/app.dart';
import 'package:window_manager/window_manager.dart';
class WindowPlacement {
final Rect rect;
final bool isMaximized;
const WindowPlacement(this.rect, this.isMaximized);
Future<void> applyToWindow() async {
await windowManager.setBounds(rect);
if(!validate(rect)){
await windowManager.center();
}
if (isMaximized) {
await windowManager.maximize();
}
}
Future<void> writeToFile() async {
var file = File("${App.dataPath}/window_placement");
await file.writeAsString(jsonEncode({
'width': rect.width,
'height': rect.height,
'x': rect.topLeft.dx,
'y': rect.topLeft.dy,
'isMaximized': isMaximized
}));
}
static Future<WindowPlacement> loadFromFile() async {
var file = File("${App.dataPath}/window_placement");
if (!file.existsSync()) {
return defaultPlacement;
}
var json = jsonDecode(await file.readAsString());
var rect =
Rect.fromLTWH(json['x'], json['y'], json['width'], json['height']);
return WindowPlacement(rect, json['isMaximized']);
}
static Future<WindowPlacement> get current async {
var rect = await windowManager.getBounds();
var isMaximized = await windowManager.isMaximized();
return WindowPlacement(rect, isMaximized);
}
static const defaultPlacement =
WindowPlacement(Rect.fromLTWH(10, 10, 900, 600), false);
static WindowPlacement cache = defaultPlacement;
static void loop() async {
var placement = await WindowPlacement.current;
if(!validate(placement.rect)){
return;
}
if (placement.rect != cache.rect ||
placement.isMaximized != cache.isMaximized) {
cache = placement;
await placement.writeToFile();
}
}
static bool validate(Rect rect){
return rect.topLeft.dx >= 0 && rect.topLeft.dy >= 0;
}
}

46
macos/Podfile Normal file
View File

@@ -0,0 +1,46 @@
platform :osx, '10.14.6'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_macos_podfile_setup
target 'Runner' do
use_frameworks!
use_modular_headers!
flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
# target 'RunnerTests' do
# inherit! :search_paths
# end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '10.14.6'
end
end
end

View File

@@ -461,7 +461,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MACOSX_DEPLOYMENT_TARGET = 10.14.6;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
@@ -543,7 +543,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MACOSX_DEPLOYMENT_TARGET = 10.14.6;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
@@ -593,7 +593,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MACOSX_DEPLOYMENT_TARGET = 10.14.6;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;

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

@@ -73,6 +73,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: eead12d1a1ed83d8283ab4c2f3fca23ac4082f29f25f29dff0f758f57d06ec91
url: "https://pub.dev"
source: hosted
version: "10.1.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64
url: "https://pub.dev"
source: hosted
version: "7.0.0"
dio:
dependency: "direct main"
description:
@@ -81,6 +97,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.4.3+1"
dynamic_color:
dependency: "direct main"
description:
name: dynamic_color
sha256: eae98052fa6e2826bdac3dd2e921c6ce2903be15c6b7f8b6d8a5d49b5086298d
url: "https://pub.dev"
source: hosted
version: "1.7.0"
fake_async:
dependency: transitive
description:
@@ -190,6 +214,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_acrylic:
dependency: "direct main"
description:
name: flutter_acrylic
sha256: a9a1fdf91ff1fb47858fd82507f57e255a132a5d355056694fdb9fd303633b18
url: "https://pub.dev"
source: hosted
version: "1.1.3"
flutter_file_dialog:
dependency: "direct main"
description:
@@ -293,6 +325,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
macos_window_utils:
dependency: transitive
description:
name: macos_window_utils
sha256: "230be594d26f6dee92c5a1544f4242d25138a5bfb9f185b27f14de3949ef0be8"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
matcher:
dependency: transitive
description:
@@ -515,22 +555,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.0"
system_theme:
dependency: "direct main"
description:
name: system_theme
sha256: "1f208db140a3d1e1eac2034b54920d95699c1534df576ced44b3312c5de3975f"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
system_theme_web:
dependency: transitive
description:
name: system_theme_web
sha256: "7566f5a928f6d28d7a60c97bea8a851d1c6bc9b86a4df2366230a97458489219"
url: "https://pub.dev"
source: hosted
version: "0.0.2"
term_glyph:
dependency: transitive
description:

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.6+106
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:
@@ -56,6 +56,8 @@ dependencies:
flutter_file_dialog: 3.0.1
archive: ^3.5.1
webview_flutter: ^4.7.0
flutter_acrylic: 1.0.0+2
device_info_plus: ^10.1.0
dev_dependencies:
flutter_test:
sdk: flutter

BIN
screenshots/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB

64
windows/build.iss Normal file
View File

@@ -0,0 +1,64 @@
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "pixes"
#define MyAppVersion "1.0.5"
#define MyAppPublisher "Nyne"
#define MyAppURL "https://github.com/wgh136/pixes"
#define MyAppExeName "pixes.exe"
#define RootPath "C:\Users\wgh19\IdeaProjects\pixes"
[Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
AppId={{88521115-48B7-4AF3-BF49-2BC6AF90B8D3}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
;AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
DisableProgramGroupPage=yes
; Uncomment the following line to run in non administrative install mode (install for current user only.)
;PrivilegesRequired=lowest
PrivilegesRequiredOverridesAllowed=dialog
OutputDir=C:\Users\wgh19\IdeaProjects\pixes\build\windows
OutputBaseFilename=pixes-windows-installer
SetupIconFile={#RootPath}\windows\runner\resources\app_icon.ico
Compression=lzma
SolidCompression=yes
WizardStyle=modern
ArchitecturesInstallIn64BitMode=x64 arm64
ArchitecturesAllowed=x64 arm64
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "{#RootPath}\build\windows\x64\runner\Release\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\app_links_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\dynamic_color_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\file_selector_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\flutter_windows.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\screen_retriever_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\share_plus_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\sqlite3.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\sqlite3_flutter_libs_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\url_launcher_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\window_manager_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\flutter_acrylic_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall

View File

@@ -1,6 +1,8 @@
import subprocess
import os
os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
fontUse = '''
fonts:
- family: font
@@ -26,4 +28,19 @@ if os.path.exists("build/app-windows.zip"):
subprocess.run(["tar", "-a", "-c", "-f", "build/windows/x64/app-windows.zip", "-C", "build/windows/x64/runner/Release", "."]
, shell=True)
subprocess.run(["iscc", "build/windows/build.iss"], shell=True)
version = str.split(str.split(content, 'version: ')[1], '+')[0]
issContent = ""
file = open('windows/build.iss', 'r')
issContent = file.read()
newContent = issContent
newContent = newContent.replace("{{version}}", version)
newContent = newContent.replace("{{root_path}}", os.getcwd())
file.close()
file = open('windows/build.iss', 'w')
file.write(newContent)
file.close()
subprocess.run(["iscc", "windows/build.iss"], shell=True)
with open('windows/build.iss', 'w') as file:
file.write(issContent)

BIN
windows/runner/RCa14464 Normal file

Binary file not shown.

BIN
windows/runner/Runner.aps Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 121 KiB