search page

This commit is contained in:
nyne
2024-10-04 10:37:31 +08:00
parent df9a854cb0
commit 2772289a19
9 changed files with 689 additions and 200 deletions

View File

@@ -314,7 +314,7 @@ class _BodyState extends State<_Body> {
var comicSource = await ComicSourceParser().createAndParse(js, fileName);
ComicSource.add(comicSource);
_addAllPagesWithComicSource(comicSource);
appdata.saveSettings();
appdata.saveData();
App.forceRebuild();
}
}
@@ -429,7 +429,7 @@ void _validatePages() {
appdata.settings['categories'] = categoryPages.toSet().toList();
appdata.settings['favorites'] = networkFavorites.toSet().toList();
appdata.saveSettings();
appdata.saveData();
}
void _addAllPagesWithComicSource(ComicSource source) {
@@ -457,5 +457,5 @@ void _addAllPagesWithComicSource(ComicSource source) {
appdata.settings['categories'] = categoryPages.toSet().toList();
appdata.settings['favorites'] = networkFavorites.toSet().toList();
appdata.saveSettings();
appdata.saveData();
}

View File

@@ -6,11 +6,13 @@ 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/comic_type.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/search_page.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart';
@@ -19,14 +21,50 @@ class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const SmoothCustomScrollView(
var widget = const SmoothCustomScrollView(
slivers: [
_SearchBar(),
_History(),
_Local(),
_ComicSourceWidget(),
_AccountsWidget(),
],
);
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.surfaceContainer,
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(),
],
),
),
),
),
);
}
}
@@ -65,17 +103,18 @@ class _HistoryState extends State<_History> {
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: InkWell(
onTap: () {},
child: Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
),
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: () {},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -182,17 +221,18 @@ class _LocalState extends State<_Local> {
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: InkWell(
onTap: () {},
child: Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
),
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: () {},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -320,7 +360,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
child: const Center(
child: CircularProgressIndicator(),
),
)
)
: Column(
key: key,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -411,20 +451,20 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
});
final picker = DirectoryPicker();
final path = await picker.pickDirectory();
if(!loading) {
if (!loading) {
picker.dispose();
return;
}
if(path == null) {
if (path == null) {
setState(() {
loading = false;
});
return;
}
Map<Directory, LocalComic> comics = {};
if(type == 0) {
if (type == 0) {
var result = await checkSingleComic(path);
if(result != null) {
if (result != null) {
comics[path] = result;
} else {
context.showMessage(message: "Invalid Comic".tl);
@@ -434,31 +474,30 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
return;
}
} else {
await for(var entry in path.list()) {
if(entry is Directory) {
await for (var entry in path.list()) {
if (entry is Directory) {
var result = await checkSingleComic(entry);
if(result != null) {
if (result != null) {
comics[entry] = result;
}
}
}
}
bool shouldCopy = true;
for(var comic in comics.keys) {
if(comic.parent.path == LocalManager().path) {
for (var comic in comics.keys) {
if (comic.parent.path == LocalManager().path) {
shouldCopy = false;
break;
}
}
if(shouldCopy && comics.isNotEmpty) {
if (shouldCopy && comics.isNotEmpty) {
try {
// copy the comics to the local directory
await compute<Map<String, dynamic>, void>(_copyDirectories, {
'toBeCopied': comics.keys.map((e) => e.path).toList(),
'destination': LocalManager().path,
});
}
catch(e) {
} catch (e) {
context.showMessage(message: "Failed to import comics".tl);
Log.error("Import Comic", e.toString());
setState(() {
@@ -467,11 +506,12 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
return;
}
}
for(var comic in comics.values) {
for (var comic in comics.values) {
LocalManager().add(comic, LocalManager().findValidId(ComicType.local));
}
context.pop();
context.showMessage(message: "Imported @a comics".tlParams({
context.showMessage(
message: "Imported @a comics".tlParams({
'a': comics.length,
}));
}
@@ -479,14 +519,16 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
static _copyDirectories(Map<String, dynamic> data) {
var toBeCopied = data['toBeCopied'] as List<String>;
var destination = data['destination'] as String;
for(var dir in toBeCopied) {
for (var dir in toBeCopied) {
var source = Directory(dir);
var dest = Directory("$destination/${source.name}");
if(dest.existsSync()) {
if (dest.existsSync()) {
// The destination directory already exists, and it is not managed by the app.
// Rename the old directory to avoid conflicts.
Log.info("Import Comic", "Directory already exists: ${source.name}\nRenaming the old directory.");
dest.rename(findValidDirectoryName(dest.parent.path, "${dest.path}_old"));
Log.info("Import Comic",
"Directory already exists: ${source.name}\nRenaming the old directory.");
dest.rename(
findValidDirectoryName(dest.parent.path, "${dest.path}_old"));
}
dest.createSync();
copyDirectory(source, dest);
@@ -494,47 +536,49 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
}
Future<LocalComic?> checkSingleComic(Directory directory) async {
if(!(await directory.exists())) return null;
if (!(await directory.exists())) return null;
var name = directory.name;
bool hasChapters = false;
var chapters = <String>[];
var coverPath = ''; // relative path to the cover image
await for(var entry in directory.list()) {
if(entry is Directory) {
await for (var entry in directory.list()) {
if (entry is Directory) {
hasChapters = true;
if(LocalManager().findByName(entry.name) != null) {
if (LocalManager().findByName(entry.name) != null) {
Log.info("Import Comic", "Comic already exists: $name");
return null;
}
chapters.add(entry.name);
await for(var file in entry.list()) {
if(file is Directory) {
Log.info("Import Comic", "Invalid Chapter: ${entry.name}\nA directory is found in the chapter directory.");
await for (var file in entry.list()) {
if (file is Directory) {
Log.info("Import Comic",
"Invalid Chapter: ${entry.name}\nA directory is found in the chapter directory.");
return null;
}
}
} else if(entry is File){
if(entry.name.startsWith('cover')) {
} else if (entry is File) {
if (entry.name.startsWith('cover')) {
coverPath = entry.name;
}
const imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'];
if(!coverPath.startsWith('cover') && imageExtensions.contains(entry.extension)) {
if (!coverPath.startsWith('cover') &&
imageExtensions.contains(entry.extension)) {
coverPath = entry.name;
}
}
}
chapters.sort();
if(hasChapters && coverPath == '') {
if (hasChapters && coverPath == '') {
// use the first image in the first chapter as the cover
var firstChapter = Directory('${directory.path}/${chapters.first}');
await for(var entry in firstChapter.list()) {
if(entry is File) {
await for (var entry in firstChapter.list()) {
if (entry is File) {
coverPath = entry.name;
break;
}
}
}
if(coverPath == '') {
if (coverPath == '') {
Log.info("Import Comic", "Invalid Comic: $name\nNo cover image found.");
return null;
}
@@ -584,19 +628,20 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: InkWell(
onTap: () {
context.to(() => const ComicSourcePage());
},
child: Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
),
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: [
@@ -615,14 +660,15 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(comicSources.length.toString(), style: ts.s12),
child:
Text(comicSources.length.toString(), style: ts.s12),
),
const Spacer(),
const Icon(Icons.arrow_right),
],
),
).paddingHorizontal(16),
if(comicSources.isNotEmpty)
if (comicSources.isNotEmpty)
SizedBox(
width: double.infinity,
child: Wrap(
@@ -633,7 +679,8 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
color:
Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(e),
@@ -661,8 +708,8 @@ class _AccountsWidgetState extends State<_AccountsWidget> {
void onComicSourceChange() {
setState(() {
for(var c in ComicSource.all()) {
if(c.isLogged) {
for (var c in ComicSource.all()) {
if (c.isLogged) {
accounts.add(c.name);
}
}
@@ -672,8 +719,8 @@ class _AccountsWidgetState extends State<_AccountsWidget> {
@override
void initState() {
accounts = [];
for(var c in ComicSource.all()) {
if(c.isLogged) {
for (var c in ComicSource.all()) {
if (c.isLogged) {
accounts.add(c.name);
}
}
@@ -690,17 +737,18 @@ class _AccountsWidgetState extends State<_AccountsWidget> {
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: InkWell(
onTap: () {},
child: Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
),
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: () {},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:venera/pages/categories_page.dart';
import 'package:venera/pages/search_page.dart';
import 'package:venera/utils/translations.dart';
import '../components/components.dart';
@@ -48,6 +49,8 @@ class _MainPageState extends State<MainPage> {
const CategoriesPage(),
];
var index = 0;
@override
Widget build(BuildContext context) {
return NaviPane(
@@ -75,11 +78,14 @@ class _MainPageState extends State<MainPage> {
),
],
paneActions: [
PaneActionEntry(
icon: Icons.search,
label: "Search".tl,
onTap: () {},
),
if(index != 0)
PaneActionEntry(
icon: Icons.search,
label: "Search".tl,
onTap: () {
to(() => const SearchPage());
},
),
PaneActionEntry(
icon: Icons.settings,
label: "Settings".tl,
@@ -100,6 +106,9 @@ class _MainPageState extends State<MainPage> {
);
},
onPageChange: (index) {
setState(() {
this.index = index;
});
_navigatorKey!.currentState?.pushAndRemoveUntil(
AppPageRoute(
preventRebuild: false,

173
lib/pages/search_page.dart Normal file
View File

@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/utils/translations.dart';
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
late final SearchBarController controller;
String searchTarget = "";
var options = <String>[];
void update() {
setState(() {});
}
void search([String? text]) {
context.to(
() => SearchResultPage(
text: text ?? controller.text,
sourceKey: searchTarget,
options: options,
),
);
}
@override
void initState() {
var defaultSearchTarget = appdata.settings['defaultSearchTarget'];
if (defaultSearchTarget != null &&
ComicSource.find(defaultSearchTarget) != null) {
searchTarget = defaultSearchTarget;
} else {
searchTarget = ComicSource.all().first.key;
}
controller = SearchBarController(
onSearch: search,
);
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SmoothCustomScrollView(
slivers: [
SliverSearchBar(controller: controller),
buildSearchTarget(),
buildSearchOptions(),
],
),
);
}
Widget buildSearchTarget() {
var sources =
ComicSource.all().where((e) => e.searchPageData != null).toList();
return SliverToBoxAdapter(
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
title: Text("Search From".tl),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: sources.map((e) {
return OptionChip(
text: e.name.tl,
isSelected: searchTarget == e.key,
onTap: () {
setState(() {
searchTarget = e.key;
});
},
);
}).toList(),
),
],
),
),
);
}
Widget buildSearchOptions() {
var children = <Widget>[];
final searchOptions =
ComicSource.find(searchTarget)!.searchPageData!.searchOptions ??
<SearchOptions>[];
if (searchOptions.length != options.length) {
options = searchOptions.map((e) => e.defaultValue).toList();
}
if (searchOptions.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox());
}
for (int i = 0; i < searchOptions.length; i++) {
final option = searchOptions[i];
children.add(ListTile(
title: Text(option.label.tl),
));
children.add(Wrap(
runSpacing: 8,
spacing: 8,
children: option.options.entries.map((e) {
return OptionChip(
text: e.value.tl,
isSelected: options[i] == e.key,
onTap: () {
options[i] = e.key;
update();
},
);
}).toList(),
).paddingHorizontal(16));
}
return SliverToBoxAdapter(
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
title: Text("Search Options".tl),
),
...children,
],
),
),
);
}
Widget buildSearchHistory() {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == 0) {
return ListTile(
contentPadding: EdgeInsets.zero,
title: Text("Search History".tl),
);
}
return ListTile(
contentPadding: EdgeInsets.zero,
title: Text(appdata.searchHistory[index - 1]),
onTap: () {
search(appdata.searchHistory[index - 1]);
},
);
},
childCount: 1 + appdata.searchHistory.length,
),
).sliverPaddingHorizontal(16);
}
}

View File

@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
class SearchResultPage extends StatefulWidget {
const SearchResultPage({
super.key,
required this.text,
required this.sourceKey,
required this.options,
});
final String text;
final String sourceKey;
final List<String> options;
@override
State<SearchResultPage> createState() => _SearchResultPageState();
}
class _SearchResultPageState extends State<SearchResultPage> {
late SearchBarController controller;
late String sourceKey;
late List<String> options;
void search([String? text]) {}
@override
void initState() {
controller = SearchBarController(
initialText: widget.text,
onSearch: search,
);
sourceKey = widget.sourceKey;
options = widget.options;
super.initState();
}
@override
Widget build(BuildContext context) {
return ComicList(
errorLeading: AppSearchBar(
controller: controller,
),
leadingSliver: SliverSearchBar(
controller: controller,
),
loadPage: (i) {
var source = ComicSource.find(sourceKey);
return source!.searchPageData!.loadPage!(
controller.initialText,
i,
options,
);
},
);
}
}