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

@@ -233,88 +233,6 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
} }
} }
class FloatingSearchBar extends StatefulWidget {
const FloatingSearchBar({
super.key,
this.height = 56,
this.trailing,
required this.onSearch,
required this.controller,
this.onChanged,
});
/// height of search bar
final double height;
/// end of search bar
final Widget? trailing;
/// callback when user do search
final void Function(String) onSearch;
/// controller of [TextField]
final TextEditingController controller;
final void Function(String)? onChanged;
@override
State<FloatingSearchBar> createState() => _FloatingSearchBarState();
}
class _FloatingSearchBarState extends State<FloatingSearchBar> {
double get effectiveHeight {
return math.max(widget.height, 53);
}
@override
Widget build(BuildContext context) {
final ColorScheme colorScheme = Theme.of(context).colorScheme;
var text = widget.controller.text;
if (text.isEmpty) {
text = "Search";
}
var padding = 12.0;
return Container(
padding: EdgeInsets.fromLTRB(padding, 9, padding, 0),
width: double.infinity,
height: effectiveHeight,
child: Material(
elevation: 0,
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(effectiveHeight / 2),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(children: [
Tooltip(
message: "返回".tl,
child: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: TextField(
controller: widget.controller,
decoration: const InputDecoration(
border: InputBorder.none,
),
onSubmitted: (s) {
widget.onSearch(s);
},
onChanged: widget.onChanged,
),
),
),
if (widget.trailing != null) widget.trailing!
]),
),
),
);
}
}
class FilledTabBar extends StatefulWidget { class FilledTabBar extends StatefulWidget {
const FilledTabBar({super.key, this.controller, required this.tabs}); const FilledTabBar({super.key, this.controller, required this.tabs});
@@ -431,10 +349,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
), ),
), ),
), ),
child: widget.tabs.isEmpty child: widget.tabs.isEmpty ? const SizedBox() : child);
? const SizedBox()
: child
);
} }
int? previousIndex; int? previousIndex;
@@ -611,8 +526,8 @@ class _IndicatorPainter extends CustomPainter {
final Rect toRect = indicatorRect(size, to); final Rect toRect = indicatorRect(size, to);
_currentRect = Rect.lerp(fromRect, toRect, (value - from).abs()); _currentRect = Rect.lerp(fromRect, toRect, (value - from).abs());
final Paint paint = Paint()..color = color; final Paint paint = Paint()..color = color;
final RRect rrect = final RRect rrect = RRect.fromRectAndCorners(_currentRect!,
RRect.fromRectAndCorners(_currentRect!, topLeft: Radius.circular(radius), topRight: Radius.circular(radius)); topLeft: Radius.circular(radius), topRight: Radius.circular(radius));
canvas.drawRRect(rrect, paint); canvas.drawRRect(rrect, paint);
} }
@@ -621,3 +536,239 @@ class _IndicatorPainter extends CustomPainter {
return false; return false;
} }
} }
class SearchBarController {
_SearchBarMixin? _state;
final void Function(String text)? onSearch;
final String initialText;
void setText(String text) {
_state?.setText(text);
}
String get text => _state?.getText() ?? '';
SearchBarController({this.onSearch, this.initialText = ''});
}
abstract mixin class _SearchBarMixin {
void setText(String text);
String getText();
}
class SliverSearchBar extends StatefulWidget {
const SliverSearchBar({super.key, required this.controller});
final SearchBarController controller;
@override
State<SliverSearchBar> createState() => _SliverSearchBarState();
}
class _SliverSearchBarState extends State<SliverSearchBar>
with _SearchBarMixin {
late TextEditingController _editingController;
late SearchBarController _controller;
@override
void initState() {
_controller = widget.controller;
_controller._state = this;
_editingController = TextEditingController(text: _controller.initialText);
super.initState();
}
@override
void setText(String text) {
_editingController.text = text;
}
@override
String getText() {
return _editingController.text;
}
@override
Widget build(BuildContext context) {
return SliverPersistentHeader(
delegate: _SliverSearchBarDelegate(
editingController: _editingController,
controller: _controller,
topPadding: MediaQuery.of(context).padding.top,
),
);
}
}
class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate {
final TextEditingController editingController;
final SearchBarController controller;
final double topPadding;
const _SliverSearchBarDelegate({
required this.editingController,
required this.controller,
required this.topPadding,
});
static const _kAppBarHeight = 52.0;
@override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return Container(
height: _kAppBarHeight + topPadding,
width: double.infinity,
padding: EdgeInsets.only(top: topPadding),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
),
child: Row(
children: [
const SizedBox(width: 8),
const BackButton(),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: TextField(
controller: editingController,
decoration: InputDecoration(
hintText: "Search".tl,
border: InputBorder.none,
),
onSubmitted: (text) {
controller.onSearch?.call(text);
},
),
),
),
ListenableBuilder(
listenable: editingController,
builder: (context, child) {
return editingController.text.isEmpty
? const SizedBox()
: IconButton(
iconSize: 20,
icon: const Icon(Icons.clear),
onPressed: () {
editingController.clear();
},
);
},
),
const SizedBox(width: 8),
],
),
);
}
@override
double get maxExtent => _kAppBarHeight + topPadding;
@override
double get minExtent => _kAppBarHeight + topPadding;
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
return oldDelegate is! _SliverSearchBarDelegate ||
editingController != oldDelegate.editingController ||
controller != oldDelegate.controller ||
topPadding != oldDelegate.topPadding;
}
}
class AppSearchBar extends StatefulWidget {
const AppSearchBar({super.key, required this.controller});
final SearchBarController controller;
@override
State<AppSearchBar> createState() => _SearchBarState();
}
class _SearchBarState extends State<AppSearchBar> with _SearchBarMixin {
late TextEditingController _editingController;
late SearchBarController _controller;
@override
void setText(String text) {
_editingController.text = text;
}
@override
String getText() {
return _editingController.text;
}
@override
void initState() {
_controller = widget.controller;
_controller._state = this;
_editingController = TextEditingController(text: _controller.initialText);
super.initState();
}
@override
Widget build(BuildContext context) {
final topPadding = MediaQuery.of(context).padding.top;
return Container(
height: _kAppBarHeight + topPadding,
width: double.infinity,
padding: EdgeInsets.only(top: topPadding),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
),
child: Row(
children: [
const SizedBox(width: 8),
const BackButton(),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: TextField(
controller: _editingController,
decoration: InputDecoration(
hintText: "Search".tl,
border: InputBorder.none,
),
onSubmitted: (text) {
_controller.onSearch?.call(text);
},
),
),
),
ListenableBuilder(
listenable: _editingController,
builder: (context, child) {
return _editingController.text.isEmpty
? const SizedBox()
: IconButton(
iconSize: 20,
icon: const Icon(Icons.clear),
onPressed: () {
_editingController.clear();
},
);
},
),
const SizedBox(width: 8),
],
),
);
}
}

