add star rating, network cache, advanced search option, loginWithCookies, loadNext; fix some minor issues

This commit is contained in:
nyne
2024-10-25 22:51:23 +08:00
parent b682d7d87b
commit 897f92f4c9
27 changed files with 1420 additions and 319 deletions

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
@@ -158,6 +159,8 @@ class _LoginPageState extends State<_LoginPage> {
String password = "";
bool loading = false;
final Map<String, String> _cookies = {};
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -173,31 +176,44 @@ class _LoginPageState extends State<_LoginPage> {
children: [
Text("Login".tl, style: const TextStyle(fontSize: 24)),
const SizedBox(height: 32),
TextField(
decoration: InputDecoration(
labelText: "Username".tl,
border: const OutlineInputBorder(),
),
enabled: widget.config.login != null,
onChanged: (s) {
username = s;
},
),
const SizedBox(height: 16),
TextField(
decoration: InputDecoration(
labelText: "Password".tl,
border: const OutlineInputBorder(),
),
obscureText: true,
enabled: widget.config.login != null,
onChanged: (s) {
password = s;
},
onSubmitted: (s) => login(),
),
const SizedBox(height: 32),
if (widget.config.login == null)
if (widget.config.cookieFields == null)
TextField(
decoration: InputDecoration(
labelText: "Username".tl,
border: const OutlineInputBorder(),
),
enabled: widget.config.login != null,
onChanged: (s) {
username = s;
},
).paddingBottom(16),
if (widget.config.cookieFields == null)
TextField(
decoration: InputDecoration(
labelText: "Password".tl,
border: const OutlineInputBorder(),
),
obscureText: true,
enabled: widget.config.login != null,
onChanged: (s) {
password = s;
},
onSubmitted: (s) => login(),
).paddingBottom(16),
for (var field in widget.config.cookieFields ?? <String>[])
TextField(
decoration: InputDecoration(
labelText: field,
border: const OutlineInputBorder(),
),
obscureText: true,
enabled: widget.config.validateCookies != null,
onChanged: (s) {
_cookies[field] = s;
},
).paddingBottom(16),
if (widget.config.login == null &&
widget.config.cookieFields == null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -214,7 +230,7 @@ class _LoginPageState extends State<_LoginPage> {
),
const SizedBox(height: 24),
if (widget.config.loginWebsite != null)
FilledButton(
TextButton(
onPressed: loginWithWebview,
child: Text("Login with webview".tl),
),
@@ -240,71 +256,81 @@ class _LoginPageState extends State<_LoginPage> {
}
void login() {
if (username.isEmpty || password.isEmpty) {
showToast(
message: "Cannot be empty".tl,
icon: const Icon(Icons.error_outline),
context: context,
);
return;
}
setState(() {
loading = true;
});
widget.config.login!(username, password).then((value) {
if (value.error) {
context.showMessage(message: value.errorMessage!);
setState(() {
loading = false;
});
} else {
if (mounted) {
context.pop();
}
if (widget.config.login != null) {
if (username.isEmpty || password.isEmpty) {
showToast(
message: "Cannot be empty".tl,
icon: const Icon(Icons.error_outline),
context: context,
);
return;
}
});
setState(() {
loading = true;
});
widget.config.login!(username, password).then((value) {
if (value.error) {
context.showMessage(message: value.errorMessage!);
setState(() {
loading = false;
});
} else {
if (mounted) {
context.pop();
}
}
});
} else if (widget.config.validateCookies != null) {
setState(() {
loading = true;
});
var cookies =
widget.config.cookieFields!.map((e) => _cookies[e] ?? '').toList();
widget.config.validateCookies!(cookies).then((value) {
if (value) {
widget.source.data['account'] = 'ok';
widget.source.saveData();
context.pop();
} else {
context.showMessage(message: "Invalid cookies".tl);
setState(() {
loading = false;
});
}
});
}
}
void loginWithWebview() async {
var url = widget.config.loginWebsite!;
var title = '';
bool success = false;
void validate(InAppWebViewController c) async {
if (widget.config.checkLoginStatus != null
&& widget.config.checkLoginStatus!(url, title)) {
var cookies = (await c.getCookies(url)) ?? [];
SingleInstanceCookieJar.instance?.saveFromResponse(
Uri.parse(url),
cookies,
);
success = true;
widget.config.onLoginWithWebviewSuccess?.call();
App.mainNavigatorKey?.currentContext?.pop();
}
}
await context.to(
() => AppWebview(
initialUrl: widget.config.loginWebsite!,
onNavigation: (u, c) {
url = u;
print(url);
() async {
if (widget.config.checkLoginStatus != null) {
if (widget.config.checkLoginStatus!(url, title)) {
var cookies = (await c.getCookies(url)) ?? [];
SingleInstanceCookieJar.instance?.saveFromResponse(
Uri.parse(url),
cookies,
);
success = true;
App.mainNavigatorKey?.currentContext?.pop();
}
}
}();
validate(c);
return false;
},
onTitleChange: (t, c) {
() async {
if (widget.config.checkLoginStatus != null) {
if (widget.config.checkLoginStatus!(url, title)) {
var cookies = (await c.getCookies(url)) ?? [];
SingleInstanceCookieJar.instance?.saveFromResponse(
Uri.parse(url),
cookies,
);
success = true;
App.mainNavigatorKey?.currentContext?.pop();
}
}
}();
title = t;
validate(c);
},
),
);

View File

@@ -352,6 +352,18 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
ListTile(
title: Text("Information".tl),
),
if (comic.stars != null)
Row(
children: [
StarRating(
value: comic.stars!,
size: 24,
onTap: starRating,
),
const SizedBox(width: 8),
Text(comic.stars!.toStringAsFixed(2)),
],
).paddingLeft(16).paddingVertical(8),
for (var e in comic.tags.entries)
buildWrap(
children: [
@@ -641,6 +653,72 @@ abstract mixin class _ComicPageActions {
),
);
}
void starRating() {
if (!comicSource.isLogged) {
return;
}
var rating = 0.0;
var isLoading = false;
showDialog(
context: App.rootContext,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => SimpleDialog(
title: const Text("Rating"),
alignment: Alignment.center,
children: [
SizedBox(
height: 100,
child: Center(
child: SizedBox(
width: 210,
child: Column(
children: [
const SizedBox(
height: 10,
),
RatingWidget(
padding: 2,
onRatingUpdate: (value) => rating = value,
value: 1,
selectable: true,
size: 40,
),
const Spacer(),
Button.filled(
isLoading: isLoading,
onPressed: () {
setState(() {
isLoading = true;
});
comicSource.starRatingFunc!
(comic.id, rating.round())
.then((value) {
if (value.success) {
App.rootContext
.showMessage(message: "Success".tl);
Navigator.of(dialogContext).pop();
} else {
App.rootContext
.showMessage(message: value.errorMessage!);
setState(() {
isLoading = false;
});
}
});
},
child: Text("Submit".tl),
)
],
),
),
),
)
],
),
),
);
}
}
class _ActionButton extends StatelessWidget {
@@ -880,62 +958,88 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
),
),
SliverGrid(
delegate: SliverChildBuilderDelegate(childCount: thumbnails.length,
(context, index) {
if (index == thumbnails.length - 1 && error == null) {
loadNext();
}
return Padding(
padding: context.width < changePoint
? const EdgeInsets.all(4)
: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: InkWell(
onTap: () => state.read(null, index + 1),
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Container(
decoration: BoxDecoration(
borderRadius:
const BorderRadius.all(Radius.circular(16)),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
),
width: double.infinity,
height: double.infinity,
child: ClipRRect(
borderRadius:
const BorderRadius.all(Radius.circular(16)),
child: AnimatedImage(
image: CachedImageProvider(
thumbnails[index],
sourceKey: state.widget.sourceKey,
delegate: SliverChildBuilderDelegate(
childCount: thumbnails.length,
(context, index) {
if (index == thumbnails.length - 1 && error == null) {
loadNext();
}
var url = thumbnails[index];
ImagePart? part;
if (url.contains('@')) {
var params = url.split('@')[1].split('&');
url = url.split('@')[0];
double? x1, y1, x2, y2;
try {
for (var p in params) {
if (p.startsWith('x')) {
var r = p.split('=')[1];
x1 = double.parse(r.split('-')[0]);
x2 = double.parse(r.split('-')[1]);
}
if (p.startsWith('y')) {
var r = p.split('=')[1];
y1 = double.parse(r.split('-')[0]);
y2 = double.parse(r.split('-')[1]);
}
}
} finally {}
part = ImagePart(x1: x1, y1: y1, x2: x2, y2: y2);
}
return Padding(
padding: context.width < changePoint
? const EdgeInsets.all(4)
: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: InkWell(
onTap: () => state.read(null, index + 1),
borderRadius:
const BorderRadius.all(Radius.circular(16)),
child: Container(
decoration: BoxDecoration(
borderRadius:
const BorderRadius.all(Radius.circular(16)),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
),
width: double.infinity,
height: double.infinity,
child: ClipRRect(
borderRadius:
const BorderRadius.all(Radius.circular(16)),
child: AnimatedImage(
image: CachedImageProvider(
url,
sourceKey: state.widget.sourceKey,
),
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
part: part,
),
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
),
),
),
),
),
const SizedBox(
height: 4,
),
Text((index + 1).toString()),
],
),
);
}),
const SizedBox(
height: 4,
),
Text((index + 1).toString()),
],
),
);
},
),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
childAspectRatio: 0.65,
),
),
if(error != null)
if (error != null)
SliverToBoxAdapter(
child: Column(
children: [
@@ -1288,7 +1392,8 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
setState(() {
isLoading = true;
});
var res = await widget.comicSource.favoriteData!.addOrDelFavorite!(
var res =
await widget.comicSource.favoriteData!.addOrDelFavorite!(
widget.cid,
selected!,
!addedFolders.contains(selected!),

View File

@@ -267,7 +267,7 @@ class _CommentTileState extends State<_CommentTile> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.comment.userName),
Text(widget.comment.userName, style: ts.bold,),
if (widget.comment.time != null)
Text(widget.comment.time!, style: ts.s12),
const SizedBox(height: 4),
@@ -403,38 +403,45 @@ class _CommentTileState extends State<_CommentTile> {
int? voteStatus;
bool isVoteUp = false;
bool isVotingUp = false;
bool isVoteDown = false;
bool isVotingDown = false;
void vote(bool isUp) async {
if (isVoteUp || isVoteDown) return;
if (isVotingUp || isVotingDown) return;
setState(() {
if (isUp) {
isVoteUp = true;
isVotingUp = true;
} else {
isVoteDown = true;
isVotingDown = true;
}
});
var isCancel = (isUp && voteStatus == 1) || (!isUp && voteStatus == -1);
var res = await widget.source.voteCommentFunc!(
widget.comic.comicId,
widget.comic.subId,
widget.comment.id!,
isUp,
(isUp && voteStatus == 1) || (!isUp && voteStatus == -1),
isCancel,
);
if (res.success) {
if (isUp) {
voteStatus = 1;
if(isCancel) {
voteStatus = 0;
} else {
voteStatus = -1;
if (isUp) {
voteStatus = 1;
} else {
voteStatus = -1;
}
}
widget.comment.voteStatus = voteStatus;
widget.comment.score = res.data ?? widget.comment.score;
} else {
context.showMessage(message: res.errorMessage ?? "Error");
}
setState(() {
isVoteUp = false;
isVoteDown = false;
isVotingUp = false;
isVotingDown = false;
});
}
@@ -461,7 +468,7 @@ class _CommentTileState extends State<_CommentTile> {
mainAxisSize: MainAxisSize.min,
children: [
Button.icon(
isLoading: isVoteUp,
isLoading: isVotingUp,
icon: const Icon(Icons.arrow_upward),
size: 18,
color: upColor,
@@ -471,7 +478,7 @@ class _CommentTileState extends State<_CommentTile> {
Text(widget.comment.score.toString()),
const SizedBox(width: 4),
Button.icon(
isLoading: isVoteDown,
isLoading: isVotingDown,
icon: const Icon(Icons.arrow_downward),
size: 18,
color: downColor,

View File

@@ -179,7 +179,7 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage> {
Widget build(BuildContext context) {
if (data.loadMultiPart != null) {
return buildMultiPart();
} else if (data.loadPage != null) {
} else if (data.loadPage != null || data.loadNext != null) {
return buildComicList();
} else if (data.loadMixed != null) {
return _MixedExplorePage(
@@ -196,7 +196,8 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage> {
Widget buildComicList() {
return ComicList(
loadPage: data.loadPage!,
loadPage: data.loadPage,
loadNext: data.loadNext,
key: ValueKey(key),
);
}

View File

@@ -112,7 +112,8 @@ class _NormalFavoritePage extends StatelessWidget {
),
title: Text(data.title),
),
loadPage: (i) => data.loadComic(i),
loadPage: data.loadComic == null ? null : (i) => data.loadComic!(i),
loadNext: data.loadNext == null ? null : (next) => data.loadNext!(next),
menuBuilder: (comic) {
return [
MenuEntry(
@@ -393,7 +394,8 @@ class _FolderTile extends StatelessWidget {
return StatefulBuilder(builder: (context, setState) {
return ContentDialog(
title: "Delete".tl,
content: Text("Are you sure you want to delete this folder?".tl).paddingHorizontal(16),
content: Text("Are you sure you want to delete this folder?".tl)
.paddingHorizontal(16),
actions: [
Button.filled(
isLoading: loading,
@@ -516,7 +518,11 @@ class _FavoriteFolder extends StatelessWidget {
errorLeading: Appbar(
title: Text(title),
),
loadPage: (i) => data.loadComic(i, folderID),
loadPage:
data.loadComic == null ? null : (i) => data.loadComic!(i, folderID),
loadNext: data.loadNext == null
? null
: (next) => data.loadNext!(next, folderID),
menuBuilder: (comic) {
return [
MenuEntry(

View File

@@ -46,7 +46,12 @@ class _RankingPageState extends State<RankingPage> {
children: [
Expanded(
child: ComicList(
loadPage: (i) => data.rankingData!.load(optionValue, i),
loadPage: data.rankingData!.load == null
? null
: (i) => data.rankingData!.load!(optionValue, i),
loadNext: data.rankingData!.loadWithNext == null
? null
: (i) => data.rankingData!.loadWithNext!(optionValue, i),
),
),
],

View File

@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
@@ -24,6 +26,8 @@ class _SearchPageState extends State<SearchPage> {
String searchTarget = "";
var focusNode = FocusNode();
var options = <String>[];
void update() {
@@ -137,6 +141,12 @@ class _SearchPageState extends State<SearchPage> {
super.initState();
}
@override
void dispose() {
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -152,6 +162,7 @@ class _SearchPageState extends State<SearchPage> {
onChanged: (s) {
findSuggestions();
},
focusNode: focusNode,
);
if (suggestions.isNotEmpty) {
yield buildSuggestions(context);
@@ -186,6 +197,7 @@ class _SearchPageState extends State<SearchPage> {
onTap: () {
setState(() {
searchTarget = e.key;
useDefaultOptions();
});
},
);
@@ -197,6 +209,13 @@ class _SearchPageState extends State<SearchPage> {
);
}
void useDefaultOptions() {
final searchOptions =
ComicSource.find(searchTarget)!.searchPageData!.searchOptions ??
<SearchOptions>[];
options = searchOptions.map((e) => e.defaultValue).toList();
}
Widget buildSearchOptions() {
var children = <Widget>[];
@@ -204,30 +223,21 @@ class _SearchPageState extends State<SearchPage> {
ComicSource.find(searchTarget)!.searchPageData!.searchOptions ??
<SearchOptions>[];
if (searchOptions.length != options.length) {
options = searchOptions.map((e) => e.defaultValue).toList();
useDefaultOptions();
}
if (searchOptions.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox());
}
for (int i = 0; i < searchOptions.length; i++) {
final option = searchOptions[i];
children.add(ListTile(
contentPadding: EdgeInsets.zero,
title: Text(option.label.tl),
));
children.add(Wrap(
runSpacing: 8,
spacing: 8,
children: option.options.entries.map((e) {
return OptionChip(
text: e.value.ts(searchTarget),
isSelected: options[i] == e.key,
onTap: () {
options[i] = e.key;
update();
},
);
}).toList(),
children.add(SearchOptionWidget(
option: option,
value: options[i],
onChanged: (value) {
options[i] = value;
update();
},
sourceKey: searchTarget,
));
}
@@ -336,7 +346,9 @@ class _SearchPageState extends State<SearchPage> {
} else {
controller.text += "$text ";
}
suggestions.clear();
update();
focusNode.requestFocus();
}
bool showMethod = MediaQuery.of(context).size.width < 600;
@@ -444,3 +456,77 @@ class _SearchPageState extends State<SearchPage> {
);
}
}
class SearchOptionWidget extends StatelessWidget {
const SearchOptionWidget({
super.key,
required this.option,
required this.value,
required this.onChanged,
required this.sourceKey,
});
final SearchOptions option;
final String value;
final void Function(String) onChanged;
final String sourceKey;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(option.label.ts(sourceKey)),
),
if(option.type == 'select')
Wrap(
runSpacing: 8,
spacing: 8,
children: option.options.entries.map((e) {
return OptionChip(
text: e.value.ts(sourceKey),
isSelected: value == e.key,
onTap: () {
onChanged(e.key);
},
);
}).toList(),
),
if(option.type == 'multi-select')
Wrap(
runSpacing: 8,
spacing: 8,
children: option.options.entries.map((e) {
return OptionChip(
text: e.value.ts(sourceKey),
isSelected: (jsonDecode(value) as List).contains(e.key),
onTap: () {
var list = jsonDecode(value) as List;
if(list.contains(e.key)) {
list.remove(e.key);
} else {
list.add(e.key);
}
onChanged(jsonEncode(list));
},
);
}).toList(),
),
if(option.type == 'dropdown')
Select(
current: option.options[value],
values: option.options.values.toList(),
onTap: (index) {
onChanged(option.options.keys.elementAt(index));
},
minWidth: 96,
)
],
);
}
}

View File

@@ -4,6 +4,7 @@ import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:venera/pages/search_page.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
@@ -87,12 +88,27 @@ class _SearchResultPageState extends State<SearchResultPage> {
);
sourceKey = widget.sourceKey;
options = widget.options;
validateOptions();
text = widget.text;
appdata.addSearchHistory(text);
suggestionsController = _SuggestionsController(controller);
super.initState();
}
void validateOptions() {
var source = ComicSource.find(sourceKey);
if (source == null) {
return;
}
var searchOptions = source.searchPageData!.searchOptions;
if (searchOptions == null) {
return;
}
if (options.length != searchOptions.length) {
options = searchOptions.map((e) => e.defaultValue).toList();
}
}
@override
Widget build(BuildContext context) {
return ComicList(
@@ -422,25 +438,15 @@ class _SearchSettingsDialogState extends State<_SearchSettingsDialog> {
}
for (int i = 0; i < searchOptions.length; i++) {
final option = searchOptions[i];
children.add(ListTile(
contentPadding: EdgeInsets.zero,
title: Text(option.label.tl),
));
children.add(Wrap(
runSpacing: 8,
spacing: 8,
children: option.options.entries.map((e) {
return OptionChip(
text: e.value.ts(searchTarget),
isSelected: options[i] == e.key,
onTap: () {
setState(() {
options[i] = e.key;
});
onChanged();
},
);
}).toList(),
children.add(SearchOptionWidget(
option: option,
value: options[i],
onChanged: (value) {
setState(() {
options[i] = value;
});
},
sourceKey: searchTarget,
));
}