diff --git a/.woodpecker/pr.yml b/.woodpecker/pr.yml index a163b09..a673b70 100644 --- a/.woodpecker/pr.yml +++ b/.woodpecker/pr.yml @@ -6,25 +6,7 @@ when: event: pull_request steps: - # Step 1: Install dependencies and generate code - setup: - image: ghcr.io/cirruslabs/flutter:stable - commands: - - flutter pub get - - flutter pub run build_runner build --delete-conflicting-outputs - - # Step 2: Lint the project lint: - image: ghcr.io/cirruslabs/flutter:stable + image: debian:bookworm-slim commands: - - dart format --set-exit-if-changed . - depends_on: - - setup - - # Step 3: Static analysis - analyze: - image: ghcr.io/cirruslabs/flutter:stable - commands: - - flutter analyze - depends_on: - - setup + - bash .woodpecker/scripts/pr-lint.sh diff --git a/.woodpecker/scripts/pr-lint.sh b/.woodpecker/scripts/pr-lint.sh new file mode 100755 index 0000000..232045c --- /dev/null +++ b/.woodpecker/scripts/pr-lint.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -eux + +apt-get update -qq && apt-get install -y --no-install-recommends bash curl git unzip xz-utils ca-certificates > /dev/null +git config --global --add safe.directory '*' + +curl -fsSL -o /tmp/flutter.tar.xz https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.41.0-stable.tar.xz +tar xf /tmp/flutter.tar.xz -C /opt && rm /tmp/flutter.tar.xz +export PATH="/opt/flutter/bin:/opt/flutter/bin/cache/dart-sdk/bin:$PATH" + +flutter pub get +dart format --set-exit-if-changed . +flutter pub run build_runner build --delete-conflicting-outputs +flutter analyze diff --git a/lib/helpers/eink_detection_helper.dart b/lib/helpers/eink_detection_helper.dart new file mode 100644 index 0000000..2b7b988 --- /dev/null +++ b/lib/helpers/eink_detection_helper.dart @@ -0,0 +1,42 @@ +import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; + +/// Detects whether the current device is a known e-ink device. +class EinkDetectionHelper { + static const _einkManufacturers = { + 'onyx', // BOOX + 'boyue', // Likebook + 'amazon', // Kindle + 'kobo', + 'rakuten', // Kobo + 'pocketbook', + 'remarkable', + 'tolino', + 'bigme', + 'dasung', + }; + + /// Hisense e-ink phone model prefixes (e.g. A5, A7, A9). + static const _hisenseEinkModels = {'a5', 'a7', 'a9'}; + + /// Returns `true` when the device is a known e-ink device. + /// On non-Android platforms this always returns `false`. + static Future isEinkDevice() async { + if (!Platform.isAndroid) return false; + + final info = await DeviceInfoPlugin().androidInfo; + final manufacturer = info.manufacturer.toLowerCase(); + final model = info.model.toLowerCase(); + + if (_einkManufacturers.contains(manufacturer)) return true; + + // Hisense makes both LCD and e-ink phones — match specific model lines. + if (manufacturer == 'hisense') { + for (final prefix in _hisenseEinkModels) { + if (model.contains(prefix)) return true; + } + } + + return false; + } +} diff --git a/lib/models/opds_feed.dart b/lib/models/opds_feed.dart index 19e7a73..dc2377d 100644 --- a/lib/models/opds_feed.dart +++ b/lib/models/opds_feed.dart @@ -1,5 +1,4 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:worldhopper/models/enhanced_metadata.dart'; import 'package:worldhopper/models/opds_entry.dart'; import 'package:worldhopper/models/opds_link.dart'; diff --git a/lib/providers/eink_mode_provider.dart b/lib/providers/eink_mode_provider.dart new file mode 100644 index 0000000..37dda63 --- /dev/null +++ b/lib/providers/eink_mode_provider.dart @@ -0,0 +1,44 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:worldhopper/helpers/eink_detection_helper.dart'; + +part 'eink_mode_provider.g.dart'; + +/// Key for storing e-ink mode preference in SharedPreferences +const String _einkModeKey = 'eink_mode'; + +/// Provider for e-ink mode state management. +/// +/// On first launch (no stored preference), auto-detects whether the device is +/// a known e-ink device and persists the result. Subsequent launches use the +/// stored value so the user's explicit choice is always respected. +@riverpod +class EinkModeNotifier extends _$EinkModeNotifier { + @override + bool build() { + _loadEinkMode(); + return false; // Default value until loaded + } + + /// Load e-ink mode from SharedPreferences, auto-detecting on first launch. + Future _loadEinkMode() async { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getBool(_einkModeKey); + + if (stored != null) { + state = stored; + } else { + // First launch — auto-detect + final detected = await EinkDetectionHelper.isEinkDevice(); + state = detected; + await prefs.setBool(_einkModeKey, detected); + } + } + + /// Set e-ink mode and persist to SharedPreferences. + Future setEinkMode(bool enabled) async { + state = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_einkModeKey, enabled); + } +} diff --git a/lib/providers/eink_mode_provider.g.dart b/lib/providers/eink_mode_provider.g.dart new file mode 100644 index 0000000..cec308e --- /dev/null +++ b/lib/providers/eink_mode_provider.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'eink_mode_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$einkModeNotifierHash() => r'4a5aa5c980270db066864fe75ebdd2c015725cd6'; + +/// Provider for e-ink mode state management. +/// +/// On first launch (no stored preference), auto-detects whether the device is +/// a known e-ink device and persists the result. Subsequent launches use the +/// stored value so the user's explicit choice is always respected. +/// +/// Copied from [EinkModeNotifier]. +@ProviderFor(EinkModeNotifier) +final einkModeNotifierProvider = + AutoDisposeNotifierProvider.internal( + EinkModeNotifier.new, + name: r'einkModeNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$einkModeNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$EinkModeNotifier = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/screens/browse/feed_screen.dart b/lib/screens/browse/feed_screen.dart index d77fa73..be09cf1 100644 --- a/lib/screens/browse/feed_screen.dart +++ b/lib/screens/browse/feed_screen.dart @@ -285,7 +285,7 @@ class _FeedScreenState extends ConsumerState { color: Theme.of(context) .colorScheme .secondary - .withOpacity(0.5), + .withValues(alpha: 0.5), ), const SizedBox(height: 24), Text( @@ -299,7 +299,7 @@ class _FeedScreenState extends ConsumerState { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.7), + .withValues(alpha: 0.7), ), ), ], diff --git a/lib/screens/browse/library_browser_screen.dart b/lib/screens/browse/library_browser_screen.dart index 53c8446..6a295ca 100644 --- a/lib/screens/browse/library_browser_screen.dart +++ b/lib/screens/browse/library_browser_screen.dart @@ -278,7 +278,7 @@ class _LibraryBrowserScreenState extends ConsumerState { color: Theme.of(context) .colorScheme .secondary - .withOpacity(0.5), + .withValues(alpha: 0.5), ), const SizedBox(height: 24), Text( @@ -292,7 +292,7 @@ class _LibraryBrowserScreenState extends ConsumerState { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.7), + .withValues(alpha: 0.7), ), ), ], diff --git a/lib/screens/library/library_screen.dart b/lib/screens/library/library_screen.dart index d3be167..42f43d4 100644 --- a/lib/screens/library/library_screen.dart +++ b/lib/screens/library/library_screen.dart @@ -122,7 +122,7 @@ class LibraryScreen extends ConsumerWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.6), + .withValues(alpha: 0.6), ), textAlign: TextAlign.center, ), @@ -163,7 +163,7 @@ class LibraryScreen extends ConsumerWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.6), + .withValues(alpha: 0.6), ), textAlign: TextAlign.center, ), diff --git a/lib/screens/publication/publication_detail_screen.dart b/lib/screens/publication/publication_detail_screen.dart index 8d1d9a8..47c335c 100644 --- a/lib/screens/publication/publication_detail_screen.dart +++ b/lib/screens/publication/publication_detail_screen.dart @@ -68,7 +68,8 @@ class PublicationDetailScreen extends ConsumerWidget { false; return AppBar( - backgroundColor: Theme.of(context).colorScheme.surface.withOpacity(0.75), + backgroundColor: + Theme.of(context).colorScheme.surface.withValues(alpha: 0.75), title: Text(entry.title), actions: isOffline ? [ @@ -94,7 +95,7 @@ class PublicationDetailScreen extends ConsumerWidget { child: Icon( Icons.book, size: 100, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.3), + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), ), ); } @@ -467,8 +468,4 @@ class PublicationDetailScreen extends ConsumerWidget { return _formatDate(dateTime); } } - - String _stripHtml(String html) { - return html.replaceAll(RegExp(r'<[^>]*>'), '').trim(); - } } diff --git a/lib/screens/reader/epub_reader_screen.dart b/lib/screens/reader/epub_reader_screen.dart index 4d31e7d..92b00c9 100644 --- a/lib/screens/reader/epub_reader_screen.dart +++ b/lib/screens/reader/epub_reader_screen.dart @@ -6,8 +6,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:flutter_epub_viewer/flutter_epub_viewer.dart'; import 'package:go_router/go_router.dart'; -import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/helpers/snackbar_helper.dart'; +import 'package:worldhopper/providers/eink_mode_provider.dart'; import 'package:worldhopper/models/enhanced_metadata.dart'; import 'package:worldhopper/models/opds_entry.dart'; import 'package:worldhopper/models/reading_progress.dart'; @@ -355,7 +355,7 @@ class _EpubReaderScreenState extends ConsumerState { child: Text( '${(_loadingProgress * 100).toInt()}%', style: TextStyle( - color: Colors.white.withOpacity(0.7), + color: Colors.white.withValues(alpha: 0.7), fontSize: 14, ), ), @@ -402,7 +402,7 @@ class _EpubReaderScreenState extends ConsumerState { return PopScope( canPop: true, - onPopInvoked: (bool didPop) { + onPopInvokedWithResult: (bool didPop, Object? result) { if (didPop) { if (_reachedEndThisSession) { _deleteCompletedProgress(); @@ -491,6 +491,7 @@ class _EpubReaderScreenState extends ConsumerState { feedUrl: widget.feedUrl, entry: widget.entry, isVisible: _reachedEndThisSession, + einkMode: ref.watch(einkModeNotifierProvider), onBeforeNavigate: _deleteCompletedProgress, ), ], @@ -619,7 +620,7 @@ class _EpubReaderScreenState extends ConsumerState { /// Helper method to convert Flutter Color to CSS hex string String _colorToHex(Color color) { - return '#${color.value.toRadixString(16).substring(2).toUpperCase()}'; + return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}'; } /// Toggle app bar visibility @@ -671,7 +672,9 @@ class _EpubReaderScreenState extends ConsumerState { right: 0, child: AnimatedOpacity( opacity: _showAppBar ? 1.0 : 0.0, - duration: const Duration(milliseconds: 200), + duration: ref.watch(einkModeNotifierProvider) + ? Duration.zero + : const Duration(milliseconds: 200), child: Container( padding: EdgeInsets.only( top: topPadding, @@ -683,8 +686,8 @@ class _EpubReaderScreenState extends ConsumerState { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Colors.black.withOpacity(0.7), - Colors.black.withOpacity(0.0), + Colors.black.withValues(alpha: 0.7), + Colors.black.withValues(alpha: 0.0), ], stops: const [0.0, 1.0], ), diff --git a/lib/screens/reader/reader_screen.dart b/lib/screens/reader/reader_screen.dart index fb24ef1..b70f743 100644 --- a/lib/screens/reader/reader_screen.dart +++ b/lib/screens/reader/reader_screen.dart @@ -18,6 +18,7 @@ import 'package:worldhopper/providers/connectivity_provider.dart'; import 'package:worldhopper/providers/filter_quality_provider.dart'; import 'package:worldhopper/providers/precache_pages_provider.dart'; import 'package:worldhopper/providers/reading_mode_provider.dart'; +import 'package:worldhopper/providers/eink_mode_provider.dart'; import 'package:worldhopper/providers/two_page_mode_provider.dart'; import 'package:worldhopper/screens/reader/page_grouping.dart'; import 'package:worldhopper/screens/reader/two_page_reader.dart'; @@ -58,6 +59,9 @@ class _ReaderScreenState extends ConsumerState { List _spreads = []; bool _wasTwoPage = false; + // E-ink swipe tracking + double? _einkSwipeStartX; + @override void initState() { super.initState(); @@ -123,7 +127,7 @@ class _ReaderScreenState extends ConsumerState { return PopScope( canPop: true, - onPopInvoked: (bool didPop) { + onPopInvokedWithResult: (bool didPop, Object? result) { if (didPop) { if (_reachedEndThisSession) { _deleteCompletedProgress(); @@ -255,6 +259,7 @@ class _ReaderScreenState extends ConsumerState { feedUrl: widget.feedUrl, entry: widget.entry, isVisible: _reachedEndThisSession, + einkMode: ref.watch(einkModeNotifierProvider), onBeforeNavigate: _deleteCompletedProgress, ), ], @@ -266,15 +271,18 @@ class _ReaderScreenState extends ConsumerState { Widget _buildPagedReader(OPDSStreamLink streamLink, dynamic server, {required bool reverse}) { final filterQuality = ref.watch(filterQualityNotifierProvider); + final einkMode = ref.watch(einkModeNotifierProvider); - return GestureDetector( + Widget gallery = GestureDetector( onTap: () { setState(() { _showControls = !_showControls; }); }, child: PhotoViewGallery.builder( - scrollPhysics: const BouncingScrollPhysics(), + scrollPhysics: einkMode + ? const NeverScrollableScrollPhysics() + : const BouncingScrollPhysics(), reverse: reverse, builder: (context, index) { return PhotoViewGalleryPageOptions( @@ -315,6 +323,17 @@ class _ReaderScreenState extends ConsumerState { }, ), ); + + if (einkMode) { + gallery = _wrapWithEinkSwipe( + child: gallery, + controller: _pageController, + itemCount: streamLink.pageCount, + reverse: reverse, + ); + } + + return gallery; } Widget _buildTwoPageReader(OPDSStreamLink streamLink, dynamic server, @@ -342,13 +361,16 @@ class _ReaderScreenState extends ConsumerState { final authHeaders = AuthService().getAuthHeaders(server); - return TwoPageReader( + final einkMode = ref.watch(einkModeNotifierProvider); + + Widget reader = TwoPageReader( spreads: _spreads, pageController: _twoPageController!, getPageUrl: (index) => streamLink.getPageUrl(index), headers: authHeaders, filterQuality: filterQuality, reverse: rtl, + einkMode: einkMode, onTap: () { setState(() { _showControls = !_showControls; @@ -368,6 +390,17 @@ class _ReaderScreenState extends ConsumerState { _precacheUpcomingPages(lastPage); }, ); + + if (einkMode) { + reader = _wrapWithEinkSwipe( + child: reader, + controller: _twoPageController!, + itemCount: _spreads.length, + reverse: rtl, + ); + } + + return reader; } Widget _buildVerticalReader(OPDSStreamLink streamLink, dynamic server) { @@ -438,6 +471,36 @@ class _ReaderScreenState extends ConsumerState { ); } + /// Wraps [child] with a [Listener] that detects horizontal swipes and calls + /// [jumpToPage] on [controller], giving instant page turns with no animation. + Widget _wrapWithEinkSwipe({ + required Widget child, + required PageController controller, + required int itemCount, + required bool reverse, + }) { + return Listener( + onPointerDown: (e) => _einkSwipeStartX = e.position.dx, + onPointerUp: (e) { + if (_einkSwipeStartX == null) return; + final dx = e.position.dx - _einkSwipeStartX!; + _einkSwipeStartX = null; + if (dx.abs() < 50) return; // below swipe threshold + + // Swipe left (dx < 0) → next page, swipe right (dx > 0) → previous + final direction = dx < 0 ? 1 : -1; + final effectiveDirection = reverse ? -direction : direction; + final currentIdx = controller.page?.round() ?? 0; + final target = currentIdx + effectiveDirection; + if (target >= 0 && target < itemCount) { + controller.jumpToPage(target); + } + }, + onPointerCancel: (_) => _einkSwipeStartX = null, + child: child, + ); + } + Widget _buildTopBar(BuildContext context, OPDSStreamLink streamLink) { return Container( decoration: BoxDecoration( @@ -445,7 +508,7 @@ class _ReaderScreenState extends ConsumerState { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Colors.black.withOpacity(0.7), + Colors.black.withValues(alpha: 0.7), Colors.transparent, ], ), @@ -492,7 +555,7 @@ class _ReaderScreenState extends ConsumerState { Text( widget.entry.authors.join(', '), style: TextStyle( - color: Colors.white.withOpacity(0.7), + color: Colors.white.withValues(alpha: 0.7), fontSize: 12, ), maxLines: 1, @@ -649,6 +712,7 @@ class _ReaderScreenState extends ConsumerState { } Widget _buildBottomBar(BuildContext context, OPDSStreamLink streamLink) { + final einkMode = ref.watch(einkModeNotifierProvider); final progress = (_currentPage + 1) / streamLink.pageCount; final globalMode = ref.watch(readingModeNotifierProvider); final seriesOverride = widget.feedUrl != null @@ -701,7 +765,7 @@ class _ReaderScreenState extends ConsumerState { begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [ - Colors.black.withOpacity(0.7), + Colors.black.withValues(alpha: 0.7), Colors.transparent, ], ), @@ -741,7 +805,7 @@ class _ReaderScreenState extends ConsumerState { // Progress bar LinearProgressIndicator( value: progress, - backgroundColor: Colors.white.withOpacity(0.3), + backgroundColor: Colors.white.withValues(alpha: 0.3), valueColor: const AlwaysStoppedAnimation(Colors.white), minHeight: 4, ), @@ -764,10 +828,12 @@ class _ReaderScreenState extends ConsumerState { icon: const Icon(Icons.chevron_right, color: Colors.white), onPressed: currentIdx > 0 - ? () => activeController.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ) + ? () => einkMode + ? activeController.jumpToPage(currentIdx - 1) + : activeController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ) : null, iconSize: 32, ), @@ -775,10 +841,12 @@ class _ReaderScreenState extends ConsumerState { icon: const Icon(Icons.chevron_left, color: Colors.white), onPressed: currentIdx < totalItems - 1 - ? () => activeController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ) + ? () => einkMode + ? activeController.jumpToPage(currentIdx + 1) + : activeController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ) : null, iconSize: 32, ), @@ -802,10 +870,12 @@ class _ReaderScreenState extends ConsumerState { icon: const Icon(Icons.chevron_left, color: Colors.white), onPressed: currentIdx > 0 - ? () => activeController.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ) + ? () => einkMode + ? activeController.jumpToPage(currentIdx - 1) + : activeController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ) : null, iconSize: 32, ), @@ -813,10 +883,12 @@ class _ReaderScreenState extends ConsumerState { icon: const Icon(Icons.chevron_right, color: Colors.white), onPressed: currentIdx < totalItems - 1 - ? () => activeController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ) + ? () => einkMode + ? activeController.jumpToPage(currentIdx + 1) + : activeController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ) : null, iconSize: 32, ), diff --git a/lib/screens/reader/two_page_reader.dart b/lib/screens/reader/two_page_reader.dart index b37eca2..e6c5c09 100644 --- a/lib/screens/reader/two_page_reader.dart +++ b/lib/screens/reader/two_page_reader.dart @@ -14,6 +14,7 @@ class TwoPageReader extends StatelessWidget { final Map headers; final FilterQuality filterQuality; final bool reverse; + final bool einkMode; final ValueChanged onPageChanged; final VoidCallback onTap; @@ -25,6 +26,7 @@ class TwoPageReader extends StatelessWidget { required this.headers, required this.filterQuality, required this.reverse, + this.einkMode = false, required this.onPageChanged, required this.onTap, }); @@ -38,7 +40,9 @@ class TwoPageReader extends StatelessWidget { reverse: reverse, itemCount: spreads.length, onPageChanged: onPageChanged, - physics: const BouncingScrollPhysics(), + physics: einkMode + ? const NeverScrollableScrollPhysics() + : const BouncingScrollPhysics(), itemBuilder: (context, spreadIndex) { final spread = spreads[spreadIndex]; diff --git a/lib/screens/servers/edit_server_screen.dart b/lib/screens/servers/edit_server_screen.dart index 9d8c8d1..ea87654 100644 --- a/lib/screens/servers/edit_server_screen.dart +++ b/lib/screens/servers/edit_server_screen.dart @@ -251,8 +251,10 @@ class _EditServerScreenState extends ConsumerState { Text( label, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: - Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), ), ), Text( diff --git a/lib/screens/servers/server_list_screen.dart b/lib/screens/servers/server_list_screen.dart index 61606f9..f5c975f 100644 --- a/lib/screens/servers/server_list_screen.dart +++ b/lib/screens/servers/server_list_screen.dart @@ -115,7 +115,8 @@ class ServerListScreen extends ConsumerWidget { Icon( Icons.cloud_off, size: 100, - color: Theme.of(context).colorScheme.secondary.withOpacity(0.5), + color: + Theme.of(context).colorScheme.secondary.withValues(alpha: 0.5), ), const SizedBox(height: 24), Text( @@ -132,7 +133,7 @@ class ServerListScreen extends ConsumerWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.7), + .withValues(alpha: 0.7), ), ), ), diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index f02ab9b..1150f10 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -20,72 +20,56 @@ class AppearanceSettingsScreen extends ConsumerWidget { _buildSectionHeader(context, 'Theme'), Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Column( - children: [ - ListTile( - leading: const Icon(Icons.light_mode), - title: const Text('Light'), - trailing: Radio( - value: ThemeMode.light, - groupValue: themeMode, - onChanged: (value) { - if (value != null) { - ref - .read(themeNotifierProvider.notifier) - .setThemeMode(value); - } + child: RadioGroup( + groupValue: themeMode, + onChanged: (value) { + if (value != null) { + ref.read(themeNotifierProvider.notifier).setThemeMode(value); + } + }, + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.light_mode), + title: const Text('Light'), + trailing: const Radio( + value: ThemeMode.light, + ), + onTap: () { + ref + .read(themeNotifierProvider.notifier) + .setThemeMode(ThemeMode.light); }, ), - onTap: () { - ref - .read(themeNotifierProvider.notifier) - .setThemeMode(ThemeMode.light); - }, - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.dark_mode), - title: const Text('Dark'), - trailing: Radio( - value: ThemeMode.dark, - groupValue: themeMode, - onChanged: (value) { - if (value != null) { - ref - .read(themeNotifierProvider.notifier) - .setThemeMode(value); - } + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.dark_mode), + title: const Text('Dark'), + trailing: const Radio( + value: ThemeMode.dark, + ), + onTap: () { + ref + .read(themeNotifierProvider.notifier) + .setThemeMode(ThemeMode.dark); }, ), - onTap: () { - ref - .read(themeNotifierProvider.notifier) - .setThemeMode(ThemeMode.dark); - }, - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.brightness_auto), - title: const Text('System'), - subtitle: const Text('Follow device theme'), - trailing: Radio( - value: ThemeMode.system, - groupValue: themeMode, - onChanged: (value) { - if (value != null) { - ref - .read(themeNotifierProvider.notifier) - .setThemeMode(value); - } + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.brightness_auto), + title: const Text('System'), + subtitle: const Text('Follow device theme'), + trailing: const Radio( + value: ThemeMode.system, + ), + onTap: () { + ref + .read(themeNotifierProvider.notifier) + .setThemeMode(ThemeMode.system); }, ), - onTap: () { - ref - .read(themeNotifierProvider.notifier) - .setThemeMode(ThemeMode.system); - }, - ), - ], + ], + ), ), ), const SizedBox(height: 16), diff --git a/lib/screens/settings/filter_quality_settings_screen.dart b/lib/screens/settings/filter_quality_settings_screen.dart index 19f3b88..d77d0a5 100644 --- a/lib/screens/settings/filter_quality_settings_screen.dart +++ b/lib/screens/settings/filter_quality_settings_screen.dart @@ -20,34 +20,36 @@ class FilterQualitySettingsScreen extends ConsumerWidget { const SizedBox(height: 8), Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Column( - children: [ - for (final entry in _filterQualityOptions.entries) ...[ - if (entry.key != _filterQualityOptions.keys.first) - const Divider(height: 1), - ListTile( - leading: Icon(entry.value.icon), - title: Text(entry.value.label), - subtitle: Text(entry.value.description), - trailing: Radio( - value: entry.key, - groupValue: filterQuality, - onChanged: (value) { - if (value != null) { - ref - .read(filterQualityNotifierProvider.notifier) - .setFilterQuality(value); - } + child: RadioGroup( + groupValue: filterQuality, + onChanged: (value) { + if (value != null) { + ref + .read(filterQualityNotifierProvider.notifier) + .setFilterQuality(value); + } + }, + child: Column( + children: [ + for (final entry in _filterQualityOptions.entries) ...[ + if (entry.key != _filterQualityOptions.keys.first) + const Divider(height: 1), + ListTile( + leading: Icon(entry.value.icon), + title: Text(entry.value.label), + subtitle: Text(entry.value.description), + trailing: Radio( + value: entry.key, + ), + onTap: () { + ref + .read(filterQualityNotifierProvider.notifier) + .setFilterQuality(entry.key); }, ), - onTap: () { - ref - .read(filterQualityNotifierProvider.notifier) - .setFilterQuality(entry.key); - }, - ), + ], ], - ], + ), ), ), Padding( diff --git a/lib/screens/settings/precache_pages_settings_screen.dart b/lib/screens/settings/precache_pages_settings_screen.dart index 0be2f15..4588a94 100644 --- a/lib/screens/settings/precache_pages_settings_screen.dart +++ b/lib/screens/settings/precache_pages_settings_screen.dart @@ -20,34 +20,36 @@ class PrecachePagesSettingsScreen extends ConsumerWidget { const SizedBox(height: 8), Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Column( - children: [ - for (final entry in _precachePagesOptions.entries) ...[ - if (entry.key != _precachePagesOptions.keys.first) - const Divider(height: 1), - ListTile( - leading: Icon(entry.value.icon), - title: Text(entry.value.label), - subtitle: Text(entry.value.description), - trailing: Radio( - value: entry.key, - groupValue: precachePages, - onChanged: (value) { - if (value != null) { - ref - .read(precachePagesNotifierProvider.notifier) - .setPrecachePages(value); - } + child: RadioGroup( + groupValue: precachePages, + onChanged: (value) { + if (value != null) { + ref + .read(precachePagesNotifierProvider.notifier) + .setPrecachePages(value); + } + }, + child: Column( + children: [ + for (final entry in _precachePagesOptions.entries) ...[ + if (entry.key != _precachePagesOptions.keys.first) + const Divider(height: 1), + ListTile( + leading: Icon(entry.value.icon), + title: Text(entry.value.label), + subtitle: Text(entry.value.description), + trailing: Radio( + value: entry.key, + ), + onTap: () { + ref + .read(precachePagesNotifierProvider.notifier) + .setPrecachePages(entry.key); }, ), - onTap: () { - ref - .read(precachePagesNotifierProvider.notifier) - .setPrecachePages(entry.key); - }, - ), + ], ], - ], + ), ), ), Padding( diff --git a/lib/screens/settings/readers_settings_screen.dart b/lib/screens/settings/readers_settings_screen.dart index 9434dfe..9782a21 100644 --- a/lib/screens/settings/readers_settings_screen.dart +++ b/lib/screens/settings/readers_settings_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:worldhopper/providers/eink_mode_provider.dart'; import 'package:worldhopper/providers/filter_quality_provider.dart'; import 'package:worldhopper/providers/precache_pages_provider.dart'; import 'package:worldhopper/providers/reading_mode_provider.dart'; @@ -38,6 +39,7 @@ class ReadersSettingsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final einkMode = ref.watch(einkModeNotifierProvider); final readingMode = ref.watch(readingModeNotifierProvider); final filterQuality = ref.watch(filterQualityNotifierProvider); final precachePages = ref.watch(precachePagesNotifierProvider); @@ -50,6 +52,25 @@ class ReadersSettingsScreen extends ConsumerWidget { ), body: ListView( children: [ + _buildSectionHeader(context, 'General'), + Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + children: [ + SwitchListTile( + secondary: const Icon(Icons.tablet_android), + title: const Text('E-ink mode'), + subtitle: const Text('Disable animations for e-ink displays'), + value: einkMode, + onChanged: (enabled) { + ref + .read(einkModeNotifierProvider.notifier) + .setEinkMode(enabled); + }, + ), + ], + ), + ), _buildSectionHeader(context, 'Image Reader'), Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), diff --git a/lib/screens/settings/reading_mode_settings_screen.dart b/lib/screens/settings/reading_mode_settings_screen.dart index 2549d5a..534c274 100644 --- a/lib/screens/settings/reading_mode_settings_screen.dart +++ b/lib/screens/settings/reading_mode_settings_screen.dart @@ -20,34 +20,36 @@ class ReadingModeSettingsScreen extends ConsumerWidget { const SizedBox(height: 8), Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Column( - children: [ - for (final entry in _readingModeOptions.entries) ...[ - if (entry.key != _readingModeOptions.keys.first) - const Divider(height: 1), - ListTile( - leading: Icon(entry.value.icon), - title: Text(entry.value.label), - subtitle: Text(entry.value.description), - trailing: Radio( - value: entry.key, - groupValue: readingMode, - onChanged: (value) { - if (value != null) { - ref - .read(readingModeNotifierProvider.notifier) - .setReadingMode(value); - } + child: RadioGroup( + groupValue: readingMode, + onChanged: (value) { + if (value != null) { + ref + .read(readingModeNotifierProvider.notifier) + .setReadingMode(value); + } + }, + child: Column( + children: [ + for (final entry in _readingModeOptions.entries) ...[ + if (entry.key != _readingModeOptions.keys.first) + const Divider(height: 1), + ListTile( + leading: Icon(entry.value.icon), + title: Text(entry.value.label), + subtitle: Text(entry.value.description), + trailing: Radio( + value: entry.key, + ), + onTap: () { + ref + .read(readingModeNotifierProvider.notifier) + .setReadingMode(entry.key); }, ), - onTap: () { - ref - .read(readingModeNotifierProvider.notifier) - .setReadingMode(entry.key); - }, - ), + ], ], - ], + ), ), ), Padding( diff --git a/lib/services/epub_download_service.dart b/lib/services/epub_download_service.dart index cd35a99..ef2ee00 100644 --- a/lib/services/epub_download_service.dart +++ b/lib/services/epub_download_service.dart @@ -30,7 +30,7 @@ class EpubDownloadService { } final url = acquisitionLink.href; - if (url == null || url.isEmpty) { + if (url.isEmpty) { throw Exception('Invalid acquisition link URL'); } @@ -76,7 +76,7 @@ class EpubDownloadService { if (acquisitionLink == null) return null; final url = acquisitionLink.href; - if (url == null || url.isEmpty) return null; + if (url.isEmpty) return null; final fileInfo = await _cacheManager.getFileFromCache(url); if (fileInfo != null && await fileInfo.file.exists()) { @@ -92,7 +92,7 @@ class EpubDownloadService { if (acquisitionLink == null) return; final url = acquisitionLink.href; - if (url == null || url.isEmpty) return; + if (url.isEmpty) return; await _cacheManager.removeFile(url); } @@ -103,7 +103,7 @@ class EpubDownloadService { if (acquisitionLink == null) return false; final url = acquisitionLink.href; - if (url == null || url.isEmpty) return false; + if (url.isEmpty) return false; final fileInfo = await _cacheManager.getFileFromCache(url); return fileInfo != null && await fileInfo.file.exists(); @@ -118,7 +118,7 @@ class EpubDownloadService { } final url = acquisitionLink.href; - if (url == null || url.isEmpty) { + if (url.isEmpty) { throw Exception('Invalid acquisition link URL'); } diff --git a/lib/services/image_cache_service.dart b/lib/services/image_cache_service.dart index b04b2b8..1012ff8 100644 --- a/lib/services/image_cache_service.dart +++ b/lib/services/image_cache_service.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; /// Service for caching publication cover images locally @@ -36,7 +37,7 @@ class ImageCacheService { return file.path; } catch (e) { - print('Error caching cover: $e'); + debugPrint('Error caching cover: $e'); return null; } } @@ -59,7 +60,7 @@ class ImageCacheService { return file.path; } catch (e) { - print('Error caching thumbnail: $e'); + debugPrint('Error caching thumbnail: $e'); return null; } } diff --git a/lib/widgets/next_in_series_overlay.dart b/lib/widgets/next_in_series_overlay.dart index 4abb94d..8348105 100644 --- a/lib/widgets/next_in_series_overlay.dart +++ b/lib/widgets/next_in_series_overlay.dart @@ -19,6 +19,7 @@ class NextInSeriesOverlay extends ConsumerStatefulWidget { final String? feedUrl; final OPDSEntry entry; final bool isVisible; + final bool einkMode; final Future Function()? onBeforeNavigate; const NextInSeriesOverlay({ @@ -27,6 +28,7 @@ class NextInSeriesOverlay extends ConsumerStatefulWidget { this.feedUrl, required this.entry, required this.isVisible, + this.einkMode = false, this.onBeforeNavigate, }); @@ -124,82 +126,84 @@ class _NextInSeriesOverlayState extends ConsumerState { required String title, required IconData trailingIcon, }) { + final barContent = Material( + elevation: 8, + borderRadius: BorderRadius.circular(12), + color: color, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: Padding( + padding: + const EdgeInsets.only(left: 16, top: 12, bottom: 12, right: 4), + child: Row( + children: [ + Icon(icon, color: onColor), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: onColor.withValues(alpha: 0.7), + ), + ), + Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: onColor, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 4), + Icon(trailingIcon, color: onColor), + SizedBox( + width: 36, + height: 36, + child: IconButton( + padding: EdgeInsets.zero, + iconSize: 20, + icon: Icon( + Icons.close, + color: onColor.withValues(alpha: 0.7), + ), + onPressed: () { + setState(() { + _dismissed = true; + }); + }, + ), + ), + ], + ), + ), + ), + ); + return Positioned( bottom: MediaQuery.of(context).padding.bottom + 16, left: 16, right: 16, - child: Dismissible( - key: ValueKey(key), - direction: DismissDirection.down, - onDismissed: (_) { - setState(() { - _dismissed = true; - }); - }, - child: Material( - elevation: 8, - borderRadius: BorderRadius.circular(12), - color: color, - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.only( - left: 16, top: 12, bottom: 12, right: 4), - child: Row( - children: [ - Icon(icon, color: onColor), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: - Theme.of(context).textTheme.labelSmall?.copyWith( - color: onColor.withOpacity(0.7), - ), - ), - Text( - title, - style: - Theme.of(context).textTheme.titleSmall?.copyWith( - color: onColor, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - const SizedBox(width: 4), - Icon(trailingIcon, color: onColor), - SizedBox( - width: 36, - height: 36, - child: IconButton( - padding: EdgeInsets.zero, - iconSize: 20, - icon: Icon( - Icons.close, - color: onColor.withOpacity(0.7), - ), - onPressed: () { - setState(() { - _dismissed = true; - }); - }, - ), - ), - ], - ), + child: widget.einkMode + ? barContent + : Dismissible( + key: ValueKey(key), + direction: DismissDirection.down, + onDismissed: (_) { + setState(() { + _dismissed = true; + }); + }, + child: barContent, ), - ), - ), - ), ); } diff --git a/lib/widgets/publication_card.dart b/lib/widgets/publication_card.dart index 27312a6..6e69997 100644 --- a/lib/widgets/publication_card.dart +++ b/lib/widgets/publication_card.dart @@ -51,7 +51,7 @@ class PublicationCard extends StatelessWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.6), + .withValues(alpha: 0.6), ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -132,7 +132,7 @@ class PublicationCard extends StatelessWidget { child: Icon( entry.isNavigation ? Icons.folder : Icons.book, size: 48, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.3), + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), ), ), ); diff --git a/lib/widgets/recently_read_card.dart b/lib/widgets/recently_read_card.dart index bfe5db5..82ab637 100644 --- a/lib/widgets/recently_read_card.dart +++ b/lib/widgets/recently_read_card.dart @@ -126,7 +126,7 @@ class RecentlyReadCard extends ConsumerWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.6), + .withValues(alpha: 0.6), ), ), ], @@ -183,7 +183,7 @@ class RecentlyReadCard extends ConsumerWidget { shape: BoxShape.circle, boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.3), + color: Colors.black.withValues(alpha: 0.3), blurRadius: 4, offset: const Offset(0, 2), ), diff --git a/lib/widgets/server_card.dart b/lib/widgets/server_card.dart index abe2733..de284db 100644 --- a/lib/widgets/server_card.dart +++ b/lib/widgets/server_card.dart @@ -65,7 +65,7 @@ class ServerCard extends StatelessWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.6), + .withValues(alpha: 0.6), ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -123,7 +123,7 @@ class ServerCard extends StatelessWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.6), + .withValues(alpha: 0.6), ), const SizedBox(width: 4), Text( @@ -132,7 +132,7 @@ class ServerCard extends StatelessWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.6), + .withValues(alpha: 0.6), ), ), ], @@ -145,7 +145,7 @@ class ServerCard extends StatelessWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.6), + .withValues(alpha: 0.6), ), const SizedBox(width: 4), Text( @@ -154,7 +154,7 @@ class ServerCard extends StatelessWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.6), + .withValues(alpha: 0.6), ), ), ], diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 43d4f72..3d853b3 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import connectivity_plus +import device_info_plus import flutter_inappwebview_macos import package_info_plus import shared_preferences_foundation @@ -15,6 +16,7 @@ import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 94e49e7..cdf3b56 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -149,10 +149,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -265,6 +265,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a" + url: "https://pub.dev" + source: hosted + version: "11.5.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" dio: dependency: "direct main" description: @@ -588,18 +604,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -1033,10 +1049,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.8" timing: dependency: transitive description: @@ -1197,6 +1213,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5a06e93..286365d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -82,6 +82,7 @@ dependencies: wakelock_plus: ^1.2.8 package_info_plus: ^9.0.0 url_launcher: ^6.3.2 + device_info_plus: ^11.0.0 dev_dependencies: flutter_test: