mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
initial commit
This commit is contained in:
10
lib/pages/categories_page.dart
Normal file
10
lib/pages/categories_page.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CategoriesPage extends StatelessWidget {
|
||||
const CategoriesPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Placeholder();
|
||||
}
|
||||
}
|
10
lib/pages/explore_page.dart
Normal file
10
lib/pages/explore_page.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ExplorePage extends StatelessWidget {
|
||||
const ExplorePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Placeholder();
|
||||
}
|
||||
}
|
10
lib/pages/favorites_page.dart
Normal file
10
lib/pages/favorites_page.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FavoritesPage extends StatelessWidget {
|
||||
const FavoritesPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Placeholder();
|
||||
}
|
||||
}
|
498
lib/pages/home_page.dart
Normal file
498
lib/pages/home_page.dart
Normal file
@@ -0,0 +1,498 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
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_type.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/utils/io.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SmoothCustomScrollView(
|
||||
slivers: [
|
||||
_History(),
|
||||
_Local(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _History extends StatelessWidget {
|
||||
const _History();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final history = HistoryManager().getRecent();
|
||||
final count = HistoryManager().count();
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: InkWell(
|
||||
onTap: () {},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
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: () {
|
||||
// TODO: toComicPageWithHistory(context, history[index]);
|
||||
},
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Local extends StatelessWidget {
|
||||
const _Local();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final local = LocalManager().getRecent();
|
||||
final count = LocalManager().count;
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: InkWell(
|
||||
onTap: () {},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
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({super.key});
|
||||
|
||||
@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<Directory, LocalComic> 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<Map<String, dynamic>, 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<String, dynamic> data) {
|
||||
var toBeCopied = data['toBeCopied'] as List<String>;
|
||||
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<LocalComic?> checkSingleComic(Directory directory) async {
|
||||
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) {
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
116
lib/pages/main_page.dart
Normal file
116
lib/pages/main_page.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/pages/categories_page.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
import '../components/components.dart';
|
||||
import '../foundation/app.dart';
|
||||
import '../foundation/app_page_route.dart';
|
||||
import 'explore_page.dart';
|
||||
import 'favorites_page.dart';
|
||||
import 'home_page.dart';
|
||||
|
||||
class MainPage extends StatefulWidget {
|
||||
const MainPage({super.key});
|
||||
|
||||
@override
|
||||
State<MainPage> createState() => _MainPageState();
|
||||
}
|
||||
|
||||
class _MainPageState extends State<MainPage> {
|
||||
late final NaviObserver _observer;
|
||||
|
||||
GlobalKey<NavigatorState>? _navigatorKey;
|
||||
|
||||
void to(Widget Function() widget, {bool preventDuplicate = false}) async {
|
||||
if (preventDuplicate) {
|
||||
var page = widget();
|
||||
if ("/${page.runtimeType}" == _observer.routes.last.toString()) return;
|
||||
}
|
||||
_navigatorKey!.currentContext!.to(widget);
|
||||
}
|
||||
|
||||
void back() {
|
||||
_navigatorKey!.currentContext!.pop();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_observer = NaviObserver();
|
||||
_navigatorKey = GlobalKey();
|
||||
App.mainNavigatorKey = _navigatorKey;
|
||||
super.initState();
|
||||
}
|
||||
|
||||
final _pages = [
|
||||
const HomePage(),
|
||||
const FavoritesPage(),
|
||||
const ExplorePage(),
|
||||
const CategoriesPage(),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NaviPane(
|
||||
observer: _observer,
|
||||
paneItems: [
|
||||
PaneItemEntry(
|
||||
label: 'Home'.tl,
|
||||
icon: Icons.home_outlined,
|
||||
activeIcon: Icons.home,
|
||||
),
|
||||
PaneItemEntry(
|
||||
label: 'Favorites'.tl,
|
||||
icon: Icons.local_activity_outlined,
|
||||
activeIcon: Icons.local_activity,
|
||||
),
|
||||
PaneItemEntry(
|
||||
label: 'Explore'.tl,
|
||||
icon: Icons.explore_outlined,
|
||||
activeIcon: Icons.explore,
|
||||
),
|
||||
PaneItemEntry(
|
||||
label: 'Categories'.tl,
|
||||
icon: Icons.category_outlined,
|
||||
activeIcon: Icons.category,
|
||||
),
|
||||
],
|
||||
paneActions: [
|
||||
PaneActionEntry(
|
||||
icon: Icons.search,
|
||||
label: "Search".tl,
|
||||
onTap: () {},
|
||||
),
|
||||
PaneActionEntry(
|
||||
icon: Icons.settings,
|
||||
label: "Settings".tl,
|
||||
onTap: () {},
|
||||
)
|
||||
],
|
||||
pageBuilder: (index) {
|
||||
return Navigator(
|
||||
observers: [_observer],
|
||||
key: _navigatorKey,
|
||||
onGenerateRoute: (settings) => AppPageRoute(
|
||||
preventRebuild: false,
|
||||
isRootRoute: true,
|
||||
builder: (context) {
|
||||
return NaviPaddingWidget(child: _pages[index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
onPageChange: (index) {
|
||||
_navigatorKey!.currentState?.pushAndRemoveUntil(
|
||||
AppPageRoute(
|
||||
preventRebuild: false,
|
||||
isRootRoute: true,
|
||||
builder: (context) {
|
||||
return NaviPaddingWidget(child: _pages[index]);
|
||||
},
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user