mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
Merge account page and comic source page.
This commit is contained in:
@@ -1,409 +0,0 @@
|
||||
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';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/state_controller.dart';
|
||||
import 'package:venera/network/cookie_jar.dart';
|
||||
import 'package:venera/pages/webview.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
import 'dart:io' as io;
|
||||
|
||||
class AccountsPageLogic extends StateController {
|
||||
final _reLogin = <String, bool>{};
|
||||
}
|
||||
|
||||
class AccountsPage extends StatelessWidget {
|
||||
const AccountsPage({super.key});
|
||||
|
||||
AccountsPageLogic get logic => StateController.find<AccountsPageLogic>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var body = StateBuilder<AccountsPageLogic>(
|
||||
init: AccountsPageLogic(),
|
||||
builder: (logic) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppbar(title: Text("Accounts".tl)),
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate(
|
||||
buildContent(context).toList(),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> buildContent(BuildContext context) sync* {
|
||||
var sources = ComicSource.all().where((element) => element.account != null);
|
||||
if (sources.isEmpty) return;
|
||||
|
||||
for (var element in sources) {
|
||||
final bool logged = element.isLogged;
|
||||
yield Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
element.name,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
);
|
||||
if (!logged) {
|
||||
yield ListTile(
|
||||
title: Text("Log in".tl),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () async {
|
||||
await context.to(
|
||||
() => _LoginPage(
|
||||
config: element.account!,
|
||||
source: element,
|
||||
),
|
||||
);
|
||||
element.saveData();
|
||||
ComicSource.notifyListeners();
|
||||
logic.update();
|
||||
},
|
||||
);
|
||||
}
|
||||
if (logged) {
|
||||
for (var item in element.account!.infoItems) {
|
||||
if (item.builder != null) {
|
||||
yield item.builder!(context);
|
||||
} else {
|
||||
yield ListTile(
|
||||
title: Text(item.title.tl),
|
||||
subtitle: item.data == null ? null : Text(item.data!()),
|
||||
onTap: item.onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (element.data["account"] is List) {
|
||||
bool loading = logic._reLogin[element.key] == true;
|
||||
yield ListTile(
|
||||
title: Text("Re-login".tl),
|
||||
subtitle: Text("Click if login expired".tl),
|
||||
onTap: () async {
|
||||
if (element.data["account"] == null) {
|
||||
context.showMessage(message: "No data".tl);
|
||||
return;
|
||||
}
|
||||
logic._reLogin[element.key] = true;
|
||||
logic.update();
|
||||
final List account = element.data["account"];
|
||||
var res = await element.account!.login!(account[0], account[1]);
|
||||
if (res.error) {
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
} else {
|
||||
context.showMessage(message: "Success".tl);
|
||||
}
|
||||
logic._reLogin[element.key] = false;
|
||||
logic.update();
|
||||
},
|
||||
trailing: loading
|
||||
? const SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
);
|
||||
}
|
||||
yield ListTile(
|
||||
title: Text("Log out".tl),
|
||||
onTap: () {
|
||||
element.data["account"] = null;
|
||||
element.account?.logout();
|
||||
element.saveData();
|
||||
ComicSource.notifyListeners();
|
||||
logic.update();
|
||||
},
|
||||
trailing: const Icon(Icons.logout),
|
||||
);
|
||||
}
|
||||
yield const Divider(thickness: 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
void setClipboard(String text) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
showToast(
|
||||
message: "Copied".tl,
|
||||
icon: const Icon(Icons.check),
|
||||
context: App.rootContext,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginPage extends StatefulWidget {
|
||||
const _LoginPage({required this.config, required this.source});
|
||||
|
||||
final AccountConfig config;
|
||||
|
||||
final ComicSource source;
|
||||
|
||||
@override
|
||||
State<_LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<_LoginPage> {
|
||||
String username = "";
|
||||
String password = "";
|
||||
bool loading = false;
|
||||
|
||||
final Map<String, String> _cookies = {};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: const Appbar(
|
||||
title: Text(''),
|
||||
),
|
||||
body: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: AutofillGroup(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("Login".tl, style: const TextStyle(fontSize: 24)),
|
||||
const SizedBox(height: 32),
|
||||
if (widget.config.cookieFields == null)
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: "Username".tl,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
enabled: widget.config.login != null,
|
||||
onChanged: (s) {
|
||||
username = s;
|
||||
},
|
||||
autofillHints: const [AutofillHints.username],
|
||||
).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(),
|
||||
autofillHints: const [AutofillHints.password],
|
||||
).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: [
|
||||
const Icon(Icons.error_outline),
|
||||
const SizedBox(width: 8),
|
||||
Text("Login with password is disabled".tl),
|
||||
],
|
||||
)
|
||||
else
|
||||
Button.filled(
|
||||
isLoading: loading,
|
||||
onPressed: login,
|
||||
child: Text("Continue".tl),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (widget.config.loginWebsite != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (App.isWindows || App.isLinux) {
|
||||
loginWithWebview2();
|
||||
} else {
|
||||
loginWithWebview();
|
||||
}
|
||||
},
|
||||
child: Text("Login with webview".tl),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.config.registerWebsite != null)
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
launchUrlString(widget.config.registerWebsite!),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.link),
|
||||
const SizedBox(width: 8),
|
||||
Text("Create Account".tl),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void login() {
|
||||
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;
|
||||
validate(c);
|
||||
return false;
|
||||
},
|
||||
onTitleChange: (t, c) {
|
||||
title = t;
|
||||
validate(c);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (success) {
|
||||
widget.source.data['account'] = 'ok';
|
||||
widget.source.saveData();
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
// for windows and linux
|
||||
void loginWithWebview2() async {
|
||||
if (!await DesktopWebview.isAvailable()) {
|
||||
context.showMessage(message: "Webview is not available".tl);
|
||||
}
|
||||
|
||||
var url = widget.config.loginWebsite!;
|
||||
var title = '';
|
||||
bool success = false;
|
||||
|
||||
void onClose() {
|
||||
if (success) {
|
||||
widget.source.data['account'] = 'ok';
|
||||
widget.source.saveData();
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
void validate(DesktopWebview webview) async {
|
||||
if (widget.config.checkLoginStatus != null &&
|
||||
widget.config.checkLoginStatus!(url, title)) {
|
||||
var cookiesMap = await webview.getCookies(url);
|
||||
var cookies = <io.Cookie>[];
|
||||
cookiesMap.forEach((key, value) {
|
||||
cookies.add(io.Cookie(key, value));
|
||||
});
|
||||
SingleInstanceCookieJar.instance?.saveFromResponse(
|
||||
Uri.parse(url),
|
||||
cookies,
|
||||
);
|
||||
success = true;
|
||||
widget.config.onLoginWithWebviewSuccess?.call();
|
||||
webview.close();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
var webview = DesktopWebview(
|
||||
initialUrl: widget.config.loginWebsite!,
|
||||
onTitleChange: (t, webview) {
|
||||
title = t;
|
||||
validate(webview);
|
||||
},
|
||||
onNavigation: (u, webview) {
|
||||
url = u;
|
||||
validate(webview);
|
||||
},
|
||||
onClose: onClose,
|
||||
);
|
||||
|
||||
webview.open();
|
||||
}
|
||||
}
|
@@ -1,5 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io' as io;
|
||||
import 'package:flutter/material.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';
|
||||
@@ -7,11 +9,13 @@ import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
import 'package:venera/network/cookie_jar.dart';
|
||||
import 'package:venera/pages/webview.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
class ComicSourcePage extends StatefulWidget {
|
||||
class ComicSourcePage extends StatelessWidget {
|
||||
const ComicSourcePage({super.key});
|
||||
|
||||
static Future<int> checkComicSourceUpdate() async {
|
||||
@@ -44,11 +48,6 @@ class ComicSourcePage extends StatefulWidget {
|
||||
return shouldUpdate.length;
|
||||
}
|
||||
|
||||
@override
|
||||
State<ComicSourcePage> createState() => _ComicSourcePageState();
|
||||
}
|
||||
|
||||
class _ComicSourcePageState extends State<ComicSourcePage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -92,167 +91,19 @@ class _BodyState extends State<_Body> {
|
||||
style: AppbarStyle.shadow,
|
||||
),
|
||||
buildCard(context),
|
||||
for (var source in ComicSource.all()) buildSource(context, source),
|
||||
for (var source in ComicSource.all())
|
||||
_SliverComicSource(
|
||||
key: ValueKey(source.key),
|
||||
source: source,
|
||||
edit: edit,
|
||||
update: update,
|
||||
delete: delete,
|
||||
),
|
||||
SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildSource(BuildContext context, ComicSource source) {
|
||||
var newVersion = ComicSource.availableUpdates[source.key];
|
||||
bool hasUpdate =
|
||||
newVersion != null && compareSemVer(newVersion, source.version);
|
||||
return SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(source.name),
|
||||
const SizedBox(width: 6),
|
||||
if (hasUpdate)
|
||||
Tooltip(
|
||||
message: newVersion,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
"New Version".tl,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Tooltip(
|
||||
message: "Edit".tl,
|
||||
child: IconButton(
|
||||
onPressed: () => edit(source),
|
||||
icon: const Icon(Icons.edit_note)),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Update".tl,
|
||||
child: IconButton(
|
||||
onPressed: () => update(source),
|
||||
icon: const Icon(Icons.update)),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Delete".tl,
|
||||
child: IconButton(
|
||||
onPressed: () => delete(source),
|
||||
icon: const Icon(Icons.delete)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text("Version"),
|
||||
subtitle: Text(source.version),
|
||||
),
|
||||
...buildSourceSettings(source),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> buildSourceSettings(ComicSource source) sync* {
|
||||
if (source.settings == null) {
|
||||
return;
|
||||
} else if (source.data['settings'] == null) {
|
||||
source.data['settings'] = {};
|
||||
}
|
||||
for (var item in source.settings!.entries) {
|
||||
var key = item.key;
|
||||
String type = item.value['type'];
|
||||
try {
|
||||
if (type == "select") {
|
||||
var current = source.data['settings'][key];
|
||||
if (current == null) {
|
||||
var d = item.value['default'];
|
||||
for (var option in item.value['options']) {
|
||||
if (option['value'] == d) {
|
||||
current = option['text'] ?? option['value'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
current = item.value['options']
|
||||
.firstWhere((e) => e['value'] == current)['text'] ??
|
||||
current;
|
||||
}
|
||||
yield ListTile(
|
||||
title: Text((item.value['title'] as String).ts(source.key)),
|
||||
trailing: Select(
|
||||
current: (current as String).ts(source.key),
|
||||
values: (item.value['options'] as List)
|
||||
.map<String>((e) =>
|
||||
((e['text'] ?? e['value']) as String).ts(source.key))
|
||||
.toList(),
|
||||
onTap: (i) {
|
||||
source.data['settings'][key] =
|
||||
item.value['options'][i]['value'];
|
||||
source.saveData();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
);
|
||||
} else if (type == "switch") {
|
||||
var current = source.data['settings'][key] ?? item.value['default'];
|
||||
yield ListTile(
|
||||
title: Text((item.value['title'] as String).ts(source.key)),
|
||||
trailing: Switch(
|
||||
value: current,
|
||||
onChanged: (v) {
|
||||
source.data['settings'][key] = v;
|
||||
source.saveData();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
);
|
||||
} else if (type == "input") {
|
||||
var current =
|
||||
source.data['settings'][key] ?? item.value['default'] ?? '';
|
||||
yield ListTile(
|
||||
title: Text((item.value['title'] as String).ts(source.key)),
|
||||
subtitle:
|
||||
Text(current, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
showInputDialog(
|
||||
context: context,
|
||||
title: (item.value['title'] as String).ts(source.key),
|
||||
initialValue: current,
|
||||
inputValidator: item.value['validator'] == null
|
||||
? null
|
||||
: RegExp(item.value['validator']),
|
||||
onConfirm: (value) {
|
||||
source.data['settings'][key] = value;
|
||||
source.saveData();
|
||||
setState(() {});
|
||||
return null;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else if (type == "callback") {
|
||||
yield _CallbackSetting(setting: item, sourceKey: source.key);
|
||||
}
|
||||
} catch (e, s) {
|
||||
Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void delete(ComicSource source) {
|
||||
showConfirmDialog(
|
||||
context: App.rootContext,
|
||||
@@ -297,10 +148,12 @@ class _BodyState extends State<_Body> {
|
||||
//
|
||||
}
|
||||
}
|
||||
context.to(() => _EditFilePage(source.filePath, () async {
|
||||
await ComicSource.reload();
|
||||
setState(() {});
|
||||
}));
|
||||
context.to(
|
||||
() => _EditFilePage(source.filePath, () async {
|
||||
await ComicSource.reload();
|
||||
setState(() {});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> update(ComicSource source) async {
|
||||
@@ -764,3 +617,566 @@ class _CallbackSettingState extends State<_CallbackSetting> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SliverComicSource extends StatefulWidget {
|
||||
const _SliverComicSource({
|
||||
super.key,
|
||||
required this.source,
|
||||
required this.edit,
|
||||
required this.update,
|
||||
required this.delete,
|
||||
});
|
||||
|
||||
final ComicSource source;
|
||||
|
||||
final void Function(ComicSource source) edit;
|
||||
final void Function(ComicSource source) update;
|
||||
final void Function(ComicSource source) delete;
|
||||
|
||||
@override
|
||||
State<_SliverComicSource> createState() => _SliverComicSourceState();
|
||||
}
|
||||
|
||||
class _SliverComicSourceState extends State<_SliverComicSource> {
|
||||
ComicSource get source => widget.source;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var newVersion = ComicSource.availableUpdates[source.key];
|
||||
bool hasUpdate =
|
||||
newVersion != null && compareSemVer(newVersion, source.version);
|
||||
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
SliverPadding(padding: const EdgeInsets.only(top: 16)),
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(
|
||||
source.name,
|
||||
style: ts.s18,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
source.version,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
if (hasUpdate)
|
||||
Tooltip(
|
||||
message: newVersion,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
"New Version".tl,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
).paddingLeft(4)
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Tooltip(
|
||||
message: "Edit".tl,
|
||||
child: IconButton(
|
||||
onPressed: () => widget.edit(source),
|
||||
icon: const Icon(Icons.edit_note),
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Update".tl,
|
||||
child: IconButton(
|
||||
onPressed: () => widget.update(source),
|
||||
icon: const Icon(Icons.update),
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Delete".tl,
|
||||
child: IconButton(
|
||||
onPressed: () => widget.delete(source),
|
||||
icon: const Icon(Icons.delete),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: buildSourceSettings().toList(),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: _buildAccount().toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> buildSourceSettings() sync* {
|
||||
if (source.settings == null) {
|
||||
return;
|
||||
} else if (source.data['settings'] == null) {
|
||||
source.data['settings'] = {};
|
||||
}
|
||||
for (var item in source.settings!.entries) {
|
||||
var key = item.key;
|
||||
String type = item.value['type'];
|
||||
try {
|
||||
if (type == "select") {
|
||||
var current = source.data['settings'][key];
|
||||
if (current == null) {
|
||||
var d = item.value['default'];
|
||||
for (var option in item.value['options']) {
|
||||
if (option['value'] == d) {
|
||||
current = option['text'] ?? option['value'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
current = item.value['options']
|
||||
.firstWhere((e) => e['value'] == current)['text'] ??
|
||||
current;
|
||||
}
|
||||
yield ListTile(
|
||||
title: Text((item.value['title'] as String).ts(source.key)),
|
||||
trailing: Select(
|
||||
current: (current as String).ts(source.key),
|
||||
values: (item.value['options'] as List)
|
||||
.map<String>((e) =>
|
||||
((e['text'] ?? e['value']) as String).ts(source.key))
|
||||
.toList(),
|
||||
onTap: (i) {
|
||||
source.data['settings'][key] =
|
||||
item.value['options'][i]['value'];
|
||||
source.saveData();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
);
|
||||
} else if (type == "switch") {
|
||||
var current = source.data['settings'][key] ?? item.value['default'];
|
||||
yield ListTile(
|
||||
title: Text((item.value['title'] as String).ts(source.key)),
|
||||
trailing: Switch(
|
||||
value: current,
|
||||
onChanged: (v) {
|
||||
source.data['settings'][key] = v;
|
||||
source.saveData();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
);
|
||||
} else if (type == "input") {
|
||||
var current =
|
||||
source.data['settings'][key] ?? item.value['default'] ?? '';
|
||||
yield ListTile(
|
||||
title: Text((item.value['title'] as String).ts(source.key)),
|
||||
subtitle:
|
||||
Text(current, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
showInputDialog(
|
||||
context: context,
|
||||
title: (item.value['title'] as String).ts(source.key),
|
||||
initialValue: current,
|
||||
inputValidator: item.value['validator'] == null
|
||||
? null
|
||||
: RegExp(item.value['validator']),
|
||||
onConfirm: (value) {
|
||||
source.data['settings'][key] = value;
|
||||
source.saveData();
|
||||
setState(() {});
|
||||
return null;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else if (type == "callback") {
|
||||
yield _CallbackSetting(setting: item, sourceKey: source.key);
|
||||
}
|
||||
} catch (e, s) {
|
||||
Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final _reLogin = <String, bool>{};
|
||||
|
||||
Iterable<Widget> _buildAccount() sync* {
|
||||
if (source.account == null) return;
|
||||
final bool logged = source.isLogged;
|
||||
if (!logged) {
|
||||
yield ListTile(
|
||||
title: Text("Log in".tl),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () async {
|
||||
await context.to(
|
||||
() => _LoginPage(
|
||||
config: source.account!,
|
||||
source: source,
|
||||
),
|
||||
);
|
||||
source.saveData();
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
}
|
||||
if (logged) {
|
||||
for (var item in source.account!.infoItems) {
|
||||
if (item.builder != null) {
|
||||
yield item.builder!(context);
|
||||
} else {
|
||||
yield ListTile(
|
||||
title: Text(item.title.tl),
|
||||
subtitle: item.data == null ? null : Text(item.data!()),
|
||||
onTap: item.onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (source.data["account"] is List) {
|
||||
bool loading = _reLogin[source.key] == true;
|
||||
yield ListTile(
|
||||
title: Text("Re-login".tl),
|
||||
subtitle: Text("Click if login expired".tl),
|
||||
onTap: () async {
|
||||
if (source.data["account"] == null) {
|
||||
context.showMessage(message: "No data".tl);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_reLogin[source.key] = true;
|
||||
});
|
||||
final List account = source.data["account"];
|
||||
var res = await source.account!.login!(account[0], account[1]);
|
||||
if (res.error) {
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
} else {
|
||||
context.showMessage(message: "Success".tl);
|
||||
}
|
||||
setState(() {
|
||||
_reLogin[source.key] = false;
|
||||
});
|
||||
},
|
||||
trailing: loading
|
||||
? const SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
);
|
||||
}
|
||||
yield ListTile(
|
||||
title: Text("Log out".tl),
|
||||
onTap: () {
|
||||
source.data["account"] = null;
|
||||
source.account?.logout();
|
||||
source.saveData();
|
||||
ComicSource.notifyListeners();
|
||||
setState(() {});
|
||||
},
|
||||
trailing: const Icon(Icons.logout),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginPage extends StatefulWidget {
|
||||
const _LoginPage({required this.config, required this.source});
|
||||
|
||||
final AccountConfig config;
|
||||
|
||||
final ComicSource source;
|
||||
|
||||
@override
|
||||
State<_LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<_LoginPage> {
|
||||
String username = "";
|
||||
String password = "";
|
||||
bool loading = false;
|
||||
|
||||
final Map<String, String> _cookies = {};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: const Appbar(
|
||||
title: Text(''),
|
||||
),
|
||||
body: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: AutofillGroup(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("Login".tl, style: const TextStyle(fontSize: 24)),
|
||||
const SizedBox(height: 32),
|
||||
if (widget.config.cookieFields == null)
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: "Username".tl,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
enabled: widget.config.login != null,
|
||||
onChanged: (s) {
|
||||
username = s;
|
||||
},
|
||||
autofillHints: const [AutofillHints.username],
|
||||
).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(),
|
||||
autofillHints: const [AutofillHints.password],
|
||||
).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: [
|
||||
const Icon(Icons.error_outline),
|
||||
const SizedBox(width: 8),
|
||||
Text("Login with password is disabled".tl),
|
||||
],
|
||||
)
|
||||
else
|
||||
Button.filled(
|
||||
isLoading: loading,
|
||||
onPressed: login,
|
||||
child: Text("Continue".tl),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (widget.config.loginWebsite != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (App.isWindows || App.isLinux) {
|
||||
loginWithWebview2();
|
||||
} else {
|
||||
loginWithWebview();
|
||||
}
|
||||
},
|
||||
child: Text("Login with webview".tl),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.config.registerWebsite != null)
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
launchUrlString(widget.config.registerWebsite!),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.link),
|
||||
const SizedBox(width: 8),
|
||||
Text("Create Account".tl),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void login() {
|
||||
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;
|
||||
validate(c);
|
||||
return false;
|
||||
},
|
||||
onTitleChange: (t, c) {
|
||||
title = t;
|
||||
validate(c);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (success) {
|
||||
widget.source.data['account'] = 'ok';
|
||||
widget.source.saveData();
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
// for windows and linux
|
||||
void loginWithWebview2() async {
|
||||
if (!await DesktopWebview.isAvailable()) {
|
||||
context.showMessage(message: "Webview is not available".tl);
|
||||
}
|
||||
|
||||
var url = widget.config.loginWebsite!;
|
||||
var title = '';
|
||||
bool success = false;
|
||||
|
||||
void onClose() {
|
||||
if (success) {
|
||||
widget.source.data['account'] = 'ok';
|
||||
widget.source.saveData();
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
void validate(DesktopWebview webview) async {
|
||||
if (widget.config.checkLoginStatus != null &&
|
||||
widget.config.checkLoginStatus!(url, title)) {
|
||||
var cookiesMap = await webview.getCookies(url);
|
||||
var cookies = <io.Cookie>[];
|
||||
cookiesMap.forEach((key, value) {
|
||||
cookies.add(io.Cookie(key, value));
|
||||
});
|
||||
SingleInstanceCookieJar.instance?.saveFromResponse(
|
||||
Uri.parse(url),
|
||||
cookies,
|
||||
);
|
||||
success = true;
|
||||
widget.config.onLoginWithWebviewSuccess?.call();
|
||||
webview.close();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
var webview = DesktopWebview(
|
||||
initialUrl: widget.config.loginWebsite!,
|
||||
onTitleChange: (t, webview) {
|
||||
title = t;
|
||||
validate(webview);
|
||||
},
|
||||
onNavigation: (u, webview) {
|
||||
url = u;
|
||||
validate(webview);
|
||||
},
|
||||
onClose: onClose,
|
||||
);
|
||||
|
||||
webview.open();
|
||||
}
|
||||
}
|
||||
|
@@ -9,7 +9,6 @@ import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/history.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/pages/accounts_page.dart';
|
||||
import 'package:venera/pages/comic_page.dart';
|
||||
import 'package:venera/pages/comic_source_page.dart';
|
||||
import 'package:venera/pages/downloading_page.dart';
|
||||
@@ -36,7 +35,6 @@ class HomePage extends StatelessWidget {
|
||||
const _History(),
|
||||
const _Local(),
|
||||
const _ComicSourceWidget(),
|
||||
const _AccountsWidget(),
|
||||
const ImageFavorites(),
|
||||
SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)),
|
||||
],
|
||||
@@ -698,115 +696,6 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
class _AccountsWidget extends StatefulWidget {
|
||||
const _AccountsWidget();
|
||||
|
||||
@override
|
||||
State<_AccountsWidget> createState() => _AccountsWidgetState();
|
||||
}
|
||||
|
||||
class _AccountsWidgetState extends State<_AccountsWidget> {
|
||||
late List<String> accounts;
|
||||
|
||||
void onComicSourceChange() {
|
||||
setState(() {
|
||||
accounts.clear();
|
||||
for (var c in ComicSource.all()) {
|
||||
if (c.isLogged) {
|
||||
accounts.add(c.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
accounts = [];
|
||||
for (var c in ComicSource.all()) {
|
||||
if (c.isLogged) {
|
||||
accounts.add(c.name);
|
||||
}
|
||||
}
|
||||
ComicSource.addListener(onComicSourceChange);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
ComicSource.removeListener(onComicSourceChange);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () {
|
||||
context.to(() => const AccountsPage());
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: [
|
||||
Center(
|
||||
child: Text('Accounts'.tl, style: ts.s18),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(accounts.length.toString(), style: ts.s12),
|
||||
),
|
||||
const Spacer(),
|
||||
const Icon(Icons.arrow_right),
|
||||
],
|
||||
),
|
||||
).paddingHorizontal(16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Wrap(
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
children: accounts.map((e) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(e),
|
||||
);
|
||||
}).toList(),
|
||||
).paddingHorizontal(16).paddingBottom(16),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedDownloadingIcon extends StatefulWidget {
|
||||
const _AnimatedDownloadingIcon();
|
||||
|
||||
|
Reference in New Issue
Block a user