mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
1152 lines
34 KiB
Dart
1152 lines
34 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:sliver_tools/sliver_tools.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/consts.dart';
|
|
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';
|
|
import 'package:venera/pages/history_page.dart';
|
|
import 'package:venera/pages/image_favorites_page/image_favorites_page.dart';
|
|
import 'package:venera/pages/search_page.dart';
|
|
import 'package:venera/utils/data_sync.dart';
|
|
import 'package:venera/utils/import_comic.dart';
|
|
import 'package:venera/utils/tags_translation.dart';
|
|
import 'package:venera/utils/translations.dart';
|
|
|
|
import 'local_comics_page.dart';
|
|
|
|
class HomePage extends StatelessWidget {
|
|
const HomePage({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
var widget = SmoothCustomScrollView(
|
|
slivers: [
|
|
SliverPadding(padding: EdgeInsets.only(top: context.padding.top)),
|
|
const _SearchBar(),
|
|
const _SyncDataWidget(),
|
|
const _History(),
|
|
const _Local(),
|
|
const _ComicSourceWidget(),
|
|
const _AccountsWidget(),
|
|
const ImageFavorites(),
|
|
SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)),
|
|
],
|
|
);
|
|
return context.width > changePoint ? widget.paddingHorizontal(8) : widget;
|
|
}
|
|
}
|
|
|
|
class _SearchBar extends StatelessWidget {
|
|
const _SearchBar();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SliverToBoxAdapter(
|
|
child: Container(
|
|
height: 52,
|
|
width: double.infinity,
|
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
|
child: Material(
|
|
color: context.colorScheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(32),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(32),
|
|
onTap: () {
|
|
context.to(() => const SearchPage());
|
|
},
|
|
child: Row(
|
|
children: [
|
|
const SizedBox(width: 16),
|
|
const Icon(Icons.search),
|
|
const SizedBox(width: 8),
|
|
Text('Search'.tl, style: ts.s16),
|
|
const Spacer(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SyncDataWidget extends StatefulWidget {
|
|
const _SyncDataWidget();
|
|
|
|
@override
|
|
State<_SyncDataWidget> createState() => _SyncDataWidgetState();
|
|
}
|
|
|
|
class _SyncDataWidgetState extends State<_SyncDataWidget>
|
|
with WidgetsBindingObserver {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
DataSync().addListener(update);
|
|
WidgetsBinding.instance.addObserver(this);
|
|
lastCheck = DateTime.now();
|
|
}
|
|
|
|
void update() {
|
|
if (mounted) {
|
|
setState(() {});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
super.dispose();
|
|
DataSync().removeListener(update);
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
}
|
|
|
|
late DateTime lastCheck;
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
super.didChangeAppLifecycleState(state);
|
|
if (state == AppLifecycleState.resumed) {
|
|
if (DateTime.now().difference(lastCheck) > const Duration(minutes: 10)) {
|
|
lastCheck = DateTime.now();
|
|
DataSync().downloadData();
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
Widget child;
|
|
if (!DataSync().isEnabled) {
|
|
child = const SliverPadding(padding: EdgeInsets.zero);
|
|
} else if (DataSync().isUploading || DataSync().isDownloading) {
|
|
child = SliverToBoxAdapter(
|
|
child: Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: ListTile(
|
|
leading: const Icon(Icons.sync),
|
|
title: Text('Syncing Data'.tl),
|
|
trailing: const CircularProgressIndicator(strokeWidth: 2)
|
|
.fixWidth(18)
|
|
.fixHeight(18),
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
child = SliverToBoxAdapter(
|
|
child: Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(
|
|
color: Theme.of(context).colorScheme.outlineVariant,
|
|
),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: ListTile(
|
|
leading: const Icon(Icons.sync),
|
|
title: Text('Sync Data'.tl),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.cloud_upload_outlined),
|
|
onPressed: () async {
|
|
DataSync().uploadData();
|
|
}),
|
|
IconButton(
|
|
icon: const Icon(Icons.cloud_download_outlined),
|
|
onPressed: () async {
|
|
DataSync().downloadData();
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return SliverAnimatedPaintExtent(
|
|
duration: const Duration(milliseconds: 200),
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _History extends StatefulWidget {
|
|
const _History();
|
|
|
|
@override
|
|
State<_History> createState() => _HistoryState();
|
|
}
|
|
|
|
class _HistoryState extends State<_History> {
|
|
late List<History> history;
|
|
late int count;
|
|
|
|
void onHistoryChange() {
|
|
setState(() {
|
|
history = HistoryManager().getRecent();
|
|
count = HistoryManager().count();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
history = HistoryManager().getRecent();
|
|
count = HistoryManager().count();
|
|
HistoryManager().addListener(onHistoryChange);
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
HistoryManager().removeListener(onHistoryChange);
|
|
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 HistoryPage());
|
|
},
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
height: 56,
|
|
child: Row(
|
|
children: [
|
|
Center(
|
|
child: Text('History'.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(count.toString(), style: ts.s12),
|
|
),
|
|
const Spacer(),
|
|
const Icon(Icons.arrow_right),
|
|
],
|
|
),
|
|
).paddingHorizontal(16),
|
|
if (history.isNotEmpty)
|
|
SizedBox(
|
|
height: 128,
|
|
child: ListView.builder(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: history.length,
|
|
itemBuilder: (context, index) {
|
|
return SimpleComicTile(
|
|
comic: history[index],
|
|
onTap: () {
|
|
context.to(
|
|
() => ComicPage(
|
|
id: history[index].id,
|
|
sourceKey: history[index].type.sourceKey,
|
|
),
|
|
);
|
|
},
|
|
).paddingHorizontal(8).paddingVertical(2);
|
|
},
|
|
),
|
|
).paddingHorizontal(8).paddingBottom(16),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Local extends StatefulWidget {
|
|
const _Local();
|
|
|
|
@override
|
|
State<_Local> createState() => _LocalState();
|
|
}
|
|
|
|
class _LocalState extends State<_Local> {
|
|
late List<LocalComic> local;
|
|
late int count;
|
|
|
|
void onLocalComicsChange() {
|
|
setState(() {
|
|
local = LocalManager().getRecent();
|
|
count = LocalManager().count;
|
|
});
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
local = LocalManager().getRecent();
|
|
count = LocalManager().count;
|
|
LocalManager().addListener(onLocalComicsChange);
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
LocalManager().removeListener(onLocalComicsChange);
|
|
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 LocalComicsPage());
|
|
},
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
height: 56,
|
|
child: Row(
|
|
children: [
|
|
Center(
|
|
child: Text('Local'.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(count.toString(), style: ts.s12),
|
|
),
|
|
const Spacer(),
|
|
const Icon(Icons.arrow_right),
|
|
],
|
|
),
|
|
).paddingHorizontal(16),
|
|
if (local.isNotEmpty)
|
|
SizedBox(
|
|
height: 128,
|
|
child: ListView.builder(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: local.length,
|
|
itemBuilder: (context, index) {
|
|
return SimpleComicTile(comic: local[index])
|
|
.paddingHorizontal(8);
|
|
},
|
|
),
|
|
).paddingHorizontal(8),
|
|
Row(
|
|
children: [
|
|
if (LocalManager().downloadingTasks.isNotEmpty)
|
|
Button.outlined(
|
|
child: Row(
|
|
children: [
|
|
if (LocalManager().downloadingTasks.first.isPaused)
|
|
const Icon(Icons.pause_circle_outline, size: 18)
|
|
else
|
|
const _AnimatedDownloadingIcon(),
|
|
const SizedBox(width: 8),
|
|
Text("@a Tasks".tlParams({
|
|
'a': LocalManager().downloadingTasks.length,
|
|
})),
|
|
],
|
|
),
|
|
onPressed: () {
|
|
showPopUpWidget(context, const DownloadingPage());
|
|
},
|
|
),
|
|
const Spacer(),
|
|
Button.filled(
|
|
onPressed: import,
|
|
child: Text("Import".tl),
|
|
),
|
|
],
|
|
).paddingHorizontal(16).paddingVertical(8),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void import() {
|
|
showDialog(
|
|
barrierDismissible: false,
|
|
context: App.rootContext,
|
|
builder: (context) {
|
|
return const _ImportComicsWidget();
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ImportComicsWidget extends StatefulWidget {
|
|
const _ImportComicsWidget();
|
|
|
|
@override
|
|
State<_ImportComicsWidget> createState() => _ImportComicsWidgetState();
|
|
}
|
|
|
|
class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
|
int type = 0;
|
|
|
|
bool loading = false;
|
|
|
|
var key = GlobalKey();
|
|
|
|
var height = 200.0;
|
|
|
|
var folders = LocalFavoritesManager().folderNames;
|
|
|
|
String? selectedFolder;
|
|
|
|
bool copyToLocalFolder = true;
|
|
|
|
bool cancelled = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
loading = false;
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
String info = [
|
|
"Select a directory which contains the comic files.".tl,
|
|
"Select a directory which contains the comic directories.".tl,
|
|
"Select an archive file (cbz, zip, 7z, cb7)".tl,
|
|
"Select a directory which contains multiple archive files.".tl,
|
|
"Select an EhViewer database and a download folder.".tl
|
|
][type];
|
|
List<String> importMethods = [
|
|
"Single Comic".tl,
|
|
"Multiple Comics".tl,
|
|
"An archive file".tl,
|
|
"Multiple archive files".tl,
|
|
"EhViewer downloads".tl
|
|
];
|
|
|
|
return ContentDialog(
|
|
dismissible: !loading,
|
|
title: "Import Comics".tl,
|
|
content: loading
|
|
? SizedBox(
|
|
width: 600,
|
|
height: height,
|
|
child: const Center(
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
)
|
|
: Column(
|
|
key: key,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(width: 600),
|
|
...List.generate(importMethods.length, (index) {
|
|
return RadioListTile(
|
|
title: Text(importMethods[index]),
|
|
value: index,
|
|
groupValue: type,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
type = value as int;
|
|
});
|
|
},
|
|
);
|
|
}),
|
|
if (type != 4)
|
|
ListTile(
|
|
title: Text("Add to favorites".tl),
|
|
trailing: Select(
|
|
current: selectedFolder,
|
|
values: folders,
|
|
minWidth: 112,
|
|
onTap: (v) {
|
|
setState(() {
|
|
selectedFolder = folders[v];
|
|
});
|
|
},
|
|
),
|
|
).paddingHorizontal(8),
|
|
if (!App.isIOS && !App.isMacOS && type != 2 && type != 3)
|
|
CheckboxListTile(
|
|
enabled: true,
|
|
title: Text("Copy to app local path".tl),
|
|
value: copyToLocalFolder,
|
|
onChanged: (v) {
|
|
setState(() {
|
|
copyToLocalFolder = !copyToLocalFolder;
|
|
});
|
|
}).paddingHorizontal(8),
|
|
const SizedBox(height: 8),
|
|
Text(info).paddingHorizontal(24),
|
|
],
|
|
),
|
|
actions: [
|
|
Button.text(
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.help_outline,
|
|
size: 18,
|
|
color: context.colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text("help".tl),
|
|
],
|
|
),
|
|
onPressed: () {
|
|
showDialog(
|
|
context: context,
|
|
barrierColor: Colors.black.toOpacity(0.2),
|
|
builder: (context) {
|
|
var help = '';
|
|
help +=
|
|
'${"A directory is considered as a comic only if it matches one of the following conditions:".tl}\n';
|
|
help += '${'1. The directory only contains image files.'.tl}\n';
|
|
help +=
|
|
'${'2. The directory contains directories which contain image files. Each directory is considered as a chapter.'.tl}\n\n';
|
|
help +=
|
|
'${"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used.".tl}\n\n';
|
|
help +=
|
|
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n"
|
|
.tl;
|
|
help +=
|
|
"If you import an EhViewer's database, program will automatically create folders according to the download label in that database."
|
|
.tl;
|
|
return ContentDialog(
|
|
title: "Help".tl,
|
|
content: Text(help).paddingHorizontal(16),
|
|
actions: [
|
|
Button.filled(
|
|
child: Text("OK".tl),
|
|
onPressed: () {
|
|
context.pop();
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
).fixWidth(90).paddingRight(8),
|
|
Button.filled(
|
|
isLoading: loading,
|
|
onPressed: selectAndImport,
|
|
child: Text("Select".tl),
|
|
)
|
|
],
|
|
);
|
|
}
|
|
|
|
void selectAndImport() async {
|
|
height = key.currentContext!.size!.height;
|
|
|
|
setState(() {
|
|
loading = true;
|
|
});
|
|
var importer = ImportComic(
|
|
selectedFolder: selectedFolder, copyToLocal: copyToLocalFolder);
|
|
var result = switch (type) {
|
|
0 => await importer.directory(true),
|
|
1 => await importer.directory(false),
|
|
2 => await importer.cbz(),
|
|
3 => await importer.multipleCbz(),
|
|
4 => await importer.ehViewer(),
|
|
int() => true,
|
|
};
|
|
if (result) {
|
|
context.pop();
|
|
} else {
|
|
setState(() {
|
|
loading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
class _ComicSourceWidget extends StatefulWidget {
|
|
const _ComicSourceWidget();
|
|
|
|
@override
|
|
State<_ComicSourceWidget> createState() => _ComicSourceWidgetState();
|
|
}
|
|
|
|
class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
|
|
late List<String> comicSources;
|
|
|
|
void onComicSourceChange() {
|
|
setState(() {
|
|
comicSources = ComicSource.all().map((e) => e.name).toList();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
comicSources = ComicSource.all().map((e) => e.name).toList();
|
|
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 ComicSourcePage());
|
|
},
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
height: 56,
|
|
child: Row(
|
|
children: [
|
|
Center(
|
|
child: Text('Comic Source'.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(comicSources.length.toString(), style: ts.s12),
|
|
),
|
|
const Spacer(),
|
|
const Icon(Icons.arrow_right),
|
|
],
|
|
),
|
|
).paddingHorizontal(16),
|
|
if (comicSources.isNotEmpty)
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: Wrap(
|
|
runSpacing: 8,
|
|
spacing: 8,
|
|
children: comicSources.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 _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();
|
|
|
|
@override
|
|
State<_AnimatedDownloadingIcon> createState() =>
|
|
__AnimatedDownloadingIconState();
|
|
}
|
|
|
|
class __AnimatedDownloadingIconState extends State<_AnimatedDownloadingIcon>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _controller;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
lowerBound: -1,
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 2000),
|
|
)..repeat();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: _controller,
|
|
builder: (context, child) {
|
|
return Container(
|
|
width: 18,
|
|
height: 18,
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
bottom: BorderSide(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
width: 2,
|
|
),
|
|
),
|
|
),
|
|
clipBehavior: Clip.hardEdge,
|
|
child: Transform.translate(
|
|
offset: Offset(0, 18 * _controller.value),
|
|
child: Icon(
|
|
Icons.arrow_downward,
|
|
size: 16,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class ImageFavorites extends StatefulWidget {
|
|
const ImageFavorites({super.key});
|
|
|
|
@override
|
|
State<ImageFavorites> createState() => _ImageFavoritesState();
|
|
}
|
|
|
|
class _ImageFavoritesState extends State<ImageFavorites> {
|
|
ImageFavoritesComputed? imageFavoritesCompute;
|
|
|
|
int displayType = 0;
|
|
|
|
void refreshImageFavorites() async {
|
|
try {
|
|
imageFavoritesCompute =
|
|
await ImageFavoriteManager.computeImageFavorites();
|
|
if (mounted) {
|
|
setState(() {});
|
|
}
|
|
} catch (e, stackTrace) {
|
|
Log.error("Unhandled Exception", e.toString(), stackTrace);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
refreshImageFavorites();
|
|
ImageFavoriteManager().addListener(refreshImageFavorites);
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
ImageFavoriteManager().removeListener(refreshImageFavorites);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
bool hasData =
|
|
imageFavoritesCompute != null && !imageFavoritesCompute!.isEmpty;
|
|
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 ImageFavoritesPage());
|
|
},
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
height: 56,
|
|
child: Row(
|
|
children: [
|
|
Center(
|
|
child: Text('Image Favorites'.tl, style: ts.s18),
|
|
),
|
|
const Spacer(),
|
|
const Icon(Icons.arrow_right),
|
|
],
|
|
),
|
|
).paddingHorizontal(16),
|
|
if (hasData)
|
|
Row(
|
|
children: [
|
|
const Spacer(),
|
|
buildTypeButton(0, "Tags".tl),
|
|
const Spacer(),
|
|
buildTypeButton(1, "Authors".tl),
|
|
const Spacer(),
|
|
buildTypeButton(2, "Comics".tl),
|
|
const Spacer(),
|
|
],
|
|
),
|
|
if (hasData) const SizedBox(height: 8),
|
|
if (hasData)
|
|
buildChart(switch (displayType) {
|
|
0 => imageFavoritesCompute!.tags,
|
|
1 => imageFavoritesCompute!.authors,
|
|
2 => imageFavoritesCompute!.comics,
|
|
_ => [],
|
|
})
|
|
.paddingHorizontal(16)
|
|
.paddingBottom(16),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget buildTypeButton(int type, String text) {
|
|
const radius = 24.0;
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(radius),
|
|
onTap: () async {
|
|
setState(() {
|
|
displayType = type;
|
|
});
|
|
await Future.delayed(const Duration(milliseconds: 20));
|
|
var scrollController = ScrollControllerProvider.of(context);
|
|
scrollController.animateTo(
|
|
scrollController.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.ease,
|
|
);
|
|
},
|
|
child: AnimatedContainer(
|
|
width: 96,
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color:
|
|
displayType == type ? context.colorScheme.primaryContainer : null,
|
|
border: Border.all(
|
|
color: Theme.of(context).colorScheme.outlineVariant,
|
|
width: 0.6,
|
|
),
|
|
borderRadius: BorderRadius.circular(radius),
|
|
),
|
|
duration: const Duration(milliseconds: 200),
|
|
child: Center(
|
|
child: Text(
|
|
text,
|
|
style: ts.s16,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget buildChart(List<TextWithCount> data) {
|
|
if (data.isEmpty) {
|
|
return const SizedBox();
|
|
}
|
|
var maxCount = data.map((e) => e.count).reduce((a, b) => a > b ? a : b);
|
|
return ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: 164,
|
|
),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
key: ValueKey(displayType),
|
|
children: data.map((e) {
|
|
return _ChartLine(
|
|
text: e.text,
|
|
count: e.count,
|
|
maxCount: maxCount,
|
|
enableTranslation: displayType != 2,
|
|
onTap: (text) {
|
|
context.to(() => ImageFavoritesPage(initialKeyword: text));
|
|
},
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ChartLine extends StatefulWidget {
|
|
const _ChartLine({
|
|
required this.text,
|
|
required this.count,
|
|
required this.maxCount,
|
|
required this.enableTranslation,
|
|
this.onTap,
|
|
});
|
|
|
|
final String text;
|
|
|
|
final int count;
|
|
|
|
final int maxCount;
|
|
|
|
final bool enableTranslation;
|
|
|
|
final void Function(String text)? onTap;
|
|
|
|
@override
|
|
State<_ChartLine> createState() => __ChartLineState();
|
|
}
|
|
|
|
class __ChartLineState extends State<_ChartLine>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _controller;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 200),
|
|
value: 0,
|
|
)..forward();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
var text = widget.text;
|
|
var enableTranslation =
|
|
App.locale.countryCode == 'CN' && widget.enableTranslation;
|
|
if (enableTranslation) {
|
|
text = text.translateTagsToCN;
|
|
}
|
|
if (widget.enableTranslation && text.contains(':')) {
|
|
text = text.split(':').last;
|
|
}
|
|
return Row(
|
|
children: [
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(4),
|
|
onTap: () {
|
|
widget.onTap?.call(widget.text);
|
|
},
|
|
child: Text(
|
|
text,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
)
|
|
.paddingHorizontal(4)
|
|
.toAlign(Alignment.centerLeft)
|
|
.fixWidth(context.width > 600 ? 120 : 80)
|
|
.fixHeight(double.infinity),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: LayoutBuilder(builder: (context, constrains) {
|
|
var width = constrains.maxWidth * widget.count / widget.maxCount;
|
|
return AnimatedBuilder(
|
|
animation: _controller,
|
|
builder: (context, child) {
|
|
return Container(
|
|
width: width * _controller.value,
|
|
height: 18,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(2),
|
|
gradient: LinearGradient(
|
|
colors: context.isDarkMode
|
|
? [
|
|
Colors.blue.shade800,
|
|
Colors.blue.shade500,
|
|
]
|
|
: [
|
|
Colors.blue.shade300,
|
|
Colors.blue.shade600,
|
|
],
|
|
),
|
|
),
|
|
).toAlign(Alignment.centerLeft);
|
|
},
|
|
);
|
|
}),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
widget.count.toString(),
|
|
style: ts.s12,
|
|
).fixWidth(context.width > 600 ? 60 : 30),
|
|
],
|
|
).fixHeight(28);
|
|
}
|
|
}
|