View File

@@ -505,6 +505,7 @@ class ComicList extends StatefulWidget {
this.loadNext, this.loadNext,
this.leadingSliver, this.leadingSliver,
this.trailingSliver, this.trailingSliver,
this.errorLeading,
}); });
final Future<Res<List<Comic>>> Function(int page)? loadPage; final Future<Res<List<Comic>>> Function(int page)? loadPage;
@@ -515,6 +516,8 @@ class ComicList extends StatefulWidget {
final Widget? trailingSliver; final Widget? trailingSliver;
final Widget? errorLeading;
@override @override
State<ComicList> createState() => _ComicListState(); State<ComicList> createState() => _ComicListState();
} }
@@ -691,6 +694,7 @@ class _ComicListState extends State<ComicList> {
if (error != null) { if (error != null) {
return Column( return Column(
children: [ children: [
if (widget.errorLeading != null) widget.errorLeading!,
buildPageSelector(), buildPageSelector(),
Expanded( Expanded(
child: NetworkError( child: NetworkError(
@@ -717,6 +721,7 @@ class _ComicListState extends State<ComicList> {
if (widget.leadingSliver != null) widget.leadingSliver!, if (widget.leadingSliver != null) widget.leadingSliver!,
buildSliverPageSelector(), buildSliverPageSelector(),
SliverGridComics(comics: data[page] ?? const []), SliverGridComics(comics: data[page] ?? const []),
if(data[page]!.length > 6)
buildSliverPageSelector(), buildSliverPageSelector(),
if (widget.trailingSliver != null) widget.trailingSliver!, if (widget.trailingSliver != null) widget.trailingSliver!,
], ],

View File

@@ -2,6 +2,7 @@ import 'dart:math';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/components/components.dart';
const double _kBackGestureWidth = 20.0; const double _kBackGestureWidth = 20.0;
const int _kMaxDroppedSwipePageForwardAnimationTime = 800; const int _kMaxDroppedSwipePageForwardAnimationTime = 800;
@@ -35,7 +36,11 @@ class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
@override @override
Widget buildContent(BuildContext context) { Widget buildContent(BuildContext context) {
var widget = builder(context); var widget = builder(context);
if(widget is NaviPaddingWidget) {
label = widget.child.runtimeType.toString();
} else {
label = widget.runtimeType.toString(); label = widget.runtimeType.toString();
}
return widget; return widget;
} }

View File

@@ -6,17 +6,52 @@ import 'package:venera/utils/io.dart';
class _Appdata { class _Appdata {
final _Settings settings = _Settings(); final _Settings settings = _Settings();
void saveSettings() async { var searchHistory = <String>[];
var data = jsonEncode(settings._data);
var file = File(FilePath.join(App.dataPath, 'settings.json')); void saveData() async {
var data = jsonEncode(toJson());
var file = File(FilePath.join(App.dataPath, 'appdata.json'));
await file.writeAsString(data); await file.writeAsString(data);
} }
void addSearchHistory(String keyword) {
if(searchHistory.contains(keyword)) {
searchHistory.remove(keyword);
}
searchHistory.insert(0, keyword);
if(searchHistory.length > 50) {
searchHistory.removeLast();
}
saveData();
}
void removeSearchHistory(String keyword) {
searchHistory.remove(keyword);
saveData();
}
void clearSearchHistory() {
searchHistory.clear();
saveData();
}
Future<void> init() async { Future<void> init() async {
var json = jsonDecode(await File(FilePath.join(App.dataPath, 'settings.json')).readAsString()) as Map<String, dynamic>; var file = File(FilePath.join(App.dataPath, 'appdata.json'));
for(var key in json.keys) { if(!await file.exists()) {
return;
}
var json = jsonDecode(await file.readAsString());
for(var key in json['settings'].keys) {
settings[key] = json[key]; settings[key] = json[key];
} }
searchHistory = List.from(json['searchHistory']);
}
Map<String, dynamic> toJson() {
return {
'settings': settings._data,
'searchHistory': searchHistory,
};
} }
} }
@@ -39,6 +74,7 @@ class _Settings {
'showFavoriteStatusOnTile': true, 'showFavoriteStatusOnTile': true,
'showHistoryStatusOnTile': false, 'showHistoryStatusOnTile': false,
'blockedWords': [], 'blockedWords': [],
'defaultSearchTarget': null,
}; };
operator[](String key) { operator[](String key) {

View File

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

View File

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