From 56f3d959fa305a2950586c9c0a722f28c840b850 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 11 Oct 2026 11:48:24 +0300 Subject: [PATCH] feat: theme chrome by palette, selectable text, wap fallback, and library pages --- .../controllers/browser_controller.dart | 66 ++++++- app/lib/application/internal_pages.dart | 165 ++++++++++++++++++ app/lib/database/database.dart | 21 +++ app/lib/main.dart | 2 +- app/lib/presentation/app.dart | 76 ++++++-- .../presentation/reader/mews_page_view.dart | 5 +- app/lib/presentation/reader/palettes.dart | 2 +- .../presentation/reader/settings_sheet.dart | 26 ++- .../presentation/reader/swipe_navigator.dart | 45 +++-- app/locales/en-US/main.ftl | 15 ++ app/test/app_widget_test.dart | 153 ++++++++++++++-- app/test/swipe_navigator_test.dart | 9 +- 12 files changed, 534 insertions(+), 51 deletions(-) create mode 100644 app/lib/application/internal_pages.dart diff --git a/app/lib/application/controllers/browser_controller.dart b/app/lib/application/controllers/browser_controller.dart index c8f154a..9158c75 100644 --- a/app/lib/application/controllers/browser_controller.dart +++ b/app/lib/application/controllers/browser_controller.dart @@ -5,6 +5,7 @@ import 'package:signals/signals.dart'; import '../../database/database.dart'; import '../../mews/document.dart'; import '../actors/fetch_actor.dart'; +import '../internal_pages.dart'; /// What went wrong while browsing. The presentation layer maps these to /// user-facing messages; no raw codes cross into the UI. @@ -27,12 +28,17 @@ class BrokenPage extends BrowseError { /// dispatches fetch commands to the fetch actor. Local-first: a cached page is /// revalidated with the server but survives any network failure. class BrowserController { - BrowserController({required MewsDatabase db, required Actor fetch}) - : _db = db, - _fetch = fetch; + BrowserController({ + required MewsDatabase db, + required Actor fetch, + InternalPages? internalPages, + }) : _db = db, + _fetch = fetch, + _internalPages = internalPages; final MewsDatabase _db; final Actor _fetch; + final InternalPages? _internalPages; /// The stack of visited pages; the reader is at [_stackIndex]. final List _stack = []; @@ -54,14 +60,27 @@ class BrowserController { bool get canGoForward => _stackIndex >= 0 && _stack.length - 1 > _stackIndex; /// Normalizes what the reader typed into an address: bare domains become - /// https URLs. + /// https URLs; anything already carrying a scheme (mews:, https:, ...) is + /// left alone. static String normalizeAddress(String input) { final trimmed = input.trim(); if (trimmed.isEmpty) return ''; - if (trimmed.contains('://')) return trimmed; + if (_schemePattern.hasMatch(trimmed)) return trimmed; return 'https://$trimmed'; } + static final _schemePattern = RegExp(r'^[a-zA-Z][a-zA-Z0-9+.-]*:'); + + /// The wap. subdomain equivalent of [url], or null when the host already + /// starts with it or the address is not a plain web URL. + static String? wapUrlOf(String url) { + final uri = Uri.tryParse(url); + if (uri == null || !uri.hasScheme || uri.host.isEmpty) return null; + if (uri.scheme != 'http' && uri.scheme != 'https') return null; + if (uri.host.startsWith('wap.')) return null; + return uri.replace(host: 'wap.${uri.host}').toString(); + } + Future open(String input) async { final url = normalizeAddress(input); if (url.isEmpty) return; @@ -70,6 +89,23 @@ class BrowserController { _stackIndex++; _updateNeighbors(); await _loadCurrent(); + + // Mews sites commonly live on a wap. subdomain; try that before showing + // an error when the address as typed does not work out. + if (error.value != null && _stack[_stackIndex] == url) { + final wap = wapUrlOf(url); + if (wap != null) { + final firstError = error.value; + _stack[_stackIndex] = wap; + _updateNeighbors(); + await _loadCurrent(); + // The address as typed is the closer match; keep its error when the + // wap. attempt does not work either. + if (error.value != null && firstError != null) { + error.value = firstError; + } + } + } } Future back() async { @@ -142,6 +178,10 @@ class BrowserController { return; } final url = _stack[index]; + if (_internalPages?.handles(url) ?? false) { + target.value = await _internalPages!.pageFor(url); + return; + } final cached = await _db.cachedPage(url); if (_stackIndex != expectedIndex) return; if (cached == null) { @@ -159,6 +199,22 @@ class BrowserController { loading.value = true; error.value = null; + // Internal pages are built from the local database, never fetched or + // cached; they join the stack like any other page. + if (_internalPages?.handles(url) ?? false) { + final page = await _internalPages!.pageFor(url); + loading.value = false; + if (page != null) { + currentPage.value = page; + error.value = null; + visitedUrls.value = {...visitedUrls.value, url}; + } else { + currentPage.value = null; + error.value = const LoadFailed(); + } + return; + } + final cached = await _db.cachedPage(url); final result = await _fetch.send(FetchPage( url: url, diff --git a/app/lib/application/internal_pages.dart b/app/lib/application/internal_pages.dart new file mode 100644 index 0000000..843bea5 --- /dev/null +++ b/app/lib/application/internal_pages.dart @@ -0,0 +1,165 @@ +import '../../database/database.dart'; +import '../../l10n/strings.dart'; +import '../../mews/document.dart'; +import 'controllers/reader_settings_controller.dart' + show LibraryController; + +/// 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, + _library = library, + _strings = strings; + + static const historyUrl = 'mews:history'; + static const bookmarksUrl = 'mews:bookmarks'; + static const subscriptionsUrl = 'mews:subscriptions'; + + final MewsDatabase _db; + final LibraryController _library; + final Strings _strings; + + /// Whether [url] is one of the internal pages. + bool handles(String url) => isInternal(url); + + /// Whether [url] belongs to the internal mews: scheme. + static bool isInternal(String url) => + Uri.tryParse(url)?.scheme == 'mews'; + + /// The internal page for [url], or null when the name is unknown. + Future pageFor(String url) async { + // mews:history is an opaque URI: the page name is the path, not a host. + switch (Uri.tryParse(url)?.path) { + case 'history': + return _history(url); + case 'bookmarks': + return _bookmarks(url); + case 'subscriptions': + return _subscriptions(url); + default: + return null; + } + } + + Future _history(String url) async { + final visits = await _db.recentVisits(); + return _page( + url: url, + title: _strings.t('history-title'), + empty: _strings.t('history-empty'), + entries: [ + for (final visit in visits) + _Entry( + url: visit.url, + // Dated links are the Mews posts pattern, so history reads like + // any other entry list. + label: '${_day(visit.visitedAt)} ${_clock(visit.visitedAt)}', + title: visit.title ?? visit.url, + ), + ], + ); + } + + Future _bookmarks(String url) async { + final bookmarks = await _db.bookmarksByDate(); + return _page( + url: url, + title: _strings.t('bookmarks-title'), + empty: _strings.t('bookmarks-empty'), + entries: [ + for (final bookmark in bookmarks) + _Entry( + url: bookmark.url, + label: _day(bookmark.createdAt), + title: bookmark.title ?? bookmark.url, + ), + ], + ); + } + + Future _subscriptions(String url) async { + final subscriptions = await _db.allSubscriptions(); + return _page( + url: url, + title: _strings.t('subscriptions-title'), + empty: _strings.t('subscriptions-empty'), + entries: [ + for (final subscription in subscriptions) + _Entry( + url: subscription.url, + label: _day(subscription.createdAt), + title: (subscription.title ?? subscription.url) + + _newPostsNote(subscription.url), + ), + ], + ); + } + + String _newPostsNote(String siteUrl) { + final count = _library.newEntries.value[siteUrl]?.length ?? 0; + if (count == 0) return ''; + final note = count == 1 + ? _strings.t('subscriptions-new-one') + : _strings.t('subscriptions-new-many', {'count': count}); + return ' \u2014 $note'; + } + + MewsPage _page({ + required String url, + required String title, + required String empty, + required List<_Entry> entries, + }) { + final blocks = [ + HeadingBlock(1, [MewsText(title)]), + if (entries.isEmpty) + ParagraphBlock([MewsText(empty)]) + else + ListBlock( + ordered: false, + start: 1, + items: [ + for (final entry in entries) + MewsListItem([ + ParagraphBlock([ + MewsLink(entry.url, [MewsText('${entry.label} ${entry.title}')]), + ]), + ]), + ], + ), + ]; + + return MewsPage( + url: url, + // Internal pages carry a marker so they render as Mews pages, not as + // plainly-shown external ones. + specVersion: 'internal', + title: title, + body: blocks, + lang: 'en', + direction: TextDirection.ltr, + variant: MewsVariant.plain, + entries: const [], + ); + } + + String _day(DateTime date) => + '${date.year.toString().padLeft(4, '0')}-${_two(date.month)}-${_two(date.day)}'; + + String _clock(DateTime date) => '${_two(date.hour)}:${_two(date.minute)}'; + + String _two(int value) => value.toString().padLeft(2, '0'); +} + +class _Entry { + const _Entry({required this.url, required this.label, required this.title}); + + final String url; + final String label; + final String title; +} diff --git a/app/lib/database/database.dart b/app/lib/database/database.dart index ff595d6..182566e 100644 --- a/app/lib/database/database.dart +++ b/app/lib/database/database.dart @@ -91,6 +91,27 @@ class MewsDatabase extends _$MewsDatabase { return row?.url; } + /// The most recent external visits, newest first. + Future> recentVisits({int limit = 50}) async { + final query = (select(historyItems) + ..orderBy([ + (r) => OrderingTerm.desc(r.visitedAt), + (r) => OrderingTerm.desc(r.id), + ]) + ..limit(limit)); + return query.get(); + } + + /// Bookmarks in the order they were added, newest first. + Future> bookmarksByDate() async { + final query = (select(bookmarks) + ..orderBy([ + (r) => OrderingTerm.desc(r.createdAt), + (r) => OrderingTerm.desc(r.id), + ])); + return query.get(); + } + // Bookmarks ----------------------------------------------------------- Future> allBookmarks() => select(bookmarks).get(); diff --git a/app/lib/main.dart b/app/lib/main.dart index 98b18f6..e2d5e57 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -9,7 +9,7 @@ Future main() async { final strings = Strings(); await strings.load(); - final deps = AppDependencies(); + final deps = AppDependencies(strings: strings); await deps.start(); runApp(MewsApp(deps: deps, strings: strings)); diff --git a/app/lib/presentation/app.dart b/app/lib/presentation/app.dart index f7acb8d..dcec0d0 100644 --- a/app/lib/presentation/app.dart +++ b/app/lib/presentation/app.dart @@ -5,26 +5,38 @@ import 'package:signals/signals_flutter.dart' show SignalBuilder; import '../application/actors/fetch_actor.dart'; import '../application/controllers/browser_controller.dart'; import '../application/controllers/reader_settings_controller.dart'; +import '../application/internal_pages.dart'; import '../database/database.dart'; import '../l10n/strings.dart'; +import '../mews/document.dart'; +import 'reader/palettes.dart'; import 'reader/reader_screen.dart'; /// Wires the app's dependencies together and provides them to the tree. /// Everything is local-first: the database is created up front and the fetch /// actor is the only path to the network. class AppDependencies { - AppDependencies({MewsDatabase? database, Actor? fetch}) - : db = database ?? MewsDatabase(), - fetch = fetch ?? Actor.create(FetchHandler.new) { - browser = BrowserController(db: db, fetch: this.fetch); + AppDependencies({ + MewsDatabase? database, + Actor? fetch, + Strings? strings, + }) : db = database ?? MewsDatabase(), + fetch = fetch ?? Actor.create(FetchHandler.new), + strings = strings ?? Strings() { settings = ReaderSettingsController(db); library = LibraryController(db); + internalPages = InternalPages(db: db, library: library, strings: this.strings); + browser = BrowserController(db: db, fetch: this.fetch, internalPages: internalPages); } + /// Localized strings, also used to title the internal pages. + final Strings strings; + final MewsDatabase db; final Actor fetch; late final BrowserController browser; late final ReaderSettingsController settings; late final LibraryController library; + late final InternalPages internalPages; Future start() async { await settings.load(); @@ -70,6 +82,8 @@ class MewsApp extends StatelessWidget { } } +/// The chrome (address bar, FOB, sheet, dialogs) follows the same palette +/// the reader selected for pages, so the whole app shares one color scheme. class _ThemedApp extends StatelessWidget { const _ThemedApp(); @@ -82,21 +96,55 @@ class _ThemedApp extends StatelessWidget { 'dark' => ThemeMode.dark, _ => ThemeMode.system, }; + final settings = deps.settings; + final page = deps.browser.currentPage.value; + final variant = settings.variant.value == 'site' + ? (page?.variant ?? MewsVariant.plain) + : pageVariantFromName(settings.variant.value); + return MaterialApp( title: 'Mews', themeMode: mode, - theme: ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff1a4f9c)), - ), - darkTheme: ThemeData( - useMaterial3: true, - brightness: Brightness.dark, - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xff1a4f9c), brightness: Brightness.dark), - ), + theme: _themeFor(paletteFor(pageVariant: variant, readerVariant: 'site', dark: false)), + darkTheme: _themeFor(paletteFor(pageVariant: variant, readerVariant: 'site', dark: true)), home: const ReaderScreen(), ); }); } + + ThemeData _themeFor(MewsPalette palette) { + final dark = palette.background.computeLuminance() < 0.5; + final scheme = ColorScheme( + brightness: dark ? Brightness.dark : Brightness.light, + primary: palette.link, + onPrimary: palette.background, + primaryContainer: palette.link, + onPrimaryContainer: palette.background, + secondary: palette.border, + onSecondary: palette.foreground, + surface: palette.background, + onSurface: palette.foreground, + onSurfaceVariant: palette.foreground, + surfaceContainerHighest: palette.preBackground, + outline: palette.border, + error: const Color(0xffba1a1a), + onError: Colors.white, + ); + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + scaffoldBackgroundColor: palette.background, + // The FOB follows the palette, not the M3 container defaults. + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: palette.link, + foregroundColor: palette.background, + ), + // Selection highlights and the caret follow the palette too. + textSelectionTheme: TextSelectionThemeData( + cursorColor: palette.link, + selectionColor: palette.link.withValues(alpha: 0.3), + selectionHandleColor: palette.link, + ), + ); + } } diff --git a/app/lib/presentation/reader/mews_page_view.dart b/app/lib/presentation/reader/mews_page_view.dart index 6bb2f3d..a344a4b 100644 --- a/app/lib/presentation/reader/mews_page_view.dart +++ b/app/lib/presentation/reader/mews_page_view.dart @@ -76,7 +76,9 @@ class _MewsPageViewState extends State { textDirection: widget.page.direction == TextDirection.rtl ? ui.TextDirection.rtl : ui.TextDirection.ltr, - child: ColoredBox( + // Page text is the reader's to copy; forms manage their own selection. + child: SelectionArea( + child: ColoredBox( color: palette.background, child: ListView( controller: widget.scrollController, @@ -94,6 +96,7 @@ class _MewsPageViewState extends State { ), ], ), + ), ), ); } diff --git a/app/lib/presentation/reader/palettes.dart b/app/lib/presentation/reader/palettes.dart index a9cc91c..2641f6d 100644 --- a/app/lib/presentation/reader/palettes.dart +++ b/app/lib/presentation/reader/palettes.dart @@ -116,7 +116,7 @@ const _monoDark = MewsPalette( /// Resolves the palette: the reader's variant choice overrides the page's own /// variant unless the choice is "site" (spec 5.3, 8.2). MewsPalette paletteFor({required MewsVariant pageVariant, required String readerVariant, required bool dark}) { - final variant = readerVariant == 'site' ? pageVariant.name : pageVariantFromName(readerVariant); + final variant = readerVariant == 'site' ? pageVariant : pageVariantFromName(readerVariant); return switch ((variant, dark)) { (MewsVariant.warm, false) => _warmLight, (MewsVariant.warm, true) => _warmDark, diff --git a/app/lib/presentation/reader/settings_sheet.dart b/app/lib/presentation/reader/settings_sheet.dart index 6e93380..4aca13c 100644 --- a/app/lib/presentation/reader/settings_sheet.dart +++ b/app/lib/presentation/reader/settings_sheet.dart @@ -2,6 +2,7 @@ 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'; @@ -44,6 +45,24 @@ class SettingsSheet extends StatelessWidget { 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, @@ -125,7 +144,7 @@ class SettingsSheet extends StatelessWidget { ), ), const Divider(), - if (url != null) ...[ + if (url != null && !InternalPages.isInternal(url)) ...[ ListTile( leading: Icon(library.isBookmarked(url) ? Icons.star : Icons.star_border), title: Text( @@ -156,6 +175,11 @@ class SettingsSheet extends StatelessWidget { }); } + 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( diff --git a/app/lib/presentation/reader/swipe_navigator.dart b/app/lib/presentation/reader/swipe_navigator.dart index 0caa5a7..3a0fcdd 100644 --- a/app/lib/presentation/reader/swipe_navigator.dart +++ b/app/lib/presentation/reader/swipe_navigator.dart @@ -73,6 +73,10 @@ class _SwipeNavigatorState extends State bool get _settledAnimating => _settling != null && _settle.isAnimating; + /// How close to a screen edge a drag must begin to navigate; horizontal + /// drags elsewhere belong to text selection. + static const _edgeZone = 40.0; + void _onDragStart(DragStartDetails details) { if (_settledAnimating) return; setState(() => _dragging = true); @@ -161,24 +165,37 @@ class _SwipeNavigatorState extends State ) : null; - return GestureDetector( - behavior: HitTestBehavior.deferToChild, - onHorizontalDragStart: _onDragStart, - onHorizontalDragUpdate: _onDragUpdate, - onHorizontalDragEnd: _onDragEnd, - child: Stack( - children: [ - if (peek != null) Positioned.fill(child: peek), - Transform.translate( - offset: Offset(dx, 0), - child: widget.child, - ), - ], - ), + return Stack( + children: [ + if (peek != null) Positioned.fill(child: peek), + Transform.translate( + offset: Offset(dx, 0), + child: widget.child, + ), + // Only horizontal drags that begin at a screen edge navigate; + // the strips are translucent, so taps, vertical scrolling and + // text selection still pass through everywhere. + _edgeStrip(fromLeft: true), + _edgeStrip(fromLeft: false), + ], ); }, ); } + + Widget _edgeStrip({required bool fromLeft}) => Positioned( + left: fromLeft ? 0 : null, + right: fromLeft ? null : 0, + top: 0, + bottom: 0, + width: _edgeZone, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: _onDragStart, + onHorizontalDragUpdate: _onDragUpdate, + onHorizontalDragEnd: _onDragEnd, + ), + ); } /// The neighboring page's fallback panel while it is still being parsed. diff --git a/app/locales/en-US/main.ftl b/app/locales/en-US/main.ftl index 9327790..895c781 100644 --- a/app/locales/en-US/main.ftl +++ b/app/locales/en-US/main.ftl @@ -30,6 +30,21 @@ settings-subscribe = Subscribe to this site settings-unsubscribe = Stop subscribing settings-subscribed-notice = New posts from this site appear here. +## Library pages + +# Internal pages rendered like ordinary Mews pages. +history-title = History +history-empty = Pages you visit appear here. +bookmarks-title = Bookmarks +bookmarks-empty = Pages you bookmark appear here. +subscriptions-title = Subscriptions +subscriptions-empty = Sites you subscribe to appear here. +subscriptions-new-one = 1 new post +subscriptions-new-many = { $count } new posts +settings-history = History +settings-bookmarks = Bookmarks +settings-subscriptions = Subscriptions + ## New posts # Shown when a subscribed site has new posts. Format the number in code. diff --git a/app/test/app_widget_test.dart b/app/test/app_widget_test.dart index 97a294a..8bf1be7 100644 --- a/app/test/app_widget_test.dart +++ b/app/test/app_widget_test.dart @@ -91,19 +91,21 @@ String _page(String title, String body) => ''' void main() { late MewsDatabase db; late AppDependencies deps; + late Strings strings; setUp(() async { db = MewsDatabase.test(NativeDatabase.memory()); - deps = AppDependencies(database: db); + strings = Strings(); + strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync()); + deps = AppDependencies(database: db, strings: strings); }); tearDown(() async { await db.close(); }); + /// Pumps the full app for [deps]; library pages get real strings. Future pumpApp(WidgetTester tester) async { - final strings = Strings(); - strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync()); await tester.pumpWidget(MewsApp(deps: deps, strings: strings)); await tester.pumpAndSettle(); } @@ -152,13 +154,16 @@ void main() { showReaderSettingsSheet(tester.element(find.byType(ReaderScreen))); await tester.pumpAndSettle(); - // Typeface, size, theme, colors. + // 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(); @@ -168,7 +173,6 @@ void main() { // Bookmark and subscribe the current page; both tiles sit at the bottom // of the sheet, so scroll the sheet down to the end first. - final sheet = find.byType(Scrollable).last; await tester.drag(sheet, const Offset(0, -1000)); await tester.pumpAndSettle(); await tester.tap(find.text('Add bookmark')); @@ -224,9 +228,8 @@ void main() { final stubDeps = AppDependencies( database: MewsDatabase.test(NativeDatabase.memory()), fetch: _FakeFetch(sources), + strings: strings, ); - final strings = Strings(); - strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync()); await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings)); await tester.pumpAndSettle(); @@ -235,10 +238,14 @@ void main() { // The swipe neighbor is parsed in the background. await _pumpUntil(tester, () => stubDeps.browser.backPage.value != null); + // Swipes begin at the screen edge; mid-page drags select text. Small + // steps keep the drag recognizer's update pacing in the test environment. final center = tester.getCenter(find.byType(SwipeNavigator)); - final gesture = await tester.startGesture(center); - await gesture.moveBy(const Offset(400, 0)); - await tester.pump(); + final gesture = await tester.startGesture(Offset(10, center.dy)); + for (var i = 0; i < 6; i++) { + await gesture.moveBy(const Offset(70, 0)); + await tester.pump(); + } // The real previous page is on screen mid-swipe, not a placeholder. expect(find.text('Page A'), findsOneWidget); @@ -253,6 +260,132 @@ 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( + database: MewsDatabase.test(NativeDatabase.memory()), + fetch: _FakeFetch({ + 'https://wap.example.com/': + _page('Wap site', '

