comic reading

This commit is contained in:
nyne
2024-10-07 22:33:07 +08:00
parent 5ccd0af2d8
commit 601a7cd796
16 changed files with 1127 additions and 278 deletions

View File

@@ -41,3 +41,4 @@ part 'scroll.dart';
part 'select.dart';
part 'side_bar.dart';
part 'comic.dart';
part 'effects.dart';

View File

@@ -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,
),
),
),
)
],
),
),
),
),
),
),
),
);
}
}

View 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,
),
);
}
}

View File

@@ -47,7 +47,7 @@ class _App {
void pop() {
if(rootNavigatorKey.currentState?.canPop() ?? false) {
rootNavigatorKey.currentState?.pop();
} else {
} else if (mainNavigatorKey?.currentState?.canPop() ?? false) {
mainNavigatorKey?.currentState?.pop();
}
}

View File

@@ -75,6 +75,10 @@ class _Settings {
'showHistoryStatusOnTile': false,
'blockedWords': [],
'defaultSearchTarget': null,
'autoPageTurningInterval': 5, // in seconds
'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
'enableTapToTurnPages': true,
'enablePageAnimation': true,
};
operator[](String key) {

View File

@@ -39,25 +39,25 @@ class History {
String id;
/// readEpisode is a set of episode numbers that have been read.
///
/// The number of episodes is 1-based.
Set<int> readEpisode;
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(
{required HistoryMixin model,
required this.ep,
required this.page,
this.readEpisode = const <int>{},
Set<int>? readChapters,
DateTime? time})
: type = model.historyType,
title = model.title,
subtitle = model.subTitle ?? '',
cover = model.cover,
id = model.id,
readEpisode = readChapters ?? <int>{},
time = time ?? DateTime.now();
Map<String, dynamic> toMap() => {
@@ -168,13 +168,8 @@ class HistoryManager with ChangeNotifier {
///
/// This function would be called when user start reading.
Future<void> addHistory(History newItem) async {
var res = _db.select("""
select * from history
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)
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [
newItem.id,
@@ -188,33 +183,10 @@ class HistoryManager with ChangeNotifier {
newItem.readEpisode.join(','),
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
]);
notifyListeners();
}
void clearHistory() {
_db.execute("delete from history;");
updateCache();

View File

@@ -1,5 +1,4 @@
import 'dart:async' show Future, StreamController;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:venera/foundation/cache_manager.dart';

View 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";
}

View File

@@ -9,6 +9,7 @@ import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/pages/favorites/favorite_actions.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/translations.dart';
import 'dart:math' as math;
@@ -458,9 +459,25 @@ abstract mixin class _ComicPageActions {
/// [ep] the episode 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() {}
@@ -1117,4 +1134,3 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
}
}
}

View File

@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.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/local.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/search_page.dart';
import 'package:venera/utils/io.dart';
@@ -149,7 +148,12 @@ class _HistoryState extends State<_History> {
itemBuilder: (context, index) {
return InkWell(
onTap: () {
// TODO: toComicPageWithHistory(context, history[index]);
context.to(
() => ComicPage(
id: history[index].id,
sourceKey: history[index].type.comicSource!.key,
),
);
},
borderRadius: BorderRadius.circular(8),
child: Container(
@@ -177,7 +181,7 @@ class _HistoryState extends State<_History> {
);
},
),
).paddingHorizontal(8),
).paddingHorizontal(8).paddingBottom(16),
],
),
),

View 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();
}),
],
);
}
}

View 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,
);
}

View 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);
}

View 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
}
}

View File

@@ -302,6 +302,15 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:

View File

@@ -29,6 +29,10 @@ dependencies:
file_picker: ^8.1.2
url_launcher: ^6.3.0
path: ^1.9.0
photo_view:
git:
url: https://github.com/wgh136/photo_view
ref: 94724a0b
dev_dependencies:
flutter_test: