From c870bf4df98b01530c6b6e6ae5cd59bd5ea58cc3 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 4 Oct 2026 00:13:33 +0300 Subject: [PATCH] refactor: separate reader controls and panels from session coordination --- AGENTS.md | 4 +- README.md | 1 - docs/architecture.md | 38 + docs/reader-plan.md | 92 -- docs/supported-formats.md | 2 +- docs/validation.md | 4 +- lib/src/presentation/papyrus_reader.dart | 836 +----------------- .../presentation/reader_contents_panel.dart | 123 +++ lib/src/presentation/reader_controls.dart | 278 ++++++ lib/src/presentation/reader_panel_header.dart | 56 ++ .../presentation/reader_settings_panel.dart | 393 ++++++++ pubspec.yaml | 36 - 12 files changed, 904 insertions(+), 959 deletions(-) delete mode 100644 docs/reader-plan.md create mode 100644 lib/src/presentation/reader_contents_panel.dart create mode 100644 lib/src/presentation/reader_controls.dart create mode 100644 lib/src/presentation/reader_panel_header.dart create mode 100644 lib/src/presentation/reader_settings_panel.dart diff --git a/AGENTS.md b/AGENTS.md index 8232d80..a7a8ec2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ This is an independent Flutter package and Git repository. The owning workspace is one level up; use its `tools/flutter` and `tools/dart` SDK wrappers when present. -Read `docs/reader-plan.md` before changing architecture or expanding formats. +Read `docs/architecture.md` before changing architecture or expanding formats. - Domain types are host-facing and serializable. Keep version-1 locators readable. EPUB content offsets are chapter-local normalized UTF-16 offsets, never page @@ -15,7 +15,7 @@ Read `docs/reader-plan.md` before changing architecture or expanding formats. - Rebuild `assets/epub_worker.js` with `tool/build_epub_worker.sh` after modifying worker source. The packaged JS lets dependent applications build without a manual worker generation step. CI checks the generated asset for drift. - This private repository commits `pubspec.lock` and pins its CI SDK so the + This package commits `pubspec.lock` and pins its CI SDK so the browser worker and third-party notices are reproducible. - UI text measurement belongs in Flutter. Preserve semantic runs, Unicode, illustrations and content offsets when reflowing. Layout must use the space diff --git a/README.md b/README.md index e5d1590..6caec2b 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,6 @@ return `ReaderErrorCode.unsupportedFixedLayout`. MOBI/AZW3, TXT, comic archives, search, bookmarks, highlights, and notes are planned extensions. - [Architecture](docs/architecture.md) -- [Core release plan](docs/reader-plan.md) - [Client integration](docs/integration.md) - [Supported formats](docs/supported-formats.md) diff --git a/docs/architecture.md b/docs/architecture.md index ba454f1..54609a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,3 +72,41 @@ Material defaults are constructed from the reading palette rather than copying the host's resolved colors. This includes the legacy canvas color used by dropdown popup routes, field labels/borders and disabled controls. Host typography, control geometry, theme extensions and motion policy remain available to the reader. + +The presentation layer keeps session, keyboard and viewport coordination in +`papyrus_reader.dart`. Toolbar/progress controls, contents, settings and the panel +header live in separate presentation modules. These modules are implementation +details and are not exported by the package entry point. Public builder contexts +and custom-engine integration remain in the shell's existing public API. + +## Library decisions + +- Retain `epub_pro` 5.6 for EPUB container, metadata and navigation parsing behind + a worker boundary. Do not treat its TOC as the OPF spine. +- Retain `pdfrx` 2.x for all six PDF targets. Its native/WASM viewer already owns + rendering and zoom; a facade keeps engine tests independent of PDFium. +- Retain `flutter_html` for the injectable HTML scrolling renderer. Native rich + pagination must preserve semantic runs and images instead of extracting only + plain text. Reader preferences override publisher colors and text sizing. +- Use Dart/Flutter SDK concurrency plus `web` for a packaged browser worker. + Flutter `compute` alone is insufficient: it uses the UI event loop on web. +- Avoid adding platform WebViews for this release: desktop/Linux and web would + need different engines and bridges. Re-evaluate a browser EPUB renderer for + full publisher CSS, fixed-layout EPUB and standardized CFI interoperability. + +Sources: [Flutter isolate behavior](https://docs.flutter.dev/perf/isolates), +[epub_pro API](https://pub.dev/documentation/epub_pro/5.6.0/epub_pro/EpubReader-class.html), +[pdfrx](https://github.com/espresso3389/pdfrx/tree/master/packages/pdfrx). + +## UI and integration + +Keep a quiet reading canvas, readable line length, 48px controls, clear contents +and appearance panels, and a progress scrubber. Typography and navigation must +behave the same on phone and desktop. Respect reduced motion and e-ink hosts. +Capability flags expose only controls implemented by the active engine. + +Existing `ReaderDocument`, controller ownership and observer callbacks remain +compatible. EPUB locators gain optional content offsets; legacy CFI strings are +compatibility data, not a claim of full EPUB CFI conformance. The Papyrus adapter +already stores complete locator JSON, so optional locator fields need no database +schema migration. Host preference and progress writes remain host-owned. diff --git a/docs/reader-plan.md b/docs/reader-plan.md deleted file mode 100644 index b250b6d..0000000 --- a/docs/reader-plan.md +++ /dev/null @@ -1,92 +0,0 @@ -# Core reader release - -## Goal and acceptance criteria - -Make reflowable EPUB and PDF comfortable to read on phones, tablets and desktops. -The first release prioritizes opening, navigation, layout, appearance and resume. -Search, bookmarks, annotations and additional formats are separate releases. - -- File loading must leave Back and the loading UI responsive. A newer open must - not wait for an obsolete open; closing releases document workers. -- EPUB archive/XML/HTML processing runs outside the UI thread on native **and - web**. Only the current chapter is prepared, with a bounded chapter cache. -- EPUB reading order comes from the OPF spine, independent of TOC order. -- Next/Previous and keyboard controls turn a page before crossing chapters. - Swiping beyond a chapter reaches the adjacent chapter. -- Preserve headings, emphasis, paragraphs, lists, line breaks and illustrations. - Layout uses the available viewport, including panels and accessible text scale. - Narrow screens use one column; wide screens can display a two-page spread. -- Persist content-based EPUB positions through typography changes; keep reading - existing version-1 locators. PDF retains page and intra-page offset. -- Settings apply to content and chrome; mobile panels fit landscape and keyboard - insets. Loading and error views always offer a way back. -- Run unit/widget tests, analyze, example web compilation and visual checks at - phone, landscape and desktop sizes. Validate the client against a local path - override before updating its released Git pin. - -## Architecture - -Host application → PapyrusReader shell → ReaderController → format engine. -The host owns bytes, routing, account scope and durable persistence. The package -owns transient document sessions, rendering, navigation and reader chrome. - -EPUB engine → document worker (resident native isolate / browser Worker) → -epub_pro archive and OPF parser → sanitized chapter and structured content. -Flutter measures and lays out the current chapter; expensive layout yields -between work batches, caches results and creates only visible page widgets. -Document processing never needs Flutter, network access or host services. - -PDF engine → pdfrx facade → PDFium on native / PDFium WASM on web. Retain pdfrx -instead of writing another PDF renderer. Use viewport constraints for spreads, -preserve its zoom/text interaction, and observe offsets for accurate resume. - -Controller load requests have independent candidate engines. Publication is -generation-guarded; superseded candidates are disposed immediately. The shell -serializes user navigation and preference commands, with errors visible to the -user. A content viewport remains mounted while progress chrome updates. - -## Library decisions - -- Retain `epub_pro` 5.6 for EPUB container, metadata and navigation parsing behind - a worker boundary. Do not treat its TOC as the OPF spine. -- Retain `pdfrx` 2.x for all six PDF targets. Its native/WASM viewer already owns - rendering and zoom; a facade keeps engine tests independent of PDFium. -- Retain `flutter_html` for the injectable HTML scrolling renderer. Native rich - pagination must preserve semantic runs and images instead of extracting only - plain text. Reader preferences override publisher colors and text sizing. -- Use Dart/Flutter SDK concurrency plus `web` for a packaged browser worker. - Flutter `compute` alone is insufficient: it uses the UI event loop on web. -- Avoid adding platform WebViews for this release: desktop/Linux and web would - need different engines and bridges. Re-evaluate a browser EPUB renderer for - full publisher CSS, fixed-layout EPUB and standardized CFI interoperability. - -Sources: [Flutter isolate behavior](https://docs.flutter.dev/perf/isolates), -[epub_pro API](https://pub.dev/documentation/epub_pro/5.6.0/epub_pro/EpubReader-class.html), -[pdfrx](https://github.com/espresso3389/pdfrx/tree/master/packages/pdfrx). - -## UI and integration - -Keep a quiet reading canvas, readable line length, 48px controls, clear contents -and appearance panels, and a progress scrubber. Typography and navigation must -behave the same on phone and desktop. Respect reduced motion and e-ink hosts. -Capability flags expose only controls implemented by the active engine. - -Existing `ReaderDocument`, controller ownership and observer callbacks remain -compatible. EPUB locators gain optional content offsets; legacy CFI strings are -compatibility data, not a claim of full EPUB CFI conformance. The Papyrus adapter -already stores complete locator JSON, so optional locator fields need no database -schema migration. Host preference and progress writes remain host-owned. - -## Follow-on releases - -1. Search with a cancellable worker index, bookmarks and chapter/page jump UI. -2. Selection-based highlights and notes, versioned annotation anchors and an - explicit host persistence interface; offline synchronization stays in client. -3. Publisher CSS/embedded fonts, RTL progression, footnotes/internal links, - standardized CFI conformance and complex tables/math fixtures. -4. Fixed-layout EPUB, TXT and comics via separate engines. MOBI/AZW3 requires a - conversion/parser decision and legal/license review before claiming support. - -Performance acceptance needs a representative corpus: long chapters, images, -tables, malformed archives and large PDFs. Synthetic tests prevent regressions -but do not establish a device-wide performance guarantee. diff --git a/docs/supported-formats.md b/docs/supported-formats.md index 6424ea6..87ef622 100644 --- a/docs/supported-formats.md +++ b/docs/supported-formats.md @@ -13,5 +13,5 @@ and illustrations are preserved. Complex tables become readable rows; publisher stylesheets, embedded fonts, advanced SVG/math, RTL book progression and footnote interaction are not complete. Font choices use installed platform families. -Search, bookmarks, highlights and notes are planned in `reader-plan.md`. Do not +Search, bookmarks, highlights and notes are future extensions. Do not advertise them until their engines, UI and host persistence contracts exist. diff --git a/docs/validation.md b/docs/validation.md index 0c534cf..080ffa8 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -63,5 +63,5 @@ remain necessary before declaring a production release. No cold-open or frame latency guarantee is claimed from the synthetic fixtures. Publisher CSS/fonts, RTL progression, complex tables/math and fixed-layout EPUB -are outside this core implementation; see `supported-formats.md` and the release -plan before evaluating books that rely on those features. +are outside this core implementation; see `supported-formats.md` and the architecture +reference before evaluating books that rely on those features. diff --git a/lib/src/presentation/papyrus_reader.dart b/lib/src/presentation/papyrus_reader.dart index 1429728..fadf87e 100644 --- a/lib/src/presentation/papyrus_reader.dart +++ b/lib/src/presentation/papyrus_reader.dart @@ -6,16 +6,17 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../controller/reader_controller.dart'; -import '../domain/reader_capabilities.dart'; import '../domain/reader_document.dart'; import '../domain/reader_exception.dart'; import '../domain/reader_locator.dart'; import '../domain/reader_preferences.dart'; import '../domain/reader_snapshot.dart'; -import '../domain/reader_toc_entry.dart'; import '../domain/reader_types.dart'; import '../engine/reader_engine_registry.dart'; import 'reader_material_theme.dart'; +import 'reader_controls.dart'; +import 'reader_contents_panel.dart'; +import 'reader_settings_panel.dart'; import 'reader_theme_data.dart'; import 'reader_ui_builders.dart'; @@ -337,7 +338,7 @@ final class _PapyrusReaderState extends State { if (widget.onBack != null) Align( alignment: AlignmentDirectional.centerStart, - child: _ReaderIconButton( + child: ReaderIconButton( icon: Icons.arrow_back_rounded, tooltip: 'Back', onPressed: widget.onBack, @@ -533,7 +534,7 @@ final class _PapyrusReaderState extends State { order: const NumericFocusOrder(1), child: toolbar ?? - _DefaultReaderToolbar( + ReaderToolbar( document: widget.document, onBack: widget.onBack, onTableOfContents: () => _openPanel( @@ -566,7 +567,7 @@ final class _PapyrusReaderState extends State { : ColoredBox(color: theme.dividerColor), ), if (_commandError case final error?) - _ReaderCommandError( + ReaderCommandError( message: error, theme: theme, onDismiss: () => setState(() => _commandError = null), @@ -609,7 +610,7 @@ final class _PapyrusReaderState extends State { FocusTraversalOrder( key: const ValueKey('reader-progress-controls'), order: const NumericFocusOrder(4), - child: _ReaderProgressControls( + child: ReaderProgressControls( progress: _dragProgress ?? _progressOf(snapshot.locator), locationLabel: snapshot.locationLabel, @@ -629,7 +630,7 @@ final class _PapyrusReaderState extends State { PositionedDirectional( key: const ValueKey('reader-controls-toggle'), top: _controlsVisible - ? (_readerToolbarHeight(context, theme, isWide) - + ? (readerToolbarHeight(context, theme, isWide) - theme.minimumTargetSize) / 2 : 8, @@ -662,7 +663,7 @@ final class _PapyrusReaderState extends State { data: IconThemeData( color: theme.onChromeColor, ), - child: _ReaderIconButton( + child: ReaderIconButton( icon: _controlsVisible ? Icons.fullscreen_rounded : Icons.fullscreen_exit_rounded, @@ -898,7 +899,7 @@ final class _PapyrusReaderState extends State { return switch (panel) { _ReaderPanel.tableOfContents => widget.builders.tableOfContents?.call(context, state) ?? - _TableOfContentsPanel( + ReaderContentsPanel( snapshot: snapshot, close: close, theme: theme, @@ -913,7 +914,7 @@ final class _PapyrusReaderState extends State { ), _ReaderPanel.settings => widget.builders.settings?.call(context, state) ?? - _ReaderSettingsPanel( + ReaderSettingsPanel( snapshot: snapshot, close: close, theme: theme, @@ -1038,821 +1039,6 @@ final class _PapyrusReaderState extends State { } } -final class _ReaderCommandError extends StatelessWidget { - const _ReaderCommandError({ - required this.message, - required this.theme, - required this.onDismiss, - }); - - final String message; - final ReaderThemeData theme; - final VoidCallback onDismiss; - - @override - Widget build(BuildContext context) { - return Material( - key: const ValueKey('reader-command-error'), - color: theme.errorColor, - child: Padding( - padding: const EdgeInsetsDirectional.only(start: 16), - child: Row( - children: [ - Expanded( - child: Text( - message, - style: TextStyle( - color: - ThemeData.estimateBrightnessForColor(theme.errorColor) == - Brightness.dark - ? Colors.white - : Colors.black, - ), - ), - ), - IconButton( - tooltip: 'Dismiss reader error', - onPressed: onDismiss, - icon: const Icon(Icons.close_rounded), - ), - ], - ), - ), - ); - } -} - -double _readerToolbarHeight( - BuildContext context, - ReaderThemeData theme, - bool isWide, -) { - final textScale = MediaQuery.textScalerOf(context).scale(1); - return math.max( - isWide ? theme.wideToolbarHeight : theme.compactToolbarHeight, - theme.minimumTargetSize + math.max(8, (textScale - 1) * 12), - ); -} - -final class _DefaultReaderToolbar extends StatelessWidget { - const _DefaultReaderToolbar({ - required this.document, - required this.onBack, - required this.onTableOfContents, - required this.onSettings, - required this.tocFocusNode, - required this.settingsFocusNode, - required this.theme, - required this.isWide, - }); - - final ReaderDocument document; - final VoidCallback? onBack; - final VoidCallback onTableOfContents; - final VoidCallback onSettings; - final FocusNode tocFocusNode; - final FocusNode settingsFocusNode; - final ReaderThemeData theme; - final bool isWide; - - @override - Widget build(BuildContext context) { - final height = _readerToolbarHeight(context, theme, isWide); - - return Container( - height: height, - padding: const EdgeInsets.symmetric(horizontal: 8), - color: theme.chromeColor, - child: IconTheme( - data: IconThemeData(color: theme.onChromeColor), - child: DefaultTextStyle.merge( - style: TextStyle(color: theme.onChromeColor), - child: Row( - children: [ - if (onBack != null) - _ReaderIconButton( - icon: Icons.arrow_back_rounded, - tooltip: 'Back', - onPressed: onBack!, - theme: theme, - ), - const SizedBox(width: 4), - Expanded( - child: Text( - document.title?.trim().isNotEmpty == true - ? document.title!.trim() - : 'Reader', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: theme.onChromeColor, - fontWeight: FontWeight.w600, - ), - ), - ), - _ReaderIconButton( - key: const ValueKey('reader-toc-button'), - focusNode: tocFocusNode, - icon: Icons.format_list_bulleted_rounded, - tooltip: 'Table of contents', - onPressed: onTableOfContents, - theme: theme, - ), - _ReaderIconButton( - key: const ValueKey('reader-settings-button'), - focusNode: settingsFocusNode, - icon: Icons.text_fields_rounded, - tooltip: 'Reading settings', - onPressed: onSettings, - theme: theme, - ), - // The controls toggle stays mounted in the shell overlay during - // reflow, preserving browser accessibility and keyboard focus. - SizedBox(width: theme.minimumTargetSize), - ], - ), - ), - ), - ); - } -} - -final class _ReaderIconButton extends StatelessWidget { - const _ReaderIconButton({ - required this.icon, - required this.tooltip, - required this.onPressed, - required this.theme, - this.focusNode, - super.key, - }); - - final IconData icon; - final String? tooltip; - final VoidCallback? onPressed; - final ReaderThemeData theme; - final FocusNode? focusNode; - - @override - Widget build(BuildContext context) { - return IconButton( - focusNode: focusNode, - constraints: BoxConstraints.tightFor( - width: theme.minimumTargetSize, - height: theme.minimumTargetSize, - ), - tooltip: tooltip, - onPressed: onPressed, - icon: Icon(icon), - ); - } -} - -final class _ReaderProgressControls extends StatelessWidget { - const _ReaderProgressControls({ - required this.progress, - required this.onProgressChanged, - required this.onProgressChangeEnd, - required this.onPrevious, - required this.onNext, - required this.enabled, - required this.theme, - this.locationLabel, - }); - - final double progress; - final ValueChanged onProgressChanged; - final ValueChanged onProgressChangeEnd; - final VoidCallback onPrevious; - final VoidCallback onNext; - final bool enabled; - final ReaderThemeData theme; - final String? locationLabel; - - @override - Widget build(BuildContext context) { - final normalized = progress.clamp(0.0, 1.0); - final percent = (normalized * 100).round(); - - return ColoredBox( - color: theme.chromeColor, - child: IconTheme( - data: IconThemeData(color: theme.onChromeColor), - child: DefaultTextStyle.merge( - style: TextStyle(color: theme.onChromeColor), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (locationLabel != null) - Text( - locationLabel!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: theme.onChromeColor, - ), - ), - Row( - children: [ - _ReaderIconButton( - icon: Icons.chevron_left_rounded, - tooltip: 'Previous', - onPressed: enabled ? onPrevious : null, - theme: theme, - ), - Expanded( - child: Semantics( - label: 'Reading progress', - value: '$percent percent', - slider: true, - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - activeTrackColor: theme.progressColor, - thumbColor: theme.handleColor, - ), - child: Slider( - value: normalized, - onChanged: enabled ? onProgressChanged : null, - onChangeEnd: enabled ? onProgressChangeEnd : null, - ), - ), - ), - ), - SizedBox( - width: 64, - child: Text( - '$percent%', - key: const ValueKey('reader-progress-label'), - textAlign: TextAlign.center, - maxLines: 1, - semanticsLabel: '$percent percent read', - ), - ), - _ReaderIconButton( - icon: Icons.chevron_right_rounded, - tooltip: 'Next', - onPressed: enabled ? onNext : null, - theme: theme, - ), - ], - ), - ], - ), - ), - ), - ), - ); - } -} - -final class _TableOfContentsPanel extends StatelessWidget { - const _TableOfContentsPanel({ - required this.snapshot, - required this.close, - required this.theme, - required this.enabled, - required this.onNavigate, - }); - - final ReaderSnapshot snapshot; - final VoidCallback close; - final ReaderThemeData theme; - final bool enabled; - final ValueChanged onNavigate; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - _PanelHeader(title: 'Contents', close: close, theme: theme), - Expanded( - child: snapshot.toc.isEmpty - ? const Center( - child: Padding( - padding: EdgeInsets.all(24), - child: Text('No table of contents is available.'), - ), - ) - : ListView( - children: [ - for (final entry in snapshot.toc) - _TocEntryTile( - entry: entry, - enabled: enabled, - onNavigate: onNavigate, - ), - ], - ), - ), - ], - ); - } -} - -final class _TocEntryTile extends StatefulWidget { - const _TocEntryTile({ - required this.entry, - required this.enabled, - required this.onNavigate, - this.depth = 0, - }); - - final ReaderTocEntry entry; - final bool enabled; - final ValueChanged onNavigate; - final int depth; - - @override - State<_TocEntryTile> createState() => _TocEntryTileState(); -} - -final class _TocEntryTileState extends State<_TocEntryTile> { - bool _expanded = false; - - void _navigate() { - widget.onNavigate(widget.entry.locator); - } - - @override - Widget build(BuildContext context) { - final entry = widget.entry; - if (entry.children.isEmpty) { - return ListTile( - contentPadding: EdgeInsetsDirectional.only( - start: 16 + widget.depth * 16, - end: 16, - ), - minVerticalPadding: 12, - title: Text(entry.title), - onTap: widget.enabled ? _navigate : null, - ); - } - - return Column( - children: [ - ListTile( - contentPadding: EdgeInsetsDirectional.only( - start: 16 + widget.depth * 16, - end: 4, - ), - title: Text(entry.title), - onTap: widget.enabled ? _navigate : null, - trailing: IconButton( - tooltip: _expanded ? 'Collapse section' : 'Expand section', - onPressed: widget.enabled - ? () => setState(() => _expanded = !_expanded) - : null, - icon: Icon( - _expanded ? Icons.expand_less_rounded : Icons.expand_more_rounded, - ), - ), - ), - if (_expanded) - for (final child in entry.children) - _TocEntryTile( - entry: child, - enabled: widget.enabled, - onNavigate: widget.onNavigate, - depth: widget.depth + 1, - ), - ], - ); - } -} - -final class _ReaderSettingsPanel extends StatelessWidget { - const _ReaderSettingsPanel({ - required this.snapshot, - required this.close, - required this.theme, - required this.enabled, - required this.isBusy, - required this.commandRevision, - required this.onUpdatePreferences, - }); - - final ReaderSnapshot snapshot; - final VoidCallback close; - final ReaderThemeData theme; - final bool enabled; - final bool isBusy; - final int commandRevision; - final ValueChanged onUpdatePreferences; - - @override - Widget build(BuildContext context) { - final capabilities = snapshot.capabilities ?? const ReaderCapabilities(); - final preferences = snapshot.preferences; - - return Column( - children: [ - _PanelHeader(title: 'Reading settings', close: close, theme: theme), - Expanded( - child: ListView( - padding: EdgeInsets.all(theme.panelPadding), - children: [ - if (capabilities.supportsTextCustomization) ...[ - DropdownButtonFormField( - key: ValueKey(('reader-font', preferences.fontFamily)), - initialValue: - const [ - null, - 'serif', - 'sans-serif', - 'monospace', - ].contains(preferences.fontFamily) - ? preferences.fontFamily ?? 'system' - : 'system', - isExpanded: true, - decoration: const InputDecoration( - labelText: 'Typeface', - border: OutlineInputBorder(), - ), - items: const [ - DropdownMenuItem(value: 'system', child: Text('System')), - DropdownMenuItem(value: 'serif', child: Text('Serif')), - DropdownMenuItem( - value: 'sans-serif', - child: Text('Sans serif'), - ), - DropdownMenuItem( - value: 'monospace', - child: Text('Monospace'), - ), - ], - onChanged: enabled - ? (value) => _update( - preferences.copyWith( - fontFamily: value == 'system' ? null : value, - ), - ) - : null, - ), - const SizedBox(height: 20), - _SettingSlider( - title: 'Font size', - value: preferences.fontSize.clamp(8, 72), - min: 8, - max: 72, - divisions: 64, - valueLabel: preferences.fontSize.round().toString(), - enabled: enabled, - isBusy: isBusy, - commandRevision: commandRevision, - onChangeEnd: (value) => - _update(preferences.copyWith(fontSize: value)), - ), - _SettingSlider( - title: 'Line height', - value: preferences.lineHeight.clamp(1, 2.5), - min: 1, - max: 2.5, - divisions: 15, - valueLabel: preferences.lineHeight.toStringAsFixed(1), - enabled: enabled, - isBusy: isBusy, - commandRevision: commandRevision, - onChangeEnd: (value) => - _update(preferences.copyWith(lineHeight: value)), - ), - _SettingSlider( - title: 'Margins', - value: preferences.pageMargins.left.clamp(0, 64), - min: 0, - max: 64, - divisions: 16, - valueLabel: preferences.pageMargins.left.round().toString(), - enabled: enabled, - isBusy: isBusy, - commandRevision: commandRevision, - onChangeEnd: (value) => _update( - preferences.copyWith(pageMargins: EdgeInsets.all(value)), - ), - ), - const SizedBox(height: 12), - ], - if (capabilities.supportsPagination || - capabilities.supportsScrolling) ...[ - DropdownButtonFormField( - key: ValueKey(('reader-mode', preferences.layoutMode)), - initialValue: preferences.layoutMode, - isExpanded: true, - decoration: const InputDecoration( - labelText: 'Reading mode', - border: OutlineInputBorder(), - ), - items: [ - if (capabilities.supportsPagination) - const DropdownMenuItem( - value: ReaderLayoutMode.paginated, - child: Text('Paginated'), - ), - if (capabilities.supportsScrolling) - const DropdownMenuItem( - value: ReaderLayoutMode.scroll, - child: Text('Continuous scroll'), - ), - ], - onChanged: enabled - ? (value) { - if (value != null) { - _update(preferences.copyWith(layoutMode: value)); - } - } - : null, - ), - ], - if (capabilities.supportsColumnMode) ...[ - const SizedBox(height: 12), - DropdownButtonFormField( - key: ValueKey(('reader-columns', preferences.columnMode)), - initialValue: preferences.columnMode, - isExpanded: true, - decoration: const InputDecoration( - labelText: 'Columns', - border: OutlineInputBorder(), - ), - items: const [ - DropdownMenuItem( - value: ReaderColumnMode.automatic, - child: Text('Automatic'), - ), - DropdownMenuItem( - value: ReaderColumnMode.single, - child: Text('Single'), - ), - DropdownMenuItem( - value: ReaderColumnMode.double, - child: Text('Double'), - ), - ], - onChanged: enabled - ? (value) { - if (value != null) { - _update(preferences.copyWith(columnMode: value)); - } - } - : null, - ), - ], - const SizedBox(height: 20), - Text( - 'Page appearance', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _AppearancePreset( - label: 'Light', - selected: - preferences.brightness == Brightness.light && - preferences.backgroundColor == const Color(0xffffffff), - background: const Color(0xffffffff), - foreground: const Color(0xff1b1b1b), - brightness: Brightness.light, - enabled: enabled, - onSelected: _applyAppearance, - ), - _AppearancePreset( - label: 'Sepia', - selected: - preferences.backgroundColor == const Color(0xfff4ecd8), - background: const Color(0xfff4ecd8), - foreground: const Color(0xff3d3527), - brightness: Brightness.light, - enabled: enabled, - onSelected: _applyAppearance, - ), - _AppearancePreset( - label: 'Night', - selected: preferences.brightness == Brightness.dark, - background: const Color(0xff151719), - foreground: const Color(0xffece7de), - brightness: Brightness.dark, - enabled: enabled, - onSelected: _applyAppearance, - ), - ], - ), - ], - ), - ), - ], - ); - } - - void _update(ReaderPreferences preferences) { - onUpdatePreferences(preferences); - } - - void _applyAppearance( - Color background, - Color foreground, - Brightness brightness, - ) { - _update( - snapshot.preferences.copyWith( - backgroundColor: background, - foregroundColor: foreground, - brightness: brightness, - ), - ); - } -} - -final class _SettingSlider extends StatefulWidget { - const _SettingSlider({ - required this.title, - required this.value, - required this.min, - required this.max, - required this.divisions, - required this.valueLabel, - required this.enabled, - required this.isBusy, - required this.commandRevision, - required this.onChangeEnd, - }); - - final String title; - final double value; - final double min; - final double max; - final int divisions; - final String valueLabel; - final bool enabled; - final bool isBusy; - final int commandRevision; - final ValueChanged onChangeEnd; - - @override - State<_SettingSlider> createState() => _SettingSliderState(); -} - -final class _SettingSliderState extends State<_SettingSlider> { - late double _preview = widget.value; - bool _interacting = false; - - @override - void didUpdateWidget(_SettingSlider oldWidget) { - super.didUpdateWidget(oldWidget); - final commandSettled = - oldWidget.commandRevision != widget.commandRevision && !widget.isBusy; - if (!_interacting && (oldWidget.value != widget.value || commandSettled)) { - _preview = widget.value; - } - } - - String get _previewLabel { - if (_preview == widget.value) { - return widget.valueLabel; - } - - return widget.max <= 3 - ? _preview.toStringAsFixed(1) - : _preview.round().toString(); - } - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded(child: Text(widget.title)), - Text(_previewLabel), - ], - ), - Slider( - value: _preview, - min: widget.min, - max: widget.max, - divisions: widget.divisions, - label: _previewLabel, - onChangeStart: widget.enabled - ? (_) => setState(() => _interacting = true) - : null, - onChanged: widget.enabled - ? (value) => setState(() => _preview = value) - : null, - onChangeEnd: widget.enabled - ? (value) { - setState(() { - _preview = value; - _interacting = false; - }); - widget.onChangeEnd(value); - } - : null, - ), - ], - ); - } -} - -typedef _AppearanceChanged = - void Function(Color background, Color foreground, Brightness brightness); - -final class _AppearancePreset extends StatelessWidget { - const _AppearancePreset({ - required this.label, - required this.selected, - required this.background, - required this.foreground, - required this.brightness, - required this.enabled, - required this.onSelected, - }); - - final String label; - final bool selected; - final Color background; - final Color foreground; - final Brightness brightness; - final bool enabled; - final _AppearanceChanged onSelected; - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - return ChoiceChip( - label: Text(label), - labelStyle: TextStyle( - color: selected ? colors.onSecondaryContainer : colors.onSurface, - ), - color: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.selected) - ? colors.secondaryContainer - : colors.surfaceContainerLow, - ), - checkmarkColor: colors.onSecondaryContainer, - side: BorderSide(color: colors.outlineVariant), - selected: selected, - avatar: CircleAvatar(backgroundColor: background), - onSelected: enabled - ? (_) => onSelected(background, foreground, brightness) - : null, - ); - } -} - -final class _PanelHeader extends StatelessWidget { - const _PanelHeader({ - required this.title, - required this.close, - required this.theme, - }); - - final String title; - final VoidCallback close; - final ReaderThemeData theme; - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: theme.dividerColor)), - ), - child: IconTheme( - data: IconThemeData(color: theme.onChromeColor), - child: DefaultTextStyle.merge( - style: TextStyle(color: theme.onChromeColor), - child: Padding( - padding: const EdgeInsetsDirectional.only(start: 16, end: 4), - child: Row( - children: [ - Expanded( - child: Semantics( - header: true, - child: Text( - title, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: theme.onChromeColor, - ), - ), - ), - ), - _ReaderIconButton( - icon: Icons.close_rounded, - tooltip: 'Close panel', - onPressed: close, - theme: theme, - ), - ], - ), - ), - ), - ), - ); - } -} - double _progressOf(ReaderLocator? locator) { return switch (locator) { EpubReaderLocator(:final totalProgression) => totalProgression, diff --git a/lib/src/presentation/reader_contents_panel.dart b/lib/src/presentation/reader_contents_panel.dart new file mode 100644 index 0000000..e878e8f --- /dev/null +++ b/lib/src/presentation/reader_contents_panel.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; + +import '../domain/reader_locator.dart'; +import '../domain/reader_snapshot.dart'; +import '../domain/reader_toc_entry.dart'; +import 'reader_panel_header.dart'; +import 'reader_theme_data.dart'; + +final class ReaderContentsPanel extends StatelessWidget { + const ReaderContentsPanel({ + super.key, + required this.snapshot, + required this.close, + required this.theme, + required this.enabled, + required this.onNavigate, + }); + + final ReaderSnapshot snapshot; + final VoidCallback close; + final ReaderThemeData theme; + final bool enabled; + final ValueChanged onNavigate; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ReaderPanelHeader(title: 'Contents', close: close, theme: theme), + Expanded( + child: snapshot.toc.isEmpty + ? const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text('No table of contents is available.'), + ), + ) + : ListView( + children: [ + for (final entry in snapshot.toc) + _TocEntryTile( + entry: entry, + enabled: enabled, + onNavigate: onNavigate, + ), + ], + ), + ), + ], + ); + } +} + +final class _TocEntryTile extends StatefulWidget { + const _TocEntryTile({ + required this.entry, + required this.enabled, + required this.onNavigate, + this.depth = 0, + }); + + final ReaderTocEntry entry; + final bool enabled; + final ValueChanged onNavigate; + final int depth; + + @override + State<_TocEntryTile> createState() => _TocEntryTileState(); +} + +final class _TocEntryTileState extends State<_TocEntryTile> { + bool _expanded = false; + + void _navigate() { + widget.onNavigate(widget.entry.locator); + } + + @override + Widget build(BuildContext context) { + final entry = widget.entry; + if (entry.children.isEmpty) { + return ListTile( + contentPadding: EdgeInsetsDirectional.only( + start: 16 + widget.depth * 16, + end: 16, + ), + minVerticalPadding: 12, + title: Text(entry.title), + onTap: widget.enabled ? _navigate : null, + ); + } + + return Column( + children: [ + ListTile( + contentPadding: EdgeInsetsDirectional.only( + start: 16 + widget.depth * 16, + end: 4, + ), + title: Text(entry.title), + onTap: widget.enabled ? _navigate : null, + trailing: IconButton( + tooltip: _expanded ? 'Collapse section' : 'Expand section', + onPressed: widget.enabled + ? () => setState(() => _expanded = !_expanded) + : null, + icon: Icon( + _expanded ? Icons.expand_less_rounded : Icons.expand_more_rounded, + ), + ), + ), + if (_expanded) + for (final child in entry.children) + _TocEntryTile( + entry: child, + enabled: widget.enabled, + onNavigate: widget.onNavigate, + depth: widget.depth + 1, + ), + ], + ); + } +} diff --git a/lib/src/presentation/reader_controls.dart b/lib/src/presentation/reader_controls.dart new file mode 100644 index 0000000..b3e3135 --- /dev/null +++ b/lib/src/presentation/reader_controls.dart @@ -0,0 +1,278 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../domain/reader_document.dart'; +import 'reader_theme_data.dart'; + +final class ReaderCommandError extends StatelessWidget { + const ReaderCommandError({ + super.key, + required this.message, + required this.theme, + required this.onDismiss, + }); + + final String message; + final ReaderThemeData theme; + final VoidCallback onDismiss; + + @override + Widget build(BuildContext context) { + return Material( + key: const ValueKey('reader-command-error'), + color: theme.errorColor, + child: Padding( + padding: const EdgeInsetsDirectional.only(start: 16), + child: Row( + children: [ + Expanded( + child: Text( + message, + style: TextStyle( + color: + ThemeData.estimateBrightnessForColor(theme.errorColor) == + Brightness.dark + ? Colors.white + : Colors.black, + ), + ), + ), + IconButton( + tooltip: 'Dismiss reader error', + onPressed: onDismiss, + icon: const Icon(Icons.close_rounded), + ), + ], + ), + ), + ); + } +} + +double readerToolbarHeight( + BuildContext context, + ReaderThemeData theme, + bool isWide, +) { + final textScale = MediaQuery.textScalerOf(context).scale(1); + return math.max( + isWide ? theme.wideToolbarHeight : theme.compactToolbarHeight, + theme.minimumTargetSize + math.max(8, (textScale - 1) * 12), + ); +} + +final class ReaderToolbar extends StatelessWidget { + const ReaderToolbar({ + super.key, + required this.document, + required this.onBack, + required this.onTableOfContents, + required this.onSettings, + required this.tocFocusNode, + required this.settingsFocusNode, + required this.theme, + required this.isWide, + }); + + final ReaderDocument document; + final VoidCallback? onBack; + final VoidCallback onTableOfContents; + final VoidCallback onSettings; + final FocusNode tocFocusNode; + final FocusNode settingsFocusNode; + final ReaderThemeData theme; + final bool isWide; + + @override + Widget build(BuildContext context) { + final height = readerToolbarHeight(context, theme, isWide); + + return Container( + height: height, + padding: const EdgeInsets.symmetric(horizontal: 8), + color: theme.chromeColor, + child: IconTheme( + data: IconThemeData(color: theme.onChromeColor), + child: DefaultTextStyle.merge( + style: TextStyle(color: theme.onChromeColor), + child: Row( + children: [ + if (onBack != null) + ReaderIconButton( + icon: Icons.arrow_back_rounded, + tooltip: 'Back', + onPressed: onBack!, + theme: theme, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + document.title?.trim().isNotEmpty == true + ? document.title!.trim() + : 'Reader', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: theme.onChromeColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ReaderIconButton( + key: const ValueKey('reader-toc-button'), + focusNode: tocFocusNode, + icon: Icons.format_list_bulleted_rounded, + tooltip: 'Table of contents', + onPressed: onTableOfContents, + theme: theme, + ), + ReaderIconButton( + key: const ValueKey('reader-settings-button'), + focusNode: settingsFocusNode, + icon: Icons.text_fields_rounded, + tooltip: 'Reading settings', + onPressed: onSettings, + theme: theme, + ), + // The controls toggle stays mounted in the shell overlay during + // reflow, preserving browser accessibility and keyboard focus. + SizedBox(width: theme.minimumTargetSize), + ], + ), + ), + ), + ); + } +} + +final class ReaderIconButton extends StatelessWidget { + const ReaderIconButton({ + required this.icon, + required this.tooltip, + required this.onPressed, + required this.theme, + this.focusNode, + super.key, + }); + + final IconData icon; + final String? tooltip; + final VoidCallback? onPressed; + final ReaderThemeData theme; + final FocusNode? focusNode; + + @override + Widget build(BuildContext context) { + return IconButton( + focusNode: focusNode, + constraints: BoxConstraints.tightFor( + width: theme.minimumTargetSize, + height: theme.minimumTargetSize, + ), + tooltip: tooltip, + onPressed: onPressed, + icon: Icon(icon), + ); + } +} + +final class ReaderProgressControls extends StatelessWidget { + const ReaderProgressControls({ + super.key, + required this.progress, + required this.onProgressChanged, + required this.onProgressChangeEnd, + required this.onPrevious, + required this.onNext, + required this.enabled, + required this.theme, + this.locationLabel, + }); + + final double progress; + final ValueChanged onProgressChanged; + final ValueChanged onProgressChangeEnd; + final VoidCallback onPrevious; + final VoidCallback onNext; + final bool enabled; + final ReaderThemeData theme; + final String? locationLabel; + + @override + Widget build(BuildContext context) { + final normalized = progress.clamp(0.0, 1.0); + final percent = (normalized * 100).round(); + + return ColoredBox( + color: theme.chromeColor, + child: IconTheme( + data: IconThemeData(color: theme.onChromeColor), + child: DefaultTextStyle.merge( + style: TextStyle(color: theme.onChromeColor), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (locationLabel != null) + Text( + locationLabel!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: theme.onChromeColor, + ), + ), + Row( + children: [ + ReaderIconButton( + icon: Icons.chevron_left_rounded, + tooltip: 'Previous', + onPressed: enabled ? onPrevious : null, + theme: theme, + ), + Expanded( + child: Semantics( + label: 'Reading progress', + value: '$percent percent', + slider: true, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: theme.progressColor, + thumbColor: theme.handleColor, + ), + child: Slider( + value: normalized, + onChanged: enabled ? onProgressChanged : null, + onChangeEnd: enabled ? onProgressChangeEnd : null, + ), + ), + ), + ), + SizedBox( + width: 64, + child: Text( + '$percent%', + key: const ValueKey('reader-progress-label'), + textAlign: TextAlign.center, + maxLines: 1, + semanticsLabel: '$percent percent read', + ), + ), + ReaderIconButton( + icon: Icons.chevron_right_rounded, + tooltip: 'Next', + onPressed: enabled ? onNext : null, + theme: theme, + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/presentation/reader_panel_header.dart b/lib/src/presentation/reader_panel_header.dart new file mode 100644 index 0000000..7962e4d --- /dev/null +++ b/lib/src/presentation/reader_panel_header.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import 'reader_theme_data.dart'; +import 'reader_controls.dart'; + +final class ReaderPanelHeader extends StatelessWidget { + const ReaderPanelHeader({ + super.key, + required this.title, + required this.close, + required this.theme, + }); + + final String title; + final VoidCallback close; + final ReaderThemeData theme; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: theme.dividerColor)), + ), + child: IconTheme( + data: IconThemeData(color: theme.onChromeColor), + child: DefaultTextStyle.merge( + style: TextStyle(color: theme.onChromeColor), + child: Padding( + padding: const EdgeInsetsDirectional.only(start: 16, end: 4), + child: Row( + children: [ + Expanded( + child: Semantics( + header: true, + child: Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: theme.onChromeColor, + ), + ), + ), + ), + ReaderIconButton( + icon: Icons.close_rounded, + tooltip: 'Close panel', + onPressed: close, + theme: theme, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/presentation/reader_settings_panel.dart b/lib/src/presentation/reader_settings_panel.dart new file mode 100644 index 0000000..e8531d6 --- /dev/null +++ b/lib/src/presentation/reader_settings_panel.dart @@ -0,0 +1,393 @@ +import 'package:flutter/material.dart'; + +import '../domain/reader_capabilities.dart'; +import '../domain/reader_preferences.dart'; +import '../domain/reader_snapshot.dart'; +import '../domain/reader_types.dart'; +import 'reader_panel_header.dart'; +import 'reader_theme_data.dart'; + +final class ReaderSettingsPanel extends StatelessWidget { + const ReaderSettingsPanel({ + super.key, + required this.snapshot, + required this.close, + required this.theme, + required this.enabled, + required this.isBusy, + required this.commandRevision, + required this.onUpdatePreferences, + }); + + final ReaderSnapshot snapshot; + final VoidCallback close; + final ReaderThemeData theme; + final bool enabled; + final bool isBusy; + final int commandRevision; + final ValueChanged onUpdatePreferences; + + @override + Widget build(BuildContext context) { + final capabilities = snapshot.capabilities ?? const ReaderCapabilities(); + final preferences = snapshot.preferences; + + return Column( + children: [ + ReaderPanelHeader( + title: 'Reading settings', + close: close, + theme: theme, + ), + Expanded( + child: ListView( + padding: EdgeInsets.all(theme.panelPadding), + children: [ + if (capabilities.supportsTextCustomization) ...[ + DropdownButtonFormField( + key: ValueKey(('reader-font', preferences.fontFamily)), + initialValue: + const [ + null, + 'serif', + 'sans-serif', + 'monospace', + ].contains(preferences.fontFamily) + ? preferences.fontFamily ?? 'system' + : 'system', + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Typeface', + border: OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem(value: 'system', child: Text('System')), + DropdownMenuItem(value: 'serif', child: Text('Serif')), + DropdownMenuItem( + value: 'sans-serif', + child: Text('Sans serif'), + ), + DropdownMenuItem( + value: 'monospace', + child: Text('Monospace'), + ), + ], + onChanged: enabled + ? (value) => _update( + preferences.copyWith( + fontFamily: value == 'system' ? null : value, + ), + ) + : null, + ), + const SizedBox(height: 20), + _SettingSlider( + title: 'Font size', + value: preferences.fontSize.clamp(8, 72), + min: 8, + max: 72, + divisions: 64, + valueLabel: preferences.fontSize.round().toString(), + enabled: enabled, + isBusy: isBusy, + commandRevision: commandRevision, + onChangeEnd: (value) => + _update(preferences.copyWith(fontSize: value)), + ), + _SettingSlider( + title: 'Line height', + value: preferences.lineHeight.clamp(1, 2.5), + min: 1, + max: 2.5, + divisions: 15, + valueLabel: preferences.lineHeight.toStringAsFixed(1), + enabled: enabled, + isBusy: isBusy, + commandRevision: commandRevision, + onChangeEnd: (value) => + _update(preferences.copyWith(lineHeight: value)), + ), + _SettingSlider( + title: 'Margins', + value: preferences.pageMargins.left.clamp(0, 64), + min: 0, + max: 64, + divisions: 16, + valueLabel: preferences.pageMargins.left.round().toString(), + enabled: enabled, + isBusy: isBusy, + commandRevision: commandRevision, + onChangeEnd: (value) => _update( + preferences.copyWith(pageMargins: EdgeInsets.all(value)), + ), + ), + const SizedBox(height: 12), + ], + if (capabilities.supportsPagination || + capabilities.supportsScrolling) ...[ + DropdownButtonFormField( + key: ValueKey(('reader-mode', preferences.layoutMode)), + initialValue: preferences.layoutMode, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Reading mode', + border: OutlineInputBorder(), + ), + items: [ + if (capabilities.supportsPagination) + const DropdownMenuItem( + value: ReaderLayoutMode.paginated, + child: Text('Paginated'), + ), + if (capabilities.supportsScrolling) + const DropdownMenuItem( + value: ReaderLayoutMode.scroll, + child: Text('Continuous scroll'), + ), + ], + onChanged: enabled + ? (value) { + if (value != null) { + _update(preferences.copyWith(layoutMode: value)); + } + } + : null, + ), + ], + if (capabilities.supportsColumnMode) ...[ + const SizedBox(height: 12), + DropdownButtonFormField( + key: ValueKey(('reader-columns', preferences.columnMode)), + initialValue: preferences.columnMode, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Columns', + border: OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem( + value: ReaderColumnMode.automatic, + child: Text('Automatic'), + ), + DropdownMenuItem( + value: ReaderColumnMode.single, + child: Text('Single'), + ), + DropdownMenuItem( + value: ReaderColumnMode.double, + child: Text('Double'), + ), + ], + onChanged: enabled + ? (value) { + if (value != null) { + _update(preferences.copyWith(columnMode: value)); + } + } + : null, + ), + ], + const SizedBox(height: 20), + Text( + 'Page appearance', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _AppearancePreset( + label: 'Light', + selected: + preferences.brightness == Brightness.light && + preferences.backgroundColor == const Color(0xffffffff), + background: const Color(0xffffffff), + foreground: const Color(0xff1b1b1b), + brightness: Brightness.light, + enabled: enabled, + onSelected: _applyAppearance, + ), + _AppearancePreset( + label: 'Sepia', + selected: + preferences.backgroundColor == const Color(0xfff4ecd8), + background: const Color(0xfff4ecd8), + foreground: const Color(0xff3d3527), + brightness: Brightness.light, + enabled: enabled, + onSelected: _applyAppearance, + ), + _AppearancePreset( + label: 'Night', + selected: preferences.brightness == Brightness.dark, + background: const Color(0xff151719), + foreground: const Color(0xffece7de), + brightness: Brightness.dark, + enabled: enabled, + onSelected: _applyAppearance, + ), + ], + ), + ], + ), + ), + ], + ); + } + + void _update(ReaderPreferences preferences) { + onUpdatePreferences(preferences); + } + + void _applyAppearance( + Color background, + Color foreground, + Brightness brightness, + ) { + _update( + snapshot.preferences.copyWith( + backgroundColor: background, + foregroundColor: foreground, + brightness: brightness, + ), + ); + } +} + +final class _SettingSlider extends StatefulWidget { + const _SettingSlider({ + required this.title, + required this.value, + required this.min, + required this.max, + required this.divisions, + required this.valueLabel, + required this.enabled, + required this.isBusy, + required this.commandRevision, + required this.onChangeEnd, + }); + + final String title; + final double value; + final double min; + final double max; + final int divisions; + final String valueLabel; + final bool enabled; + final bool isBusy; + final int commandRevision; + final ValueChanged onChangeEnd; + + @override + State<_SettingSlider> createState() => _SettingSliderState(); +} + +final class _SettingSliderState extends State<_SettingSlider> { + late double _preview = widget.value; + bool _interacting = false; + + @override + void didUpdateWidget(_SettingSlider oldWidget) { + super.didUpdateWidget(oldWidget); + final commandSettled = + oldWidget.commandRevision != widget.commandRevision && !widget.isBusy; + if (!_interacting && (oldWidget.value != widget.value || commandSettled)) { + _preview = widget.value; + } + } + + String get _previewLabel { + if (_preview == widget.value) { + return widget.valueLabel; + } + + return widget.max <= 3 + ? _preview.toStringAsFixed(1) + : _preview.round().toString(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded(child: Text(widget.title)), + Text(_previewLabel), + ], + ), + Slider( + value: _preview, + min: widget.min, + max: widget.max, + divisions: widget.divisions, + label: _previewLabel, + onChangeStart: widget.enabled + ? (_) => setState(() => _interacting = true) + : null, + onChanged: widget.enabled + ? (value) => setState(() => _preview = value) + : null, + onChangeEnd: widget.enabled + ? (value) { + setState(() { + _preview = value; + _interacting = false; + }); + widget.onChangeEnd(value); + } + : null, + ), + ], + ); + } +} + +typedef _AppearanceChanged = + void Function(Color background, Color foreground, Brightness brightness); + +final class _AppearancePreset extends StatelessWidget { + const _AppearancePreset({ + required this.label, + required this.selected, + required this.background, + required this.foreground, + required this.brightness, + required this.enabled, + required this.onSelected, + }); + + final String label; + final bool selected; + final Color background; + final Color foreground; + final Brightness brightness; + final bool enabled; + final _AppearanceChanged onSelected; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return ChoiceChip( + label: Text(label), + labelStyle: TextStyle( + color: selected ? colors.onSecondaryContainer : colors.onSurface, + ), + color: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? colors.secondaryContainer + : colors.surfaceContainerLow, + ), + checkmarkColor: colors.onSecondaryContainer, + side: BorderSide(color: colors.outlineVariant), + selected: selected, + avatar: CircleAvatar(backgroundColor: background), + onSelected: enabled + ? (_) => onSelected(background, foreground, brightness) + : null, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 190594e..87028ed 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,6 @@ name: papyrus_reader description: "Extensible cross-platform EPUB and PDF reader for Papyrus." version: 0.0.1 publish_to: none -homepage: environment: sdk: ">=3.9.2 <4.0.0" @@ -25,42 +24,7 @@ dev_dependencies: sdk: flutter flutter_lints: ^6.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: assets: - assets/epub_worker.js - assets/epub_worker.NOTICES - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/to/asset-from-package - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/to/font-from-package