Wap site

Content lives here.

'), + }), + strings: strings, + ); + await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings)); + await tester.pumpAndSettle(); + + // The address as typed has no page; the wap. equivalent does. + await stubDeps.browser.open('https://example.com/'); + expect(stubDeps.browser.currentPage.value?.url, 'https://wap.example.com/'); + await tester.pumpAndSettle(); + expect(find.textContaining('Content lives here'), findsOneWidget); + + // Addresses that work nowhere keep showing the error. + await stubDeps.browser.open('https://missing.example.org/'); + expect(stubDeps.browser.currentPage.value, isNull); + expect(stubDeps.browser.error.value, isA()); + }); + + testWidgets('library pages render as Mews pages and join the walk history', + (tester) async { + final stubDeps = AppDependencies( + database: MewsDatabase.test(NativeDatabase.memory()), + fetch: _FakeFetch({ + 'https://example.com/a.html': + _page('Page A', '

Page A

The first page.

'), + 'https://example.com/b.html': + _page('Page B', '

Page B

The second page.

'), + }), + strings: strings, + ); + await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings)); + await tester.pumpAndSettle(); + + await stubDeps.browser.open('https://example.com/a.html'); + await stubDeps.browser.open('https://example.com/b.html'); + await stubDeps.browser.open('mews:history'); + await tester.pumpAndSettle(); + + // Rendered like any Mews page, with dated links to both visits. + expect(stubDeps.browser.currentPage.value?.url, 'mews:history'); + expect(find.text('History'), findsOneWidget); + expect(find.textContaining('Page A'), findsOneWidget); + expect(find.textContaining('Page B'), findsOneWidget); + + // Part of the walk: back returns to page B, forward to history again. + await stubDeps.browser.back(); + await tester.pumpAndSettle(); + expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/b.html'); + + await stubDeps.browser.forward(); + await tester.pumpAndSettle(); + expect(stubDeps.browser.currentPage.value?.url, 'mews:history'); + + // A history link navigates like any other link. + await tester.tap(find.textContaining('Page A')); + await tester.pumpAndSettle(); + expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html'); + + // Bookmarks and subscriptions get the same treatment. + await stubDeps.library.load(); + await stubDeps.library.toggleBookmark('https://example.com/b.html', 'Page B'); + await stubDeps.library.toggleSubscription( + 'https://example.com/a.html', 'Page A', const []); + await stubDeps.browser.open('mews:bookmarks'); + await tester.pumpAndSettle(); + expect(find.text('Bookmarks'), findsOneWidget); + expect(find.textContaining('Page B'), findsOneWidget); + + await stubDeps.browser.open('mews:subscriptions'); + await tester.pumpAndSettle(); + expect(find.text('Subscriptions'), findsOneWidget); + 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/swipe_navigator_test.dart b/app/test/swipe_navigator_test.dart index b12ef65..17cc5cc 100644 --- a/app/test/swipe_navigator_test.dart +++ b/app/test/swipe_navigator_test.dart @@ -45,8 +45,9 @@ void main() { onForward: () => forward++, ))); + // Swipes begin at the screen edge; mid-page drags select text. final center = tester.getCenter(find.byType(SwipeNavigator)); - final gesture = await tester.startGesture(center); + final gesture = await tester.startGesture(Offset(20, center.dy)); await gesture.moveBy(const Offset(300, 0)); await tester.pump(); @@ -69,7 +70,7 @@ void main() { ))); final center = tester.getCenter(find.byType(SwipeNavigator)); - final gesture = await tester.startGesture(center); + final gesture = await tester.startGesture(Offset(780, center.dy)); await gesture.moveBy(const Offset(-300, 0)); await tester.pump(); expect(find.text('Next page'), findsOneWidget); @@ -89,7 +90,7 @@ void main() { ))); final center = tester.getCenter(find.byType(SwipeNavigator)); - final gesture = await tester.startGesture(center); + final gesture = await tester.startGesture(Offset(20, center.dy)); await gesture.moveBy(const Offset(80, 0)); await tester.pump(); await gesture.up(); @@ -112,7 +113,7 @@ void main() { ))); final center = tester.getCenter(find.byType(SwipeNavigator)); - final gesture = await tester.startGesture(center); + final gesture = await tester.startGesture(Offset(20, center.dy)); await gesture.moveBy(const Offset(400, 0)); await tester.pump(); // No neighbor title shows when there is nothing to go back to.