diff --git a/app/lib/pages/reader_page.dart b/app/lib/pages/reader_page.dart index 17e8c651..ef1948fa 100644 --- a/app/lib/pages/reader_page.dart +++ b/app/lib/pages/reader_page.dart @@ -1,5 +1,3 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; @@ -26,8 +24,7 @@ class ReaderPage extends StatefulWidget { class _ReaderPageState extends State { Book? _book; - Uint8List? _bytes; - ReaderFormat? _format; + ReaderDocument? _document; ReaderLocator? _initialLocator; ReaderPreferences? _initialPreferences; ReaderSession? _session; @@ -86,9 +83,14 @@ class _ReaderPageState extends State { setState(() { _book = book; - _bytes = bytes; - _format = format; - _initialLocator = ReaderBookAdapter.restoreLocator(book!); + _document = ReaderDocument( + id: book!.id, + format: format, + title: book.title, + author: book.author, + loadBytes: () async => bytes, + ); + _initialLocator = ReaderBookAdapter.restoreLocator(book); _initialPreferences = preferences; _session = session; }); @@ -120,21 +122,17 @@ class _ReaderPageState extends State { } final book = _book; - final bytes = _bytes; - final format = _format; + final document = _document; final preferences = _initialPreferences; - if (book == null || bytes == null || format == null || preferences == null) { - return const Scaffold(body: Center(child: AppCircularProgressIndicator())); + if (book == null || document == null || preferences == null) { + return Scaffold( + appBar: AppBar(leading: BackButton(onPressed: _close)), + body: const Center(child: AppCircularProgressIndicator()), + ); } return PapyrusReader( - document: ReaderDocument( - id: book.id, - format: format, - title: book.title, - author: book.author, - loadBytes: () async => bytes, - ), + document: document, initialLocator: _initialLocator, initialPreferences: preferences, onLocatorChanged: _session!.updateLocator, diff --git a/app/pubspec.lock b/app/pubspec.lock index 811e0e77..481ecdd5 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -555,8 +555,8 @@ packages: dependency: "direct main" description: path: "." - ref: "08a5161b9d00eb73581f74ce087b9ad6c1568ca7" - resolved-ref: "08a5161b9d00eb73581f74ce087b9ad6c1568ca7" + ref: e9f91aa1a6fd6a93494e7aeba057f6f65c6e1c18 + resolved-ref: e9f91aa1a6fd6a93494e7aeba057f6f65c6e1c18 url: "https://github.com/PapyrusReader/reader.git" source: git version: "0.0.1" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 15fc7994..1ad6164e 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -29,7 +29,7 @@ dependencies: papyrus_reader: git: url: https://github.com/PapyrusReader/reader.git - ref: 08a5161b9d00eb73581f74ce087b9ad6c1568ca7 + ref: e9f91aa1a6fd6a93494e7aeba057f6f65c6e1c18 epub_pro: ^5.6.0 syncfusion_flutter_pdf: ^32.2.3 dart_mobi: ^1.0.2 diff --git a/app/test/pages/reader_page_test.dart b/app/test/pages/reader_page_test.dart index 0e04ce8a..b4335f7d 100644 --- a/app/test/pages/reader_page_test.dart +++ b/app/test/pages/reader_page_test.dart @@ -1,7 +1,12 @@ +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/media/media_cache_service.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/services/book_import_service_stub.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; import 'package:papyrus/pages/reader_page.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:provider/provider.dart'; @@ -10,6 +15,50 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/test_helpers.dart'; void main() { + testWidgets('host theme changes keep the same open document and avoid rereading media', (tester) async { + SharedPreferences.setMockInitialValues({}); + final preferences = PreferencesProvider(await SharedPreferences.getInstance()); + final dataStore = DataStore() + ..loadData( + books: [buildTestBook(id: 'epub-book', fileFormat: BookFormat.epub)], + ); + final cache = _ReaderMediaCache(); + addTearDown(dataStore.dispose); + late StateSetter updateTheme; + var dark = false; + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: dataStore), + ChangeNotifierProvider.value(value: preferences), + Provider.value(value: cache), + Provider(create: (_) => BookImportService()), + ChangeNotifierProvider(create: (_) => _ReaderAuth()), + ], + child: StatefulBuilder( + builder: (context, setState) { + updateTheme = setState; + return MaterialApp( + theme: ThemeData(brightness: dark ? Brightness.dark : Brightness.light), + home: const ReaderPage(bookId: 'epub-book'), + ); + }, + ), + ), + ); + for (var frame = 0; frame < 8 && find.byType(PapyrusReader).evaluate().isEmpty; frame++) { + await tester.pump(); + } + expect(cache.loads, 1, reason: tester.widgetList(find.byType(Text)).map((text) => text.data).join(' / ')); + expect(find.byType(PapyrusReader), findsOneWidget); + final before = tester.widget(find.byType(PapyrusReader)).document; + updateTheme(() => dark = true); + await tester.pump(); + await tester.pump(); + expect(tester.widget(find.byType(PapyrusReader)).document, same(before)); + expect(cache.loads, 1); + await tester.pumpWidget(const SizedBox.shrink()); + }); testWidgets('explains when a book format is not supported', (tester) async { SharedPreferences.setMockInitialValues({}); final preferences = PreferencesProvider(await SharedPreferences.getInstance()); @@ -33,3 +82,24 @@ void main() { expect(find.text('This book format is not supported yet.'), findsOneWidget); }); } + +class _ReaderMediaCache extends MediaCacheService { + int loads = 0; + @override + Future ensureBookFileCached( + Book book, { + required LocalBookFileReader readLocalBookFile, + required LocalBookFileWriter writeLocalBookFile, + required MediaDownloader downloadMedia, + }) async { + loads++; + return Uint8List.fromList([1, 2, 3]); + } +} + +class _ReaderAuth extends ChangeNotifier implements AuthProvider { + @override + Future downloadMedia(String assetId) async => Uint8List(0); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/app/test/pages/reader_theme_test.dart b/app/test/pages/reader_theme_test.dart new file mode 100644 index 00000000..6daaca85 --- /dev/null +++ b/app/test/pages/reader_theme_test.dart @@ -0,0 +1,112 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; + +void main() { + for (final host in ['light', 'dark', 'eink']) { + for (final reading in [Brightness.light, Brightness.dark]) { + for (final width in [600.0, 1200.0]) { + testWidgets('settings menu contrast: app $host, reader $reading, width $width', (tester) async { + tester.view.physicalSize = Size(width, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final controller = ReaderController( + initialPreferences: ReaderPreferences(brightness: reading), + registry: ReaderEngineRegistry([ + ReaderEngineRegistration( + formats: const {ReaderFormat.pdf}, + factory: () => PdfReaderEngine(facadeFactory: (_) async => _ThemePdfFacade()), + ), + ]), + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + theme: switch (host) { + 'dark' => AppTheme.dark, + 'eink' => AppTheme.eink, + _ => AppTheme.light, + }, + home: PapyrusReader( + document: ReaderDocument(id: 'theme', format: ReaderFormat.pdf, loadBytes: () async => Uint8List(0)), + controller: controller, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Reading settings')); + await tester.pumpAndSettle(); + final label = find.text('Reading mode'); + final readerTheme = Theme.of(tester.element(label)); + final readerColors = readerTheme.colorScheme; + if (host == 'eink') { + expect(readerTheme.extension()!.reduceAnimations, isTrue); + expect(readerTheme.inputDecorationTheme.hintFadeDuration, Duration.zero); + expect(readerTheme.filledButtonTheme.style!.animationDuration, Duration.zero); + expect(readerTheme.splashFactory, same(NoSplash.splashFactory)); + } + final labelText = tester.widget(find.descendant(of: label, matching: find.byType(RichText)).first); + expect(labelText.text.style!.color, readerColors.onSurfaceVariant); + await tester.tap(find.text('Paginated').first); + await tester.pumpAndSettle(); + final option = find.text('Continuous scroll').last; + final menuTheme = Theme.of(tester.element(option)); + expect(menuTheme.canvasColor, readerColors.surface); + final optionText = tester.widget( + find.descendant(of: option, matching: find.byType(RichText)).first, + ); + final foreground = optionText.text.style!.color!.computeLuminance(); + final background = menuTheme.canvasColor.computeLuminance(); + expect( + (math.max(foreground, background) + .05) / (math.min(foreground, background) + .05), + greaterThanOrEqualTo(4.5), + ); + await tester.tap(option); + await tester.pumpAndSettle(); + expect(controller.preferences.layoutMode, ReaderLayoutMode.scroll); + // A mobile route captures the initial host theme. Reopen a popup + // after changing appearance while that settings route is still open. + await tester.tap(find.text(reading == Brightness.light ? 'Night' : 'Light')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Automatic').first); + await tester.pumpAndSettle(); + final columns = find.text('Double').last; + final changedTheme = Theme.of(tester.element(columns)); + expect(changedTheme.brightness, reading == Brightness.light ? Brightness.dark : Brightness.light); + expect(changedTheme.canvasColor, changedTheme.colorScheme.surface); + final columnsText = tester.widget( + find.descendant(of: columns, matching: find.byType(RichText)).first, + ); + final changedForeground = columnsText.text.style!.color!.computeLuminance(); + final changedBackground = changedTheme.canvasColor.computeLuminance(); + expect( + (math.max(changedForeground, changedBackground) + .05) / + (math.min(changedForeground, changedBackground) + .05), + greaterThanOrEqualTo(4.5), + ); + await tester.tap(columns); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + } + } +} + +final class _ThemePdfFacade implements PdfFacade { + @override + int get pageCount => 3; + @override + List get outline => const []; + @override + Future showPage(int pageIndex, double pageOffset) async {} + @override + Widget buildViewport(PdfViewportConfiguration configuration) => const SizedBox.expand(); +}