mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
comic reading
This commit is contained in:
@@ -41,3 +41,4 @@ part 'scroll.dart';
|
|||||||
part 'select.dart';
|
part 'select.dart';
|
||||||
part 'side_bar.dart';
|
part 'side_bar.dart';
|
||||||
part 'comic.dart';
|
part 'comic.dart';
|
||||||
|
part 'effects.dart';
|
@@ -1,222 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
/// copied from flutter source
|
|
||||||
class _SliderDefaultsM3 extends SliderThemeData {
|
|
||||||
_SliderDefaultsM3(this.context)
|
|
||||||
: super(trackHeight: 4.0);
|
|
||||||
|
|
||||||
final BuildContext context;
|
|
||||||
late final ColorScheme _colors = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Color? get activeTrackColor => _colors.primary;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Color? get inactiveTrackColor => _colors.surfaceContainerHighest;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Color? get secondaryActiveTrackColor => _colors.primary.withOpacity(0.54);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Color? get disabledActiveTrackColor => _colors.onSurface.withOpacity(0.38);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Color? get disabledInactiveTrackColor => _colors.onSurface.withOpacity(0.12);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Color? get disabledSecondaryActiveTrackColor => _colors.onSurface.withOpacity(0.12);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Color? get activeTickMarkColor => _colors.onPrimary.withOpacity(0.38);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Color? get inactiveTickMarkColor => _colors.onSurfaceVariant.withOpacity(0.38);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Color? get disabledActiveTickMarkColor => _colors.onSurface.withOpacity(0.38);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Color? get disabledInactiveTickMarkColor => _colors.onSurface.withOpacity(0.38);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Color? get thumbColor => _colors.primary;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Color? get disabledThumbColor => Color.alphaBlend(_colors.onSurface.withOpacity(0.38), _colors.surface);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Color? get overlayColor => WidgetStateColor.resolveWith((Set<WidgetState> states) {
|
|
||||||
if (states.contains(WidgetState.hovered)) {
|
|
||||||
return _colors.primary.withOpacity(0.08);
|
|
||||||
}
|
|
||||||
if (states.contains(WidgetState.focused)) {
|
|
||||||
return _colors.primary.withOpacity(0.12);
|
|
||||||
}
|
|
||||||
if (states.contains(WidgetState.dragged)) {
|
|
||||||
return _colors.primary.withOpacity(0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Colors.transparent;
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
TextStyle? get valueIndicatorTextStyle => Theme.of(context).textTheme.labelMedium!.copyWith(
|
|
||||||
color: _colors.onPrimary,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
SliderComponentShape? get valueIndicatorShape => const DropSliderValueIndicatorShape();
|
|
||||||
}
|
|
||||||
|
|
||||||
class CustomSlider extends StatefulWidget {
|
|
||||||
const CustomSlider({required this.min, required this.max, required this.value, required this.divisions, required this.onChanged, this.reversed = false, super.key});
|
|
||||||
|
|
||||||
final double min;
|
|
||||||
|
|
||||||
final double max;
|
|
||||||
|
|
||||||
final double value;
|
|
||||||
|
|
||||||
final int divisions;
|
|
||||||
|
|
||||||
final void Function(double) onChanged;
|
|
||||||
|
|
||||||
final bool reversed;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<CustomSlider> createState() => _CustomSliderState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CustomSliderState extends State<CustomSlider> {
|
|
||||||
late double value;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
value = widget.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(CustomSlider oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (widget.value != oldWidget.value) {
|
|
||||||
setState(() {
|
|
||||||
value = widget.value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final theme = _SliderDefaultsM3(context);
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 12),
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constrains) => MouseRegion(
|
|
||||||
cursor: SystemMouseCursors.click,
|
|
||||||
child: GestureDetector(
|
|
||||||
behavior: HitTestBehavior.translucent,
|
|
||||||
onTapDown: (details){
|
|
||||||
var dx = details.localPosition.dx;
|
|
||||||
if(widget.reversed){
|
|
||||||
dx = constrains.maxWidth - dx;
|
|
||||||
}
|
|
||||||
var gap = constrains.maxWidth / widget.divisions;
|
|
||||||
var gapValue = (widget.max - widget.min) / widget.divisions;
|
|
||||||
widget.onChanged.call((dx / gap).round() * gapValue + widget.min);
|
|
||||||
},
|
|
||||||
onVerticalDragUpdate: (details){
|
|
||||||
var dx = details.localPosition.dx;
|
|
||||||
if(dx > constrains.maxWidth || dx < 0) return;
|
|
||||||
if(widget.reversed){
|
|
||||||
dx = constrains.maxWidth - dx;
|
|
||||||
}
|
|
||||||
var gap = constrains.maxWidth / widget.divisions;
|
|
||||||
var gapValue = (widget.max - widget.min) / widget.divisions;
|
|
||||||
widget.onChanged.call((dx / gap).round() * gapValue + widget.min);
|
|
||||||
},
|
|
||||||
child: SizedBox(
|
|
||||||
height: 24,
|
|
||||||
child: Center(
|
|
||||||
child: SizedBox(
|
|
||||||
height: 24,
|
|
||||||
child: Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: Center(
|
|
||||||
child: Container(
|
|
||||||
width: double.infinity,
|
|
||||||
height: 6,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.inactiveTrackColor,
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(10))
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if(constrains.maxWidth / widget.divisions > 10)
|
|
||||||
Positioned.fill(
|
|
||||||
child: Row(
|
|
||||||
children: (){
|
|
||||||
var res = <Widget>[];
|
|
||||||
for(int i = 0; i<widget.divisions-1; i++){
|
|
||||||
res.add(const Spacer());
|
|
||||||
res.add(Container(
|
|
||||||
width: 4,
|
|
||||||
height: 4,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surface.withRed(10),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
res.add(const Spacer());
|
|
||||||
return res;
|
|
||||||
}.call(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
left: widget.reversed ? null : 0,
|
|
||||||
right: widget.reversed ? 0 : null,
|
|
||||||
child: Center(
|
|
||||||
child: Container(
|
|
||||||
width: constrains.maxWidth * ((value - widget.min) / (widget.max - widget.min)),
|
|
||||||
height: 8,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.activeTrackColor,
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(10))
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
left: widget.reversed ? null : constrains.maxWidth * ((value - widget.min) / (widget.max - widget.min))-11,
|
|
||||||
right: !widget.reversed ? null : constrains.maxWidth * ((value - widget.min) / (widget.max - widget.min))-11,
|
|
||||||
child: Center(
|
|
||||||
child: Container(
|
|
||||||
width: 22,
|
|
||||||
height: 22,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.activeTrackColor,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
27
lib/components/effects.dart
Normal file
27
lib/components/effects.dart
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
part of 'components.dart';
|
||||||
|
|
||||||
|
class BlurEffect extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
final double blur;
|
||||||
|
|
||||||
|
const BlurEffect({
|
||||||
|
required this.child,
|
||||||
|
this.blur = 15,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ClipRect(
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(
|
||||||
|
sigmaX: blur,
|
||||||
|
sigmaY: blur,
|
||||||
|
tileMode: TileMode.mirror,
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -47,7 +47,7 @@ class _App {
|
|||||||
void pop() {
|
void pop() {
|
||||||
if(rootNavigatorKey.currentState?.canPop() ?? false) {
|
if(rootNavigatorKey.currentState?.canPop() ?? false) {
|
||||||
rootNavigatorKey.currentState?.pop();
|
rootNavigatorKey.currentState?.pop();
|
||||||
} else {
|
} else if (mainNavigatorKey?.currentState?.canPop() ?? false) {
|
||||||
mainNavigatorKey?.currentState?.pop();
|
mainNavigatorKey?.currentState?.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -75,6 +75,10 @@ class _Settings {
|
|||||||
'showHistoryStatusOnTile': false,
|
'showHistoryStatusOnTile': false,
|
||||||
'blockedWords': [],
|
'blockedWords': [],
|
||||||
'defaultSearchTarget': null,
|
'defaultSearchTarget': null,
|
||||||
|
'autoPageTurningInterval': 5, // in seconds
|
||||||
|
'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
|
||||||
|
'enableTapToTurnPages': true,
|
||||||
|
'enablePageAnimation': true,
|
||||||
};
|
};
|
||||||
|
|
||||||
operator[](String key) {
|
operator[](String key) {
|
||||||
|
@@ -39,25 +39,25 @@ class History {
|
|||||||
|
|
||||||
String id;
|
String id;
|
||||||
|
|
||||||
|
/// readEpisode is a set of episode numbers that have been read.
|
||||||
|
///
|
||||||
|
/// The number of episodes is 1-based.
|
||||||
Set<int> readEpisode;
|
Set<int> readEpisode;
|
||||||
|
|
||||||
int? maxPage;
|
int? maxPage;
|
||||||
|
|
||||||
History(this.type, this.time, this.title, this.subtitle, this.cover, this.ep,
|
|
||||||
this.page, this.id,
|
|
||||||
[this.readEpisode = const <int>{}, this.maxPage]);
|
|
||||||
|
|
||||||
History.fromModel(
|
History.fromModel(
|
||||||
{required HistoryMixin model,
|
{required HistoryMixin model,
|
||||||
required this.ep,
|
required this.ep,
|
||||||
required this.page,
|
required this.page,
|
||||||
this.readEpisode = const <int>{},
|
Set<int>? readChapters,
|
||||||
DateTime? time})
|
DateTime? time})
|
||||||
: type = model.historyType,
|
: type = model.historyType,
|
||||||
title = model.title,
|
title = model.title,
|
||||||
subtitle = model.subTitle ?? '',
|
subtitle = model.subTitle ?? '',
|
||||||
cover = model.cover,
|
cover = model.cover,
|
||||||
id = model.id,
|
id = model.id,
|
||||||
|
readEpisode = readChapters ?? <int>{},
|
||||||
time = time ?? DateTime.now();
|
time = time ?? DateTime.now();
|
||||||
|
|
||||||
Map<String, dynamic> toMap() => {
|
Map<String, dynamic> toMap() => {
|
||||||
@@ -168,50 +168,22 @@ class HistoryManager with ChangeNotifier {
|
|||||||
///
|
///
|
||||||
/// This function would be called when user start reading.
|
/// This function would be called when user start reading.
|
||||||
Future<void> addHistory(History newItem) async {
|
Future<void> addHistory(History newItem) async {
|
||||||
var res = _db.select("""
|
_db.execute("""
|
||||||
select * from history
|
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
|
||||||
where id == ? and type == ?;
|
|
||||||
""", [newItem.id, newItem.type.value]);
|
|
||||||
if (res.isEmpty) {
|
|
||||||
_db.execute("""
|
|
||||||
insert into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
|
|
||||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||||
""", [
|
""", [
|
||||||
newItem.id,
|
newItem.id,
|
||||||
newItem.title,
|
newItem.title,
|
||||||
newItem.subtitle,
|
newItem.subtitle,
|
||||||
newItem.cover,
|
newItem.cover,
|
||||||
newItem.time.millisecondsSinceEpoch,
|
newItem.time.millisecondsSinceEpoch,
|
||||||
newItem.type.value,
|
newItem.type.value,
|
||||||
newItem.ep,
|
newItem.ep,
|
||||||
newItem.page,
|
newItem.page,
|
||||||
newItem.readEpisode.join(','),
|
newItem.readEpisode.join(','),
|
||||||
newItem.maxPage
|
newItem.maxPage
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
_db.execute("""
|
|
||||||
update history
|
|
||||||
set time = ${DateTime.now().millisecondsSinceEpoch}
|
|
||||||
where id == ? and type == ?;
|
|
||||||
""", [newItem.id, newItem.type.value]);
|
|
||||||
}
|
|
||||||
updateCache();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> saveReadHistory(History history) async {
|
|
||||||
_db.execute("""
|
|
||||||
update history
|
|
||||||
set time = ${DateTime.now().millisecondsSinceEpoch}, ep = ?, page = ?, readEpisode = ?, max_page = ?
|
|
||||||
where id == ? and type == ?;
|
|
||||||
""", [
|
|
||||||
history.ep,
|
|
||||||
history.page,
|
|
||||||
history.readEpisode.join(','),
|
|
||||||
history.maxPage,
|
|
||||||
history.id,
|
|
||||||
history.type.value
|
|
||||||
]);
|
]);
|
||||||
|
updateCache();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:async' show Future, StreamController;
|
import 'dart:async' show Future, StreamController;
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:venera/foundation/cache_manager.dart';
|
import 'package:venera/foundation/cache_manager.dart';
|
||||||
|
81
lib/foundation/image_provider/reader_image.dart
Normal file
81
lib/foundation/image_provider/reader_image.dart
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import 'dart:async' show Future, StreamController;
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:venera/foundation/cache_manager.dart';
|
||||||
|
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||||
|
import 'package:venera/foundation/consts.dart';
|
||||||
|
import 'package:venera/network/app_dio.dart';
|
||||||
|
import 'base_image_provider.dart';
|
||||||
|
import 'reader_image.dart' as image_provider;
|
||||||
|
|
||||||
|
class ReaderImageProvider
|
||||||
|
extends BaseImageProvider<image_provider.ReaderImageProvider> {
|
||||||
|
/// Image provider for normal image.
|
||||||
|
const ReaderImageProvider(this.imageKey, this.sourceKey, this.cid, this.eid);
|
||||||
|
|
||||||
|
final String imageKey;
|
||||||
|
|
||||||
|
final String? sourceKey;
|
||||||
|
|
||||||
|
final String cid;
|
||||||
|
|
||||||
|
final String eid;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
|
||||||
|
final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
|
||||||
|
final cache = await CacheManager().findCache(cacheKey);
|
||||||
|
|
||||||
|
if (cache != null) {
|
||||||
|
return await cache.readAsBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
var configs = <String, dynamic>{};
|
||||||
|
if (sourceKey != null) {
|
||||||
|
var comicSource = ComicSource.find(sourceKey!);
|
||||||
|
configs = comicSource!.getImageLoadingConfig?.call(imageKey, cid, eid) ?? {};
|
||||||
|
}
|
||||||
|
configs['headers'] ??= {
|
||||||
|
'user-agent': webUA,
|
||||||
|
};
|
||||||
|
|
||||||
|
var dio = AppDio(BaseOptions(
|
||||||
|
headers: configs['headers'],
|
||||||
|
method: configs['method'] ?? 'GET',
|
||||||
|
responseType: ResponseType.stream,
|
||||||
|
));
|
||||||
|
|
||||||
|
var req = await dio.request<ResponseBody>(configs['url'] ?? imageKey,
|
||||||
|
data: configs['data']);
|
||||||
|
var stream = req.data?.stream ?? (throw "Error: Empty response body.");
|
||||||
|
int? expectedBytes = req.data!.contentLength;
|
||||||
|
if (expectedBytes == -1) {
|
||||||
|
expectedBytes = null;
|
||||||
|
}
|
||||||
|
var buffer = <int>[];
|
||||||
|
await for (var data in stream) {
|
||||||
|
buffer.addAll(data);
|
||||||
|
if (expectedBytes != null) {
|
||||||
|
chunkEvents.add(ImageChunkEvent(
|
||||||
|
cumulativeBytesLoaded: buffer.length,
|
||||||
|
expectedTotalBytes: expectedBytes,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(configs['onResponse'] != null) {
|
||||||
|
buffer = configs['onResponse'](buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
await CacheManager().writeCache(cacheKey, buffer);
|
||||||
|
return Uint8List.fromList(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ReaderImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||||
|
return SynchronousFuture(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get key => "$imageKey@$sourceKey@$cid@$eid";
|
||||||
|
}
|
@@ -9,6 +9,7 @@ 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/res.dart';
|
import 'package:venera/foundation/res.dart';
|
||||||
import 'package:venera/pages/favorites/favorite_actions.dart';
|
import 'package:venera/pages/favorites/favorite_actions.dart';
|
||||||
|
import 'package:venera/pages/reader/reader.dart';
|
||||||
import 'package:venera/utils/translations.dart';
|
import 'package:venera/utils/translations.dart';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
@@ -458,9 +459,25 @@ abstract mixin class _ComicPageActions {
|
|||||||
/// [ep] the episode number, start from 1
|
/// [ep] the episode number, start from 1
|
||||||
///
|
///
|
||||||
/// [page] the page number, start from 1
|
/// [page] the page number, start from 1
|
||||||
void read([int? ep, int? page]) {}
|
void read([int? ep, int? page]) {
|
||||||
|
App.rootContext.to(
|
||||||
|
() => Reader(
|
||||||
|
source: comicSource,
|
||||||
|
cid: comic.id,
|
||||||
|
name: comic.title,
|
||||||
|
chapters: comic.chapters,
|
||||||
|
initialChapter: ep,
|
||||||
|
initialPage: page,
|
||||||
|
history: History.fromModel(model: comic, ep: 0, page: 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void continueRead() {}
|
void continueRead() {
|
||||||
|
var ep = history?.ep ?? 1;
|
||||||
|
var page = history?.page ?? 1;
|
||||||
|
read(ep, page);
|
||||||
|
}
|
||||||
|
|
||||||
void download() {}
|
void download() {}
|
||||||
|
|
||||||
@@ -1117,4 +1134,3 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:venera/components/components.dart';
|
import 'package:venera/components/components.dart';
|
||||||
@@ -11,6 +9,7 @@ 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_page.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/pages/search_page.dart';
|
||||||
import 'package:venera/utils/io.dart';
|
import 'package:venera/utils/io.dart';
|
||||||
@@ -149,7 +148,12 @@ class _HistoryState extends State<_History> {
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// TODO: toComicPageWithHistory(context, history[index]);
|
context.to(
|
||||||
|
() => ComicPage(
|
||||||
|
id: history[index].id,
|
||||||
|
sourceKey: history[index].type.comicSource!.key,
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -177,7 +181,7 @@ class _HistoryState extends State<_History> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
).paddingHorizontal(8),
|
).paddingHorizontal(8).paddingBottom(16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
165
lib/pages/reader/gesture.dart
Normal file
165
lib/pages/reader/gesture.dart
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
part of 'reader.dart';
|
||||||
|
|
||||||
|
class _ReaderGestureDetector extends StatefulWidget {
|
||||||
|
const _ReaderGestureDetector({required this.child});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ReaderGestureDetector> createState() => _ReaderGestureDetectorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
|
||||||
|
late TapGestureRecognizer _tapGestureRecognizer;
|
||||||
|
|
||||||
|
static const _kDoubleTapMinTime = Duration(milliseconds: 200);
|
||||||
|
|
||||||
|
static const _kDoubleTapMaxDistanceSquared = 20.0 * 20.0;
|
||||||
|
|
||||||
|
static const _kTapToTurnPagePercent = 0.3;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
_tapGestureRecognizer = TapGestureRecognizer()
|
||||||
|
..onTapUp = onTapUp
|
||||||
|
..onSecondaryTapUp = (details) {
|
||||||
|
onSecondaryTapUp(details.globalPosition);
|
||||||
|
};
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Listener(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onPointerDown: (event) {
|
||||||
|
_tapGestureRecognizer.addPointer(event);
|
||||||
|
},
|
||||||
|
onPointerSignal: (event) {
|
||||||
|
if (event is PointerScrollEvent) {
|
||||||
|
onMouseWheel(event.scrollDelta.dy > 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onMouseWheel(bool forward) {
|
||||||
|
if (forward) {
|
||||||
|
if (!context.reader.toNextPage()) {
|
||||||
|
context.reader.toNextChapter();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!context.reader.toPrevPage()) {
|
||||||
|
context.reader.toPrevChapter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TapUpDetails? _previousEvent;
|
||||||
|
|
||||||
|
void onTapUp(TapUpDetails event) {
|
||||||
|
final location = event.globalPosition;
|
||||||
|
final previousLocation = _previousEvent?.globalPosition;
|
||||||
|
if (previousLocation != null) {
|
||||||
|
if ((location - previousLocation).distanceSquared <
|
||||||
|
_kDoubleTapMaxDistanceSquared) {
|
||||||
|
onDoubleTap(location);
|
||||||
|
_previousEvent = null;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
onTap(previousLocation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_previousEvent = event;
|
||||||
|
Future.delayed(_kDoubleTapMinTime, () {
|
||||||
|
if (_previousEvent == event) {
|
||||||
|
onTap(location);
|
||||||
|
_previousEvent = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void onTap(Offset location) {
|
||||||
|
if (context.readerScaffold.isOpen) {
|
||||||
|
context.readerScaffold.openOrClose();
|
||||||
|
} else {
|
||||||
|
if (appdata.settings['enableTapToTurnPages']) {
|
||||||
|
bool isLeft = false, isRight = false, isTop = false, isBottom = false;
|
||||||
|
final width = context.width;
|
||||||
|
final height = context.height;
|
||||||
|
final x = location.dx;
|
||||||
|
final y = location.dy;
|
||||||
|
if (x < width * _kTapToTurnPagePercent) {
|
||||||
|
isLeft = true;
|
||||||
|
} else if (x > width * (1 - _kTapToTurnPagePercent)) {
|
||||||
|
isRight = true;
|
||||||
|
}
|
||||||
|
if (y < height * _kTapToTurnPagePercent) {
|
||||||
|
isTop = true;
|
||||||
|
} else if (y > height * (1 - _kTapToTurnPagePercent)) {
|
||||||
|
isBottom = true;
|
||||||
|
}
|
||||||
|
bool isCenter = false;
|
||||||
|
switch (context.reader.mode) {
|
||||||
|
case ReaderMode.galleryLeftToRight:
|
||||||
|
case ReaderMode.continuousLeftToRight:
|
||||||
|
if (isLeft) {
|
||||||
|
context.reader.toPrevPage();
|
||||||
|
} else if (isRight) {
|
||||||
|
context.reader.toNextPage();
|
||||||
|
} else {
|
||||||
|
isCenter = true;
|
||||||
|
}
|
||||||
|
case ReaderMode.galleryRightToLeft:
|
||||||
|
case ReaderMode.continuousRightToLeft:
|
||||||
|
if (isLeft) {
|
||||||
|
context.reader.toNextPage();
|
||||||
|
} else if (isRight) {
|
||||||
|
context.reader.toPrevPage();
|
||||||
|
} else {
|
||||||
|
isCenter = true;
|
||||||
|
}
|
||||||
|
case ReaderMode.galleryTopToBottom:
|
||||||
|
case ReaderMode.continuousTopToBottom:
|
||||||
|
if (isTop) {
|
||||||
|
context.reader.toPrevPage();
|
||||||
|
} else if (isBottom) {
|
||||||
|
context.reader.toNextPage();
|
||||||
|
} else {
|
||||||
|
isCenter = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isCenter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.readerScaffold.openOrClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onDoubleTap(Offset location) {
|
||||||
|
context.reader._imageViewController?.handleDoubleTap(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onSecondaryTapUp(Offset location) {
|
||||||
|
showDesktopMenu(
|
||||||
|
context,
|
||||||
|
location,
|
||||||
|
[
|
||||||
|
DesktopMenuEntry(text: "Settings".tl, onClick: () {
|
||||||
|
context.readerScaffold.openSetting();
|
||||||
|
}),
|
||||||
|
DesktopMenuEntry(text: "Chapters".tl, onClick: () {
|
||||||
|
context.readerScaffold.openChapterDrawer();
|
||||||
|
}),
|
||||||
|
DesktopMenuEntry(text: "Fullscreen".tl, onClick: () {
|
||||||
|
context.reader.fullscreen();
|
||||||
|
}),
|
||||||
|
DesktopMenuEntry(text: "Exit".tl, onClick: () {
|
||||||
|
context.pop();
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
212
lib/pages/reader/images.dart
Normal file
212
lib/pages/reader/images.dart
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
part of 'reader.dart';
|
||||||
|
|
||||||
|
class _ReaderImages extends StatefulWidget {
|
||||||
|
const _ReaderImages({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ReaderImages> createState() => _ReaderImagesState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReaderImagesState extends State<_ReaderImages> {
|
||||||
|
String? error;
|
||||||
|
|
||||||
|
bool inProgress = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
context.reader.isLoading = true;
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void load() async {
|
||||||
|
if (inProgress) return;
|
||||||
|
inProgress = true;
|
||||||
|
var res = await context.reader.widget.source.loadComicPages!(
|
||||||
|
context.reader.widget.cid,
|
||||||
|
context.reader.widget.chapters?.keys
|
||||||
|
.elementAt(context.reader.chapter - 1),
|
||||||
|
);
|
||||||
|
if (res.error) {
|
||||||
|
setState(() {
|
||||||
|
error = res.errorMessage;
|
||||||
|
context.reader.isLoading = false;
|
||||||
|
inProgress = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
context.reader.images = res.data;
|
||||||
|
context.reader.isLoading = false;
|
||||||
|
inProgress = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
context.readerScaffold.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (context.reader.isLoading) {
|
||||||
|
load();
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
} else if (error != null) {
|
||||||
|
return NetworkError(
|
||||||
|
message: error!,
|
||||||
|
retry: () {
|
||||||
|
setState(() {
|
||||||
|
context.reader.isLoading = true;
|
||||||
|
error = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (context.reader.mode.isGallery) {
|
||||||
|
return _GalleryMode(key: Key(context.reader.mode.key));
|
||||||
|
} else {
|
||||||
|
// TODO: Implement other modes
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GalleryMode extends StatefulWidget {
|
||||||
|
const _GalleryMode({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_GalleryMode> createState() => _GalleryModeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GalleryModeState extends State<_GalleryMode>
|
||||||
|
implements _ImageViewController {
|
||||||
|
late PageController controller;
|
||||||
|
|
||||||
|
late List<bool> cached;
|
||||||
|
|
||||||
|
int get preCacheCount => 4;
|
||||||
|
|
||||||
|
var photoViewControllers = <int, PhotoViewController>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
controller = PageController(initialPage: context.reader.page);
|
||||||
|
context.reader._imageViewController = this;
|
||||||
|
cached = List.filled(context.reader.maxPage + 2, false);
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void cache(int current) {
|
||||||
|
for (int i = current + 1; i <= current + preCacheCount; i++) {
|
||||||
|
if (i <= context.reader.maxPage && !cached[i]) {
|
||||||
|
_precacheImage(i, context);
|
||||||
|
cached[i] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PhotoViewGallery.builder(
|
||||||
|
backgroundDecoration: BoxDecoration(
|
||||||
|
color: context.colorScheme.surface,
|
||||||
|
),
|
||||||
|
reverse: context.reader.mode == ReaderMode.galleryRightToLeft,
|
||||||
|
scrollDirection: context.reader.mode == ReaderMode.galleryTopToBottom
|
||||||
|
? Axis.vertical
|
||||||
|
: Axis.horizontal,
|
||||||
|
itemCount: context.reader.images!.length + 2,
|
||||||
|
builder: (BuildContext context, int index) {
|
||||||
|
ImageProvider? imageProvider;
|
||||||
|
if (index != 0 && index != context.reader.images!.length + 1) {
|
||||||
|
imageProvider = _createImageProvider(index, context);
|
||||||
|
} else {
|
||||||
|
return PhotoViewGalleryPageOptions.customChild(
|
||||||
|
scaleStateController: PhotoViewScaleStateController(),
|
||||||
|
child: const SizedBox(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cached[index] = true;
|
||||||
|
cache(index);
|
||||||
|
|
||||||
|
photoViewControllers[index] ??= PhotoViewController();
|
||||||
|
|
||||||
|
return PhotoViewGalleryPageOptions(
|
||||||
|
filterQuality: FilterQuality.medium,
|
||||||
|
controller: photoViewControllers[index],
|
||||||
|
imageProvider: imageProvider,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (_, error, s, retry) {
|
||||||
|
return NetworkError(message: error.toString(), retry: retry);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
pageController: controller,
|
||||||
|
loadingBuilder: (context, event) => Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20.0,
|
||||||
|
height: 20.0,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
backgroundColor: context.colorScheme.surfaceContainerHigh,
|
||||||
|
value: event == null || event.expectedTotalBytes == null
|
||||||
|
? null
|
||||||
|
: event.cumulativeBytesLoaded / event.expectedTotalBytes!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPageChanged: (i) {
|
||||||
|
if (i == 0) {
|
||||||
|
if (!context.reader.toNextChapter()) {
|
||||||
|
context.reader.toPage(1);
|
||||||
|
}
|
||||||
|
} else if (i == context.reader.maxPage + 1) {
|
||||||
|
if (!context.reader.toPrevChapter()) {
|
||||||
|
context.reader.toPage(context.reader.maxPage);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
context.reader.setPage(i);
|
||||||
|
context.readerScaffold.update();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> animateToPage(int page) {
|
||||||
|
if ((page - controller.page!).abs() > 1) {
|
||||||
|
controller.jumpToPage(page > controller.page! ? page - 1 : page + 1);
|
||||||
|
}
|
||||||
|
return controller.animateToPage(
|
||||||
|
page,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.ease,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void toPage(int page) {
|
||||||
|
controller.jumpToPage(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void handleDoubleTap(Offset location) {
|
||||||
|
var controller = photoViewControllers[context.reader.page]!;
|
||||||
|
controller.onDoubleClick?.call();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageProvider _createImageProvider(int page, BuildContext context) {
|
||||||
|
return ReaderImageProvider(
|
||||||
|
context.reader.images![page - 1],
|
||||||
|
context.reader.widget.source.key,
|
||||||
|
context.reader.cid,
|
||||||
|
context.reader.eid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _precacheImage(int page, BuildContext context) {
|
||||||
|
precacheImage(
|
||||||
|
_createImageProvider(page, context),
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
}
|
275
lib/pages/reader/reader.dart
Normal file
275
lib/pages/reader/reader.dart
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
library venera_reader;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:photo_view/photo_view.dart';
|
||||||
|
import 'package:photo_view/photo_view_gallery.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/foundation/history.dart';
|
||||||
|
import 'package:venera/foundation/image_provider/reader_image.dart';
|
||||||
|
import 'package:venera/utils/translations.dart';
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
part 'scaffold.dart';
|
||||||
|
|
||||||
|
part 'images.dart';
|
||||||
|
|
||||||
|
part 'gesture.dart';
|
||||||
|
|
||||||
|
extension _ReaderContext on BuildContext {
|
||||||
|
_ReaderState get reader => findAncestorStateOfType<_ReaderState>()!;
|
||||||
|
|
||||||
|
_ReaderScaffoldState get readerScaffold =>
|
||||||
|
findAncestorStateOfType<_ReaderScaffoldState>()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Reader extends StatefulWidget {
|
||||||
|
const Reader({
|
||||||
|
super.key,
|
||||||
|
required this.source,
|
||||||
|
required this.cid,
|
||||||
|
required this.name,
|
||||||
|
required this.chapters,
|
||||||
|
required this.history,
|
||||||
|
this.initialPage,
|
||||||
|
this.initialChapter,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ComicSource source;
|
||||||
|
|
||||||
|
final String cid;
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// Map<Chapter ID, Chapter Name>.
|
||||||
|
/// null if the comic is a gallery
|
||||||
|
final Map<String, String>? chapters;
|
||||||
|
|
||||||
|
/// Starts from 1, invalid values equal to 1
|
||||||
|
final int? initialPage;
|
||||||
|
|
||||||
|
/// Starts from 1, invalid values equal to 1
|
||||||
|
final int? initialChapter;
|
||||||
|
|
||||||
|
final History history;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<Reader> createState() => _ReaderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
|
||||||
|
@override
|
||||||
|
void update() {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get maxPage => images?.length ?? 1;
|
||||||
|
|
||||||
|
String get cid => widget.cid;
|
||||||
|
|
||||||
|
String get eid => widget.chapters?.keys.elementAt(chapter - 1) ?? '0';
|
||||||
|
|
||||||
|
List<String>? images;
|
||||||
|
|
||||||
|
late ReaderMode mode;
|
||||||
|
|
||||||
|
History? history;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
page = widget.initialPage ?? 1;
|
||||||
|
chapter = widget.initialChapter ?? 1;
|
||||||
|
mode = ReaderMode.fromKey(appdata.settings['readerMode']);
|
||||||
|
history = widget.history;
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _ReaderScaffold(
|
||||||
|
child: _ReaderGestureDetector(
|
||||||
|
child: _ReaderImages(key: Key(chapter.toString())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get maxChapter => widget.chapters?.length ?? 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onPageChanged() {
|
||||||
|
updateHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateHistory() {
|
||||||
|
if(history != null) {
|
||||||
|
history!.page = page;
|
||||||
|
history!.ep = chapter;
|
||||||
|
history!.readEpisode.add(chapter);
|
||||||
|
HistoryManager().addHistory(history!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract mixin class _ReaderLocation {
|
||||||
|
int _page = 1;
|
||||||
|
|
||||||
|
int get page => _page;
|
||||||
|
|
||||||
|
set page(int value) {
|
||||||
|
_page = value;
|
||||||
|
onPageChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
int chapter = 1;
|
||||||
|
|
||||||
|
int get maxPage;
|
||||||
|
|
||||||
|
int get maxChapter;
|
||||||
|
|
||||||
|
bool get isLoading;
|
||||||
|
|
||||||
|
void update();
|
||||||
|
|
||||||
|
bool get enablePageAnimation => appdata.settings['enablePageAnimation'];
|
||||||
|
|
||||||
|
_ImageViewController? _imageViewController;
|
||||||
|
|
||||||
|
void onPageChanged();
|
||||||
|
|
||||||
|
void setPage(int page) {
|
||||||
|
// Prevent page change during animation
|
||||||
|
if (_animationCount > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _validatePage(int page) {
|
||||||
|
return page >= 1 && page <= maxPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the page is changed
|
||||||
|
bool toNextPage() {
|
||||||
|
return toPage(page + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the page is changed
|
||||||
|
bool toPrevPage() {
|
||||||
|
return toPage(page - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _animationCount = 0;
|
||||||
|
|
||||||
|
bool toPage(int page) {
|
||||||
|
if (_validatePage(page)) {
|
||||||
|
if (page == this.page) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.page = page;
|
||||||
|
update();
|
||||||
|
if (enablePageAnimation) {
|
||||||
|
_animationCount++;
|
||||||
|
_imageViewController!.animateToPage(page).then((_) {
|
||||||
|
_animationCount--;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_imageViewController!.toPage(page);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _validateChapter(int chapter) {
|
||||||
|
return chapter >= 1 && chapter <= maxChapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the chapter is changed
|
||||||
|
bool toNextChapter() {
|
||||||
|
return toChapter(chapter + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the chapter is changed
|
||||||
|
bool toPrevChapter() {
|
||||||
|
return toChapter(chapter - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool toChapter(int c) {
|
||||||
|
if (_validateChapter(c) && !isLoading) {
|
||||||
|
chapter = c;
|
||||||
|
page = 1;
|
||||||
|
update();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer? autoPageTurningTimer;
|
||||||
|
|
||||||
|
void autoPageTurning() {
|
||||||
|
if (autoPageTurningTimer != null) {
|
||||||
|
autoPageTurningTimer!.cancel();
|
||||||
|
autoPageTurningTimer = null;
|
||||||
|
} else {
|
||||||
|
int interval = appdata.settings['autoPageTurningInterval'];
|
||||||
|
autoPageTurningTimer = Timer.periodic(Duration(seconds: interval), (_) {
|
||||||
|
if (page == maxPage) {
|
||||||
|
autoPageTurningTimer!.cancel();
|
||||||
|
}
|
||||||
|
toNextPage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin class _ReaderWindow {
|
||||||
|
bool isFullscreen = false;
|
||||||
|
|
||||||
|
void fullscreen() {
|
||||||
|
windowManager.setFullScreen(!isFullscreen);
|
||||||
|
isFullscreen = !isFullscreen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReaderMode {
|
||||||
|
galleryLeftToRight('galleryLeftToRight'),
|
||||||
|
galleryRightToLeft('galleryRightToLeft'),
|
||||||
|
galleryTopToBottom('galleryTopToBottom'),
|
||||||
|
continuousTopToBottom('continuousTopToBottom'),
|
||||||
|
continuousLeftToRight('continuousLeftToRight'),
|
||||||
|
continuousRightToLeft('continuousRightToLeft');
|
||||||
|
|
||||||
|
final String key;
|
||||||
|
|
||||||
|
bool get isGallery => key.startsWith('gallery');
|
||||||
|
|
||||||
|
const ReaderMode(this.key);
|
||||||
|
|
||||||
|
static ReaderMode fromKey(String key) {
|
||||||
|
for (var mode in values) {
|
||||||
|
if (mode.key == key) {
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return galleryLeftToRight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract interface class _ImageViewController {
|
||||||
|
void toPage(int page);
|
||||||
|
|
||||||
|
Future<void> animateToPage(int page);
|
||||||
|
|
||||||
|
void handleDoubleTap(Offset location);
|
||||||
|
}
|
302
lib/pages/reader/scaffold.dart
Normal file
302
lib/pages/reader/scaffold.dart
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
part of 'reader.dart';
|
||||||
|
|
||||||
|
class _ReaderScaffold extends StatefulWidget {
|
||||||
|
const _ReaderScaffold({required this.child});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ReaderScaffold> createState() => _ReaderScaffoldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||||
|
bool _isOpen = false;
|
||||||
|
|
||||||
|
static const kTopBarHeight = 56.0;
|
||||||
|
|
||||||
|
static const kBottomBarHeight = 105.0;
|
||||||
|
|
||||||
|
bool get isOpen => _isOpen;
|
||||||
|
|
||||||
|
void openOrClose() {
|
||||||
|
setState(() {
|
||||||
|
_isOpen = !_isOpen;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool? rotation;
|
||||||
|
|
||||||
|
void update() {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
buildPageInfoText(),
|
||||||
|
AnimatedPositioned(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
top: _isOpen ? 0 : -(kTopBarHeight + context.padding.top),
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: kTopBarHeight + context.padding.top,
|
||||||
|
child: buildTop(),
|
||||||
|
),
|
||||||
|
AnimatedPositioned(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
bottom: _isOpen ? 0 : -(kBottomBarHeight + context.padding.bottom),
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: kBottomBarHeight + context.padding.bottom,
|
||||||
|
child: buildBottom(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildTop() {
|
||||||
|
return BlurEffect(
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.only(top: context.padding.top),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: Colors.grey.withOpacity(0.5),
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(context.reader.widget.name, style: ts.s18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildBottom() {
|
||||||
|
var text = "E${context.reader.chapter} : P${context.reader.page}";
|
||||||
|
if (context.reader.widget.chapters == null) {
|
||||||
|
text = "P${context.reader.page}";
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget child = SizedBox(
|
||||||
|
height: kBottomBarHeight + MediaQuery.of(context).padding.bottom,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: context.reader.toPrevChapter,
|
||||||
|
icon: const Icon(Icons.first_page),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: buildSlider(),
|
||||||
|
),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: context.reader.toNextChapter,
|
||||||
|
icon: const Icon(Icons.last_page)),
|
||||||
|
const SizedBox(
|
||||||
|
width: 8,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
height: 24,
|
||||||
|
padding: const EdgeInsets.fromLTRB(6, 2, 6, 0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.tertiaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(text),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (App.isWindows)
|
||||||
|
Tooltip(
|
||||||
|
message: "${"Full Screen".tl}(F12)",
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.fullscreen),
|
||||||
|
onPressed: () {
|
||||||
|
context.reader.fullscreen();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (App.isAndroid)
|
||||||
|
Tooltip(
|
||||||
|
message: "Screen Rotation".tl,
|
||||||
|
child: IconButton(
|
||||||
|
icon: () {
|
||||||
|
if (rotation == null) {
|
||||||
|
return const Icon(Icons.screen_rotation);
|
||||||
|
} else if (rotation == false) {
|
||||||
|
return const Icon(Icons.screen_lock_portrait);
|
||||||
|
} else {
|
||||||
|
return const Icon(Icons.screen_lock_landscape);
|
||||||
|
}
|
||||||
|
}.call(),
|
||||||
|
onPressed: () {
|
||||||
|
if (rotation == null) {
|
||||||
|
setState(() {
|
||||||
|
rotation = false;
|
||||||
|
});
|
||||||
|
SystemChrome.setPreferredOrientations([
|
||||||
|
DeviceOrientation.portraitUp,
|
||||||
|
DeviceOrientation.portraitDown,
|
||||||
|
]);
|
||||||
|
} else if (rotation == false) {
|
||||||
|
setState(() {
|
||||||
|
rotation = true;
|
||||||
|
});
|
||||||
|
SystemChrome.setPreferredOrientations([
|
||||||
|
DeviceOrientation.landscapeLeft,
|
||||||
|
DeviceOrientation.landscapeRight
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
rotation = null;
|
||||||
|
});
|
||||||
|
SystemChrome.setPreferredOrientations(
|
||||||
|
DeviceOrientation.values);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Tooltip(
|
||||||
|
message: "Auto Page Turning".tl,
|
||||||
|
child: IconButton(
|
||||||
|
icon: context.reader.autoPageTurningTimer != null
|
||||||
|
? const Icon(Icons.timer)
|
||||||
|
: const Icon(Icons.timer_sharp),
|
||||||
|
onPressed: context.reader.autoPageTurning,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (context.reader.widget.chapters != null)
|
||||||
|
Tooltip(
|
||||||
|
message: "Chapters".tl,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.library_books),
|
||||||
|
onPressed: openChapterDrawer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Tooltip(
|
||||||
|
message: "Save Image".tl,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
onPressed: saveCurrentImage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Tooltip(
|
||||||
|
message: "Share".tl,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.share),
|
||||||
|
onPressed: share,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return BlurEffect(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(
|
||||||
|
color: Colors.grey.withOpacity(0.5),
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.only(bottom: context.padding.bottom),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildSlider() {
|
||||||
|
return Slider(
|
||||||
|
value: context.reader.page.toDouble(),
|
||||||
|
min: 1,
|
||||||
|
max: context.reader.maxPage.clamp(context.reader.page, 1 << 16).toDouble(),
|
||||||
|
divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16),
|
||||||
|
onChanged: (i) {
|
||||||
|
context.reader.toPage(i.toInt());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildPageInfoText() {
|
||||||
|
var epName = context.reader.widget.chapters?.values
|
||||||
|
.elementAt(context.reader.chapter - 1) ??
|
||||||
|
"E${context.reader.chapter}";
|
||||||
|
if (epName.length > 8) {
|
||||||
|
epName = "${epName.substring(0, 8)}...";
|
||||||
|
}
|
||||||
|
var pageText = "${context.reader.page}/${context.reader.maxPage}";
|
||||||
|
var text = context.reader.widget.chapters != null
|
||||||
|
? "$epName : $pageText"
|
||||||
|
: pageText;
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
bottom: 13,
|
||||||
|
left: 25,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
foreground: Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.4
|
||||||
|
..color = context.colorScheme.onInverseSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(text),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void openChapterDrawer() {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
void saveCurrentImage() {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
void share() {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
void openSetting() {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
}
|
@@ -302,6 +302,15 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
photo_view:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "."
|
||||||
|
ref: "94724a0b"
|
||||||
|
resolved-ref: "94724a0b7f94167fd1ae061f84e14ae04cae5c39"
|
||||||
|
url: "https://github.com/wgh136/photo_view"
|
||||||
|
source: git
|
||||||
|
version: "0.14.0"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@@ -29,6 +29,10 @@ dependencies:
|
|||||||
file_picker: ^8.1.2
|
file_picker: ^8.1.2
|
||||||
url_launcher: ^6.3.0
|
url_launcher: ^6.3.0
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
|
photo_view:
|
||||||
|
git:
|
||||||
|
url: https://github.com/wgh136/photo_view
|
||||||
|
ref: 94724a0b
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
Reference in New Issue
Block a user