import 'package:flutter/foundation.dart'; import 'package:flutter/material.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/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/accounts_page.dart'; import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/history_page.dart'; import 'package:venera/pages/search_page.dart'; import 'package:venera/utils/io.dart'; import 'package:venera/utils/translations.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 _History(), const _Local(), const _ComicSourceWidget(), const _AccountsWidget(), 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.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(), ], ), ), ), ), ); } } class _History extends StatefulWidget { const _History(); @override State<_History> createState() => _HistoryState(); } class _HistoryState extends State<_History> { late List 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 InkWell( onTap: () { context.to( () => ComicPage( id: history[index].id, sourceKey: history[index].type.comicSource!.key, ), ); }, borderRadius: BorderRadius.circular(8), child: Container( width: 96, height: 128, margin: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Theme.of(context) .colorScheme .secondaryContainer, ), clipBehavior: Clip.antiAlias, child: AnimatedImage( image: CachedImageProvider( history[index].cover, sourceKey: history[index].type.comicSource?.key, ), width: 96, height: 128, fit: BoxFit.cover, filterQuality: FilterQuality.medium, ), ), ); }, ), ).paddingHorizontal(8).paddingBottom(16), ], ), ), ), ); } } class _Local extends StatefulWidget { const _Local(); @override State<_Local> createState() => _LocalState(); } class _LocalState extends State<_Local> { late List 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: () {}, 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 InkWell( onTap: () { // TODO: view local comic }, borderRadius: BorderRadius.circular(8), child: Container( width: 96, height: 128, margin: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Theme.of(context) .colorScheme .secondaryContainer, ), clipBehavior: Clip.antiAlias, child: AnimatedImage( image: FileImage( local[index].coverFile, ), width: 96, height: 128, fit: BoxFit.cover, filterQuality: FilterQuality.medium, ), ), ); }, ), ).paddingHorizontal(8), Row( children: [ 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; @override void dispose() { loading = false; super.dispose(); } @override Widget build(BuildContext context) { String info = type == 0 ? "Select a directory which contains the comic files.".tl : "Select a directory which contains the comic directories.".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), RadioListTile( title: Text("Single Comic".tl), value: 0, groupValue: type, onChanged: (value) { setState(() { type = value as int; }); }, ), RadioListTile( title: Text("Multiple Comics".tl), value: 1, groupValue: type, onChanged: (value) { setState(() { type = value as int; }); }, ), 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.transparent, 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." .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; }); final picker = DirectoryPicker(); final path = await picker.pickDirectory(); if (!loading) { picker.dispose(); return; } if (path == null) { setState(() { loading = false; }); return; } Map comics = {}; if (type == 0) { var result = await checkSingleComic(path); if (result != null) { comics[path] = result; } else { context.showMessage(message: "Invalid Comic".tl); setState(() { loading = false; }); return; } } else { await for (var entry in path.list()) { if (entry is Directory) { var result = await checkSingleComic(entry); if (result != null) { comics[entry] = result; } } } } bool shouldCopy = true; for (var comic in comics.keys) { if (comic.parent.path == LocalManager().path) { shouldCopy = false; break; } } if (shouldCopy && comics.isNotEmpty) { try { // copy the comics to the local directory await compute, void>(_copyDirectories, { 'toBeCopied': comics.keys.map((e) => e.path).toList(), 'destination': LocalManager().path, }); } catch (e) { context.showMessage(message: "Failed to import comics".tl); Log.error("Import Comic", e.toString()); setState(() { loading = false; }); return; } } for (var comic in comics.values) { LocalManager().add(comic, LocalManager().findValidId(ComicType.local)); } context.pop(); context.showMessage( message: "Imported @a comics".tlParams({ 'a': comics.length, })); } static _copyDirectories(Map data) { var toBeCopied = data['toBeCopied'] as List; var destination = data['destination'] as String; for (var dir in toBeCopied) { var source = Directory(dir); var dest = Directory("$destination/${source.name}"); 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")); } dest.createSync(); copyDirectory(source, dest); } } Future checkSingleComic(Directory directory) async { if (!(await directory.exists())) return null; var name = directory.name; bool hasChapters = false; var chapters = []; var coverPath = ''; // relative path to the cover image await for (var entry in directory.list()) { if (entry is Directory) { hasChapters = true; 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."); return null; } } } 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)) { coverPath = entry.name; } } } chapters.sort(); 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) { coverPath = entry.name; break; } } } if (coverPath == '') { Log.info("Import Comic", "Invalid Comic: $name\nNo cover image found."); return null; } return LocalComic( id: '0', title: name, subtitle: '', tags: [], directory: directory.name, chapters: Map.fromIterables(chapters, chapters), cover: coverPath, comicType: ComicType.local, createdAt: DateTime.now(), ); } } class _ComicSourceWidget extends StatefulWidget { const _ComicSourceWidget(); @override State<_ComicSourceWidget> createState() => _ComicSourceWidgetState(); } class _ComicSourceWidgetState extends State<_ComicSourceWidget> { late List 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 accounts; void onComicSourceChange() { setState(() { 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( children: accounts.map((e) { return 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(e), ); }).toList(), ), ), ], ), ), ), ); } }