mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
search page
This commit is contained in:
@@ -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 {
|
||||
const FilledTabBar({super.key, this.controller, required this.tabs});
|
||||
|
||||
@@ -431,10 +349,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
|
||||
),
|
||||
),
|
||||
),
|
||||
child: widget.tabs.isEmpty
|
||||
? const SizedBox()
|
||||
: child
|
||||
);
|
||||
child: widget.tabs.isEmpty ? const SizedBox() : child);
|
||||
}
|
||||
|
||||
int? previousIndex;
|
||||
@@ -611,8 +526,8 @@ class _IndicatorPainter extends CustomPainter {
|
||||
final Rect toRect = indicatorRect(size, to);
|
||||
_currentRect = Rect.lerp(fromRect, toRect, (value - from).abs());
|
||||
final Paint paint = Paint()..color = color;
|
||||
final RRect rrect =
|
||||
RRect.fromRectAndCorners(_currentRect!, topLeft: Radius.circular(radius), topRight: Radius.circular(radius));
|
||||
final RRect rrect = RRect.fromRectAndCorners(_currentRect!,
|
||||
topLeft: Radius.circular(radius), topRight: Radius.circular(radius));
|
||||
canvas.drawRRect(rrect, paint);
|
||||
}
|
||||
|
||||
@@ -621,3 +536,239 @@ class _IndicatorPainter extends CustomPainter {
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -505,6 +505,7 @@ class ComicList extends StatefulWidget {
|
||||
this.loadNext,
|
||||
this.leadingSliver,
|
||||
this.trailingSliver,
|
||||
this.errorLeading,
|
||||
});
|
||||
|
||||
final Future<Res<List<Comic>>> Function(int page)? loadPage;
|
||||
@@ -515,6 +516,8 @@ class ComicList extends StatefulWidget {
|
||||
|
||||
final Widget? trailingSliver;
|
||||
|
||||
final Widget? errorLeading;
|
||||
|
||||
@override
|
||||
State<ComicList> createState() => _ComicListState();
|
||||
}
|
||||
@@ -691,6 +694,7 @@ class _ComicListState extends State<ComicList> {
|
||||
if (error != null) {
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.errorLeading != null) widget.errorLeading!,
|
||||
buildPageSelector(),
|
||||
Expanded(
|
||||
child: NetworkError(
|
||||
@@ -717,6 +721,7 @@ class _ComicListState extends State<ComicList> {
|
||||
if (widget.leadingSliver != null) widget.leadingSliver!,
|
||||
buildSliverPageSelector(),
|
||||
SliverGridComics(comics: data[page] ?? const []),
|
||||
if(data[page]!.length > 6)
|
||||
buildSliverPageSelector(),
|
||||
if (widget.trailingSliver != null) widget.trailingSliver!,
|
||||
],
|
||||
|
@@ -2,6 +2,7 @@ import 'dart:math';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
|
||||
const double _kBackGestureWidth = 20.0;
|
||||
const int _kMaxDroppedSwipePageForwardAnimationTime = 800;
|
||||
@@ -35,7 +36,11 @@ class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
|
||||
@override
|
||||
Widget buildContent(BuildContext context) {
|
||||
var widget = builder(context);
|
||||
if(widget is NaviPaddingWidget) {
|
||||
label = widget.child.runtimeType.toString();
|
||||
} else {
|
||||
label = widget.runtimeType.toString();
|
||||
}
|
||||
return widget;
|
||||
}
|
||||
|
||||
|
@@ -6,17 +6,52 @@ import 'package:venera/utils/io.dart';
|
||||
class _Appdata {
|
||||
final _Settings settings = _Settings();
|
||||
|
||||
void saveSettings() async {
|
||||
var data = jsonEncode(settings._data);
|
||||
var file = File(FilePath.join(App.dataPath, 'settings.json'));
|
||||
var searchHistory = <String>[];
|
||||
|
||||
void saveData() async {
|
||||
var data = jsonEncode(toJson());
|
||||
var file = File(FilePath.join(App.dataPath, 'appdata.json'));
|
||||
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 {
|
||||
var json = jsonDecode(await File(FilePath.join(App.dataPath, 'settings.json')).readAsString()) as Map<String, dynamic>;
|
||||
for(var key in json.keys) {
|
||||
var file = File(FilePath.join(App.dataPath, 'appdata.json'));
|
||||
if(!await file.exists()) {
|
||||
return;
|
||||
}
|
||||
var json = jsonDecode(await file.readAsString());
|
||||
for(var key in json['settings'].keys) {
|
||||
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,
|
||||
'showHistoryStatusOnTile': false,
|
||||
'blockedWords': [],
|
||||
'defaultSearchTarget': null,
|
||||
};
|
||||
|
||||
operator[](String key) {
|
||||
|
@@ -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();
|
||||
}
|
||||
|
@@ -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(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
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(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
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: [
|
||||
@@ -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(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
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(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
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: [
|
||||
|
@@ -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,10 +78,13 @@ class _MainPageState extends State<MainPage> {
|
||||
),
|
||||
],
|
||||
paneActions: [
|
||||
if(index != 0)
|
||||
PaneActionEntry(
|
||||
icon: Icons.search,
|
||||
label: "Search".tl,
|
||||
onTap: () {},
|
||||
onTap: () {
|
||||
to(() => const SearchPage());
|
||||
},
|
||||
),
|
||||
PaneActionEntry(
|
||||
icon: Icons.settings,
|
||||
@@ -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
173
lib/pages/search_page.dart
Normal 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);
|
||||
}
|
||||
}
|
62
lib/pages/search_result_page.dart
Normal file
62
lib/pages/search_result_page.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user