diff --git a/app/lib/application/controllers/browser_controller.dart b/app/lib/application/controllers/browser_controller.dart index 9158c75..ad33f10 100644 --- a/app/lib/application/controllers/browser_controller.dart +++ b/app/lib/application/controllers/browser_controller.dart @@ -84,6 +84,15 @@ class BrowserController { Future open(String input) async { final url = normalizeAddress(input); if (url.isEmpty) return; + + // Setting links change a value and stay on the current page instead of + // joining the walk history. + if (InternalPages.isSettingUrl(url)) { + await _internalPages?.applySetting(url); + await _loadCurrent(); + return; + } + _stack.removeRange(_stackIndex + 1, _stack.length); _stack.add(url); _stackIndex++; diff --git a/app/lib/application/internal_pages.dart b/app/lib/application/internal_pages.dart index 843bea5..c65f6ac 100644 --- a/app/lib/application/internal_pages.dart +++ b/app/lib/application/internal_pages.dart @@ -2,26 +2,30 @@ import '../../database/database.dart'; import '../../l10n/strings.dart'; import '../../mews/document.dart'; import 'controllers/reader_settings_controller.dart' - show LibraryController; + show LibraryController, ReaderSettingsController; /// Builds the internal History, Bookmarks and Subscriptions pages as real /// [MewsPage]s: they render like any other page and take part in the walk /// history, so swiping and back/forward treat them like visited pages. class InternalPages { - InternalPages( - {required MewsDatabase db, - required LibraryController library, - required Strings strings}) - : _db = db, + InternalPages({ + required MewsDatabase db, + required LibraryController library, + required ReaderSettingsController settings, + required Strings strings, + }) : _db = db, _library = library, + _settings = settings, _strings = strings; static const historyUrl = 'mews:history'; static const bookmarksUrl = 'mews:bookmarks'; static const subscriptionsUrl = 'mews:subscriptions'; + static const settingsUrl = 'mews:settings'; final MewsDatabase _db; final LibraryController _library; + final ReaderSettingsController _settings; final Strings _strings; /// Whether [url] is one of the internal pages. @@ -41,11 +45,145 @@ class InternalPages { return _bookmarks(url); case 'subscriptions': return _subscriptions(url); + case 'settings': + return _settingsPage(url); default: return null; } } + /// Whether [url] changes a setting instead of visiting a page, e.g. + /// mews:setting/theme/light. + static bool isSettingUrl(String url) => + Uri.tryParse(url)?.path.startsWith('setting/') ?? false; + + /// Applies the setting encoded in [url]; the page stays where it is and + /// rerenders with the new value. + Future applySetting(String url) async { + final segments = + Uri.tryParse(url)?.path.split('/') ?? const []; + if (segments.length < 3) return; + final value = Uri.decodeComponent(segments.last); + switch (segments[1]) { + case 'font': + await _settings.setFont(value); + case 'size': + if (value == 'decrease') { + await _settings.decreaseSize(); + } else if (value == 'increase') { + await _settings.increaseSize(); + } + case 'theme': + await _settings.setThemeMode(value); + case 'colors': + await _settings.setVariant(value); + } + } + + MewsPage _settingsPage(String url) { + return _page( + url: url, + title: _strings.t('settings-page-title'), + empty: '', + entries: const [], + extraBlocks: [ + // Typeface: the current choice is plain text; alternatives are links. + HeadingBlock(2, [MewsText(_strings.t('settings-font-heading'))]), + ..._options('font', [ + _Option(_strings.t('settings-font-atkinson'), 'Atkinson Hyperlegible'), + ], current: _settings.font.value), + // Text size steps between the configured bounds; the current value + // stays plain text between the two actions. + HeadingBlock(2, [MewsText(_strings.t('settings-size-heading'))]), + ListBlock( + ordered: false, + start: 1, + items: [ + if (_settings.fontScale.value > ReaderSettingsController.minScale) + MewsListItem([ + ParagraphBlock([ + _link('mews:setting/size/decrease', + _strings.t('settings-size-decrease')), + ]), + ]), + MewsListItem([ + ParagraphBlock([ + MewsEmphasis(EmphasisStyle.strong, [ + MewsText('${(_settings.fontScale.value * 100).round()}%'), + ]), + ]), + ]), + if (_settings.fontScale.value < ReaderSettingsController.maxScale) + MewsListItem([ + ParagraphBlock([ + _link('mews:setting/size/increase', + _strings.t('settings-size-increase')), + ]), + ]), + ], + ), + HeadingBlock(2, [MewsText(_strings.t('settings-theme-heading'))]), + ..._options('theme', [ + _Option(_strings.t('settings-theme-light'), 'light'), + _Option(_strings.t('settings-theme-dark'), 'dark'), + _Option(_strings.t('settings-theme-system'), 'system'), + ], current: _settings.themeMode.value), + HeadingBlock(2, [MewsText(_strings.t('settings-variant-heading'))]), + ..._options('colors', [ + _Option(_strings.t('settings-variant-site'), 'site'), + _Option(_strings.t('settings-variant-warm'), 'warm'), + _Option(_strings.t('settings-variant-cool'), 'cool'), + _Option(_strings.t('settings-variant-green'), 'green'), + _Option(_strings.t('settings-variant-mono'), 'mono'), + ], current: _settings.variant.value), + // The library pages stay reachable now the sheet is gone. + HeadingBlock(2, [MewsText(_strings.t('settings-library-heading'))]), + ListBlock( + ordered: false, + start: 1, + items: [ + for (final entry in [ + (historyUrl, _strings.t('settings-history')), + (bookmarksUrl, _strings.t('settings-bookmarks')), + (subscriptionsUrl, _strings.t('settings-subscriptions')), + ]) + MewsListItem([ + ParagraphBlock([_link(entry.$1, entry.$2)]), + ]), + ], + ), + ], + ); + } + + /// One item per option: the current value in bold, the others as links. + List _options(String group, List<_Option> options, + {required String current}) { + return [ + ListBlock( + ordered: false, + start: 1, + items: [ + for (final option in options) + MewsListItem([ + if (option.value == current) + ParagraphBlock([ + MewsEmphasis( + EmphasisStyle.strong, [MewsText(option.label)]), + ]) + else + ParagraphBlock([ + _link('mews:setting/$group/${option.value}', option.label), + ]), + ]), + ], + ), + ]; + } + + MewsLink _link(String href, String label) => + MewsLink(href, [MewsText(label)]); + Future _history(String url) async { final visits = await _db.recentVisits(); return _page( @@ -114,12 +252,13 @@ class InternalPages { required String title, required String empty, required List<_Entry> entries, + List extraBlocks = const [], }) { final blocks = [ HeadingBlock(1, [MewsText(title)]), - if (entries.isEmpty) + if (entries.isEmpty && extraBlocks.isEmpty) ParagraphBlock([MewsText(empty)]) - else + else if (entries.isNotEmpty) ListBlock( ordered: false, start: 1, @@ -134,6 +273,8 @@ class InternalPages { ), ]; + blocks.addAll(extraBlocks); + return MewsPage( url: url, // Internal pages carry a marker so they render as Mews pages, not as @@ -163,3 +304,11 @@ class _Entry { final String label; final String title; } + +/// One selectable value in a settings option group. +class _Option { + const _Option(this.label, this.value); + + final String label; + final String value; +} diff --git a/app/lib/presentation/app.dart b/app/lib/presentation/app.dart index dcec0d0..7def95b 100644 --- a/app/lib/presentation/app.dart +++ b/app/lib/presentation/app.dart @@ -24,7 +24,8 @@ class AppDependencies { strings = strings ?? Strings() { settings = ReaderSettingsController(db); library = LibraryController(db); - internalPages = InternalPages(db: db, library: library, strings: this.strings); + internalPages = InternalPages( + db: db, library: library, settings: settings, strings: this.strings); browser = BrowserController(db: db, fetch: this.fetch, internalPages: internalPages); } diff --git a/app/lib/presentation/reader/address_bar.dart b/app/lib/presentation/reader/address_bar.dart index 04c27cc..ed817ac 100644 --- a/app/lib/presentation/reader/address_bar.dart +++ b/app/lib/presentation/reader/address_bar.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart' show SignalBuilder; +import '../../application/internal_pages.dart'; import '../app.dart'; -/// The single-field address bar at the top of the reader. Submitting navigates; -/// the field shows the current page's URL whenever the reader is not typing. +/// The single-field address bar. The left icon opens the settings page; the +/// right icons reload, subscribe (only when the page offers something to +/// subscribe to) and bookmark the current page. class AddressBar extends StatefulWidget { const AddressBar({super.key}); @@ -44,10 +46,20 @@ class _AddressBarState extends State { final strings = AppScope.of(context).strings; return SignalBuilder(builder: (context) { - final url = deps.browser.currentPage.value?.url; + final page = deps.browser.currentPage.value; + final url = page?.url; if (url != null && !_focused) { _controller.text = url; } + + // Only pages with dated entry links or an Atom feed can be subscribed + // to (mews spec, section 6). + final canSubscribe = page != null && + !InternalPages.isInternal(page.url) && + (page.atomFeedHref != null || page.entries.isNotEmpty); + final canBookmark = + page != null && !InternalPages.isInternal(page.url); + return Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), child: TextField( @@ -65,13 +77,50 @@ class _AddressBarState extends State { decoration: InputDecoration( hintText: strings.t('reader-address-placeholder'), labelText: strings.t('reader-address-label'), - prefixIcon: IconButton( - icon: const Icon(Icons.refresh), - tooltip: strings.t('reader-reload'), - onPressed: () => deps.browser.reload(), - ), isDense: true, border: const OutlineInputBorder(), + prefixIcon: IconButton( + icon: const Icon(Icons.tune), + tooltip: strings.t('reader-open-settings'), + onPressed: () => + deps.browser.open(InternalPages.settingsUrl), + ), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: strings.t('reader-reload'), + onPressed: () => deps.browser.reload(), + ), + if (canSubscribe) + IconButton( + icon: Icon( + deps.library.isSubscribed(page.url) + ? Icons.notifications_active + : Icons.notifications_outlined, + ), + tooltip: deps.library.isSubscribed(page.url) + ? strings.t('settings-unsubscribe') + : strings.t('settings-subscribe'), + onPressed: () => deps.library + .toggleSubscription(page.url, page.title, page.entries), + ), + if (canBookmark) + IconButton( + icon: Icon( + deps.library.isBookmarked(page.url) + ? Icons.star + : Icons.star_border, + ), + tooltip: deps.library.isBookmarked(page.url) + ? strings.t('settings-bookmark-remove') + : strings.t('settings-bookmark-add'), + onPressed: () => + deps.library.toggleBookmark(page.url, page.title), + ), + ], + ), ), ), ); diff --git a/app/lib/presentation/reader/fob.dart b/app/lib/presentation/reader/fob.dart deleted file mode 100644 index 4f18f9c..0000000 --- a/app/lib/presentation/reader/fob.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; - -/// The floating control that appears when the reader scrolls, opening the -/// reader settings bottom sheet. -class Fob extends StatelessWidget { - const Fob({required this.visible, required this.label, required this.onTap, super.key}); - - final bool visible; - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return AnimatedOpacity( - opacity: visible ? 1 : 0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - child: IgnorePointer( - ignoring: !visible, - child: Padding( - padding: const EdgeInsets.all(16), - child: Semantics( - label: label, - button: true, - child: FloatingActionButton( - onPressed: onTap, - child: const Icon(Icons.tune), - ), - ), - ), - ), - ); - } -} diff --git a/app/lib/presentation/reader/reader_screen.dart b/app/lib/presentation/reader/reader_screen.dart index 92bca32..d95cbe4 100644 --- a/app/lib/presentation/reader/reader_screen.dart +++ b/app/lib/presentation/reader/reader_screen.dart @@ -8,10 +8,8 @@ import '../../mews/document.dart'; import '../../mews/parser.dart' show mewsPlainText; import '../app.dart'; import 'address_bar.dart'; -import 'fob.dart'; import 'mews_page_view.dart'; import 'palettes.dart'; -import 'settings_sheet.dart'; import 'swipe_navigator.dart'; /// The whole v1 UI: an address bar that hides on scroll, the page reader with @@ -27,8 +25,8 @@ class ReaderScreen extends StatefulWidget { class _ReaderScreenState extends State { final _scroll = ScrollController(); - /// Address bar and FOB share one visibility: on screen from the start, - /// hidden while the reader scrolls down, back on any upward scroll. + /// The address bar is on screen from the start, hidden while the reader + /// scrolls down, back on any upward scroll. bool _chromeVisible = true; bool _showBrokenText = false; final _reportedNewPosts = {}; @@ -63,9 +61,7 @@ class _ReaderScreenState extends State { @override Widget build(BuildContext context) { - final scope = AppScope.of(context); - final deps = scope.deps; - final strings = scope.strings; + final deps = AppScope.of(context).deps; return Scaffold( body: Column( @@ -116,15 +112,6 @@ class _ReaderScreenState extends State { child: Stack( children: [ Positioned.fill(child: _body(context, page)), - Positioned( - right: 0, - bottom: 0, - child: Fob( - visible: _chromeVisible, - label: strings.t('reader-fob-label'), - onTap: () => showReaderSettingsSheet(context), - ), - ), if (browser.loading.value) const Positioned( top: 8, diff --git a/app/lib/presentation/reader/settings_sheet.dart b/app/lib/presentation/reader/settings_sheet.dart deleted file mode 100644 index 4aca13c..0000000 --- a/app/lib/presentation/reader/settings_sheet.dart +++ /dev/null @@ -1,193 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:signals/signals_flutter.dart' show SignalBuilder; - -import '../../application/controllers/reader_settings_controller.dart'; -import '../../application/internal_pages.dart'; -import '../app.dart'; -import 'reader_fonts.dart'; - -/// Opens the reader settings bottom sheet: fonts, text size, theme, colors, -/// bookmark and subscription for the current page. -void showReaderSettingsSheet(BuildContext context) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => const SettingsSheet(), - ); -} - -class SettingsSheet extends StatelessWidget { - const SettingsSheet({super.key}); - - @override - Widget build(BuildContext context) { - final scope = AppScope.of(context); - final deps = scope.deps; - final strings = scope.strings; - - return SignalBuilder(builder: (context) { - final settings = deps.settings; - final library = deps.library; - final page = deps.browser.currentPage.value; - final url = page?.url; - - final percent = (settings.fontScale.value * 100).round(); - - return SafeArea( - child: ListView( - shrinkWrap: true, - padding: const EdgeInsets.fromLTRB(0, 12, 0, 16), - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - strings.t('settings-sheet-title'), - style: Theme.of(context).textTheme.titleMedium, - ), - ), - // The library pages are internal Mews pages; opening one joins - // the walk history like any other page. - ListTile( - leading: const Icon(Icons.history), - title: Text(strings.t('settings-history')), - onTap: () => _openPage(context, InternalPages.historyUrl), - ), - ListTile( - leading: const Icon(Icons.star_outline), - title: Text(strings.t('settings-bookmarks')), - onTap: () => _openPage(context, InternalPages.bookmarksUrl), - ), - ListTile( - leading: const Icon(Icons.notifications_outlined), - title: Text(strings.t('settings-subscriptions')), - onTap: () => _openPage(context, InternalPages.subscriptionsUrl), - ), - const Divider(), - _heading(context, strings.t('settings-font-heading')), - RadioGroup( - groupValue: settings.font.value, - onChanged: (value) => settings.setFont(value!), - child: Column( - children: [ - for (final font in readerFonts) - RadioListTile( - value: font.family, - title: Text(strings.t(font.labelId)), - ), - ], - ), - ), - _heading(context, strings.t('settings-size-heading')), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - tooltip: strings.t('settings-size-decrease'), - onPressed: settings.fontScale.value > ReaderSettingsController.minScale - ? settings.decreaseSize - : null, - icon: const Icon(Icons.remove), - ), - SizedBox( - width: 56, - child: Text( - '$percent%', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - IconButton( - tooltip: strings.t('settings-size-increase'), - onPressed: settings.fontScale.value < ReaderSettingsController.maxScale - ? settings.increaseSize - : null, - icon: const Icon(Icons.add), - ), - ], - ), - _heading(context, strings.t('settings-theme-heading')), - RadioGroup( - groupValue: settings.themeMode.value, - onChanged: (value) => settings.setThemeMode(value!), - child: Column( - children: [ - for (final (mode, id) in [ - ('light', 'settings-theme-light'), - ('dark', 'settings-theme-dark'), - ('system', 'settings-theme-system'), - ]) - RadioListTile( - value: mode, - title: Text(strings.t(id)), - ), - ], - ), - ), - _heading(context, strings.t('settings-variant-heading')), - RadioGroup( - groupValue: settings.variant.value, - onChanged: (value) => settings.setVariant(value!), - child: Column( - children: [ - for (final (variant, id) in [ - ('site', 'settings-variant-site'), - ('warm', 'settings-variant-warm'), - ('cool', 'settings-variant-cool'), - ('green', 'settings-variant-green'), - ('mono', 'settings-variant-mono'), - ]) - RadioListTile( - value: variant, - title: Text(strings.t(id)), - ), - ], - ), - ), - const Divider(), - if (url != null && !InternalPages.isInternal(url)) ...[ - ListTile( - leading: Icon(library.isBookmarked(url) ? Icons.star : Icons.star_border), - title: Text( - library.isBookmarked(url) - ? strings.t('settings-bookmark-remove') - : strings.t('settings-bookmark-add'), - ), - onTap: () => library.toggleBookmark(url, page?.title), - ), - ListTile( - leading: Icon(library.isSubscribed(url) - ? Icons.notifications_active - : Icons.notifications_outlined), - title: Text( - library.isSubscribed(url) - ? strings.t('settings-unsubscribe') - : strings.t('settings-subscribe'), - ), - subtitle: library.isSubscribed(url) - ? Text(strings.t('settings-subscribed-notice')) - : null, - onTap: () => library.toggleSubscription(url, page?.title, page?.entries ?? const []), - ), - ], - ], - ), - ); - }); - } - - void _openPage(BuildContext context, String url) { - Navigator.of(context).pop(); - AppScope.of(context).deps.browser.open(url); - } - - Widget _heading(BuildContext context, String text) => Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), - child: Text( - text, - style: Theme.of(context) - .textTheme - .labelLarge - ?.copyWith(color: Theme.of(context).colorScheme.primary), - ), - ); -} diff --git a/app/locales/en-US/main.ftl b/app/locales/en-US/main.ftl index 895c781..89863e1 100644 --- a/app/locales/en-US/main.ftl +++ b/app/locales/en-US/main.ftl @@ -4,11 +4,11 @@ reader-address-placeholder = Enter a web address # Accessibility label for the address field. reader-address-label = Web address reader-reload = Reload this page -reader-fob-label = Reading settings +reader-open-settings = Open settings -## Reader settings sheet +## Settings page -settings-sheet-title = Reading settings +settings-page-title = Settings settings-font-heading = Typeface settings-font-atkinson = Atkinson Hyperlegible settings-size-heading = Text size @@ -28,7 +28,6 @@ settings-bookmark-add = Add bookmark settings-bookmark-remove = Remove bookmark settings-subscribe = Subscribe to this site settings-unsubscribe = Stop subscribing -settings-subscribed-notice = New posts from this site appear here. ## Library pages @@ -44,6 +43,7 @@ subscriptions-new-many = { $count } new posts settings-history = History settings-bookmarks = Bookmarks settings-subscriptions = Subscriptions +settings-library-heading = Library ## New posts diff --git a/app/test/app_widget_test.dart b/app/test/app_widget_test.dart index 8bf1be7..ad8c059 100644 --- a/app/test/app_widget_test.dart +++ b/app/test/app_widget_test.dart @@ -8,17 +8,13 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mews/application/actors/fetch_actor.dart'; import 'package:mews/application/controllers/browser_controller.dart' show BrokenPage, LoadFailed; -import 'package:mews/application/controllers/reader_settings_controller.dart' - show ReaderSettingsController; import 'package:mews/database/database.dart'; import 'package:mews/l10n/strings.dart'; import 'package:mews/mews/document.dart'; import 'package:mews/mews/parser.dart'; +import 'package:mews/application/internal_pages.dart'; import 'package:mews/presentation/app.dart'; import 'package:mews/presentation/reader/address_bar.dart'; -import 'package:mews/presentation/reader/fob.dart'; -import 'package:mews/presentation/reader/reader_screen.dart'; -import 'package:mews/presentation/reader/settings_sheet.dart'; import 'package:mews/presentation/reader/swipe_navigator.dart'; const pageSource = ''' @@ -143,53 +139,102 @@ void main() { expect(find.textContaining('Try again'), findsOneWidget); }); - testWidgets('settings sheet changes presentation and toggles library', + testWidgets('the settings page changes presentation and keeps its place', (tester) async { await pumpApp(tester); + // Settings opens from the left address bar icon. + await tester.tap(find.byIcon(Icons.tune)); + await tester.pumpAndSettle(); + expect(deps.browser.currentPage.value?.url, InternalPages.settingsUrl); + + // Rendered like a Mews page with the option groups. + expect(find.text('Settings'), findsOneWidget); + expect(find.text('Typeface'), findsOneWidget); + expect(find.text('Theme'), findsOneWidget); + expect(find.text('Colors'), findsOneWidget); + expect(find.text('Library'), findsOneWidget); + + // Tapping a theme link applies it and stays on the settings page; the + // lower sections scroll into view first. + Future scrollTo(Finder target) async { + // Search down the page, then back up for targets above. + for (var i = 0; i < 20 && target.evaluate().isEmpty; i++) { + await tester.drag(find.byType(ListView), const Offset(0, -200)); + await tester.pumpAndSettle(); + } + for (var i = 0; i < 40 && target.evaluate().isEmpty; i++) { + await tester.drag(find.byType(ListView), const Offset(0, 200)); + await tester.pumpAndSettle(); + } + await tester.ensureVisible(target); + await tester.pumpAndSettle(); + } + + // The option rows stretch the full column width, so tap on the glyphs + // themselves rather than the widget's center. + Future tapLink(String label) async { + final rect = tester.getRect(find.text(label)); + await tester.tapAt(rect.topLeft + const Offset(30, 13)); + await tester.pumpAndSettle(); + } + + await scrollTo(find.text('Dark')); + await tapLink('Dark'); + expect(deps.settings.themeMode.value, 'dark'); + expect(deps.browser.currentPage.value?.url, InternalPages.settingsUrl); + expect(deps.browser.canGoBack, isFalse); + + await scrollTo(find.text('Warm')); + await tapLink('Warm'); + expect(deps.settings.variant.value, 'warm'); + + await scrollTo(find.text('Larger text')); + await tapLink('Larger text'); + expect(deps.settings.fontScale.value, 1.125); + + // The library links navigate and join the walk history. + await scrollTo(find.text('History')); + await tapLink('History'); + await tester.pumpAndSettle(); + expect(deps.browser.currentPage.value?.url, InternalPages.historyUrl); + expect(deps.browser.canGoBack, isTrue); + + await deps.browser.back(); + await tester.pumpAndSettle(); + expect(deps.browser.currentPage.value?.url, InternalPages.settingsUrl); + }); + + testWidgets('bookmark and subscribe live on the address bar', (tester) async { + await pumpApp(tester); + + // A page with dated entries can be subscribed to. final page = parseSource(pageSource, 'https://example.com/index.html'); deps.browser.currentPage.value = page; await tester.pumpAndSettle(); - showReaderSettingsSheet(tester.element(find.byType(ReaderScreen))); - await tester.pumpAndSettle(); - - // Typeface, size, theme, colors; the lower sections need scrolling. - final sheet = find.byType(Scrollable).last; - await tester.tap(find.text('Atkinson Hyperlegible')); - await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.add)); - await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('Dark'), 200, scrollable: sheet); - await tester.tap(find.text('Dark')); - await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('Warm'), 200, scrollable: sheet); - await tester.tap(find.text('Warm')); - await tester.pumpAndSettle(); - - expect(deps.settings.fontScale.value, 1.125); - expect(deps.settings.themeMode.value, 'dark'); - expect(deps.settings.variant.value, 'warm'); - - // Bookmark and subscribe the current page; both tiles sit at the bottom - // of the sheet, so scroll the sheet down to the end first. - await tester.drag(sheet, const Offset(0, -1000)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Add bookmark')); - await tester.pumpAndSettle(); - expect(deps.library.isBookmarked('https://example.com/index.html'), isTrue); - - await tester.tap(find.text('Subscribe to this site')); + expect(find.byIcon(Icons.notifications_outlined), findsOneWidget); + await tester.tap(find.byIcon(Icons.notifications_outlined)); await tester.pumpAndSettle(); expect(deps.library.isSubscribed('https://example.com/index.html'), isTrue); + expect(find.byIcon(Icons.notifications_active), findsOneWidget); - // Settings survive a reload of the controller. - final reloaded = ReaderSettingsController(db); - await reloaded.load(); - expect(reloaded.themeMode.value, 'dark'); + expect(find.byIcon(Icons.star_border), findsOneWidget); + await tester.tap(find.byIcon(Icons.star_border)); + await tester.pumpAndSettle(); + expect(deps.library.isBookmarked('https://example.com/index.html'), isTrue); + expect(find.byIcon(Icons.star), findsOneWidget); + + // Pages with neither Atom feed nor dated links offer no subscription. + final plain = parseSource( + _page('Plain', '

Plain

No entries here.

'), + 'https://example.com/plain.html'); + deps.browser.currentPage.value = plain; + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.notifications_outlined), findsNothing); }); - testWidgets('the FOB hides on scroll down and returns on scroll up', + testWidgets('the address bar hides on scroll down and returns on scroll up', (tester) async { await pumpApp(tester); @@ -201,19 +246,16 @@ void main() { deps.browser.currentPage.value = page; await tester.pumpAndSettle(); - Fob fob() => tester.widget(find.byType(Fob)); - expect(fob().visible, isTrue); + expect(find.byType(AddressBar), findsOneWidget); - // Scrolling down hides the address bar and the FOB. + // Scrolling down hides the address bar. await tester.drag(find.byType(ListView), const Offset(0, -300)); await tester.pumpAndSettle(); - expect(fob().visible, isFalse); expect(find.byType(AddressBar), findsNothing); - // Scrolling up brings both back. + // Scrolling up brings it back. await tester.drag(find.byType(ListView), const Offset(0, 300)); await tester.pumpAndSettle(); - expect(fob().visible, isTrue); expect(find.byType(AddressBar), findsOneWidget); }); @@ -260,40 +302,6 @@ void main() { expect(find.text('https://example.com/a.html'), findsOneWidget); }); - testWidgets('the chrome follows the selected colors', (tester) async { - await pumpApp(tester); - await deps.settings.setThemeMode('light'); - await deps.settings.setVariant('warm'); - await tester.pumpAndSettle(); - - MaterialApp app() => tester.widget(find.byType(MaterialApp)); - // Warm light palette on the light theme, FOB included. - expect(app().theme!.colorScheme.surface, const Color(0xfffbf4e8)); - expect(app().theme!.colorScheme.primary, const Color(0xff8a3d14)); - expect(app().theme!.floatingActionButtonTheme.backgroundColor, - const Color(0xff8a3d14)); - expect(app().theme!.textSelectionTheme.selectionColor, - const Color(0xff8a3d14).withValues(alpha: 0.3)); - - await deps.settings.setThemeMode('dark'); - await deps.settings.setVariant('green'); - await tester.pumpAndSettle(); - // Green dark palette on the dark theme. - expect(app().darkTheme!.colorScheme.surface, const Color(0xff161b16)); - expect(app().darkTheme!.colorScheme.primary, const Color(0xff9fd39a)); - expect(app().darkTheme!.floatingActionButtonTheme.backgroundColor, - const Color(0xff9fd39a)); - }); - - testWidgets('page text is selectable', (tester) async { - await pumpApp(tester); - final page = parseSource(pageSource, 'https://example.com/index.html'); - deps.browser.currentPage.value = page; - await tester.pumpAndSettle(); - - expect(find.byType(SelectionArea), findsOneWidget); - }); - testWidgets('falls back to the wap subdomain before showing an error', (tester) async { final stubDeps = AppDependencies( @@ -375,17 +383,6 @@ void main() { expect(find.textContaining('Page A'), findsOneWidget); }); - testWidgets('the settings sheet opens the library pages', (tester) async { - await pumpApp(tester); - showReaderSettingsSheet(tester.element(find.byType(ReaderScreen))); - await tester.pumpAndSettle(); - - await tester.tap(find.text('History')); - await tester.pumpAndSettle(); - expect(deps.browser.currentPage.value?.url, 'mews:history'); - expect(find.text('History'), findsOneWidget); - }); - testWidgets('reports new posts once per page', (tester) async { await pumpApp(tester); diff --git a/app/test/reader_widget_test.dart b/app/test/reader_widget_test.dart index 71fea69..b43a3c1 100644 --- a/app/test/reader_widget_test.dart +++ b/app/test/reader_widget_test.dart @@ -8,7 +8,6 @@ import 'package:mews/database/database.dart'; import 'package:mews/l10n/strings.dart'; import 'package:mews/mews/document.dart'; import 'package:mews/presentation/app.dart'; -import 'package:mews/presentation/reader/fob.dart'; import 'package:mews/presentation/reader/mews_page_view.dart'; import 'package:mews/presentation/reader/palettes.dart'; @@ -20,16 +19,15 @@ Future _app() async { } void main() { - testWidgets('starts with the address bar and the FOB on screen', (tester) async { + testWidgets('starts with the address bar and its icons', (tester) async { await tester.pumpWidget(await _app()); await tester.pumpAndSettle(); expect(find.byType(TextField), findsOneWidget); expect(find.text('Enter a web address'), findsOneWidget); - // The FOB is on screen from the beginning and hides only while the - // reader scrolls down. - final fob = tester.widget(find.byType(Fob)); - expect(fob.visible, isTrue); + // Settings opens from the left icon; no floating button remains. + expect(find.byIcon(Icons.tune), findsOneWidget); + expect(find.byType(FloatingActionButton), findsNothing); }); testWidgets('renders a page and follows links', (tester) async {