feat: theme chrome by palette, selectable text, wap fallback, and library pages
This commit is contained in:
parent
c104c2026b
commit
56f3d959fa
12 changed files with 534 additions and 51 deletions
|
|
@ -5,6 +5,7 @@ import 'package:signals/signals.dart';
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
import '../../mews/document.dart';
|
import '../../mews/document.dart';
|
||||||
import '../actors/fetch_actor.dart';
|
import '../actors/fetch_actor.dart';
|
||||||
|
import '../internal_pages.dart';
|
||||||
|
|
||||||
/// What went wrong while browsing. The presentation layer maps these to
|
/// What went wrong while browsing. The presentation layer maps these to
|
||||||
/// user-facing messages; no raw codes cross into the UI.
|
/// 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
|
/// dispatches fetch commands to the fetch actor. Local-first: a cached page is
|
||||||
/// revalidated with the server but survives any network failure.
|
/// revalidated with the server but survives any network failure.
|
||||||
class BrowserController {
|
class BrowserController {
|
||||||
BrowserController({required MewsDatabase db, required Actor<FetchCommand, FetchResult> fetch})
|
BrowserController({
|
||||||
: _db = db,
|
required MewsDatabase db,
|
||||||
_fetch = fetch;
|
required Actor<FetchCommand, FetchResult> fetch,
|
||||||
|
InternalPages? internalPages,
|
||||||
|
}) : _db = db,
|
||||||
|
_fetch = fetch,
|
||||||
|
_internalPages = internalPages;
|
||||||
|
|
||||||
final MewsDatabase _db;
|
final MewsDatabase _db;
|
||||||
final Actor<FetchCommand, FetchResult> _fetch;
|
final Actor<FetchCommand, FetchResult> _fetch;
|
||||||
|
final InternalPages? _internalPages;
|
||||||
|
|
||||||
/// The stack of visited pages; the reader is at [_stackIndex].
|
/// The stack of visited pages; the reader is at [_stackIndex].
|
||||||
final List<String> _stack = [];
|
final List<String> _stack = [];
|
||||||
|
|
@ -54,14 +60,27 @@ class BrowserController {
|
||||||
bool get canGoForward => _stackIndex >= 0 && _stack.length - 1 > _stackIndex;
|
bool get canGoForward => _stackIndex >= 0 && _stack.length - 1 > _stackIndex;
|
||||||
|
|
||||||
/// Normalizes what the reader typed into an address: bare domains become
|
/// 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) {
|
static String normalizeAddress(String input) {
|
||||||
final trimmed = input.trim();
|
final trimmed = input.trim();
|
||||||
if (trimmed.isEmpty) return '';
|
if (trimmed.isEmpty) return '';
|
||||||
if (trimmed.contains('://')) return trimmed;
|
if (_schemePattern.hasMatch(trimmed)) return trimmed;
|
||||||
return 'https://$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<void> open(String input) async {
|
Future<void> open(String input) async {
|
||||||
final url = normalizeAddress(input);
|
final url = normalizeAddress(input);
|
||||||
if (url.isEmpty) return;
|
if (url.isEmpty) return;
|
||||||
|
|
@ -70,6 +89,23 @@ class BrowserController {
|
||||||
_stackIndex++;
|
_stackIndex++;
|
||||||
_updateNeighbors();
|
_updateNeighbors();
|
||||||
await _loadCurrent();
|
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<void> back() async {
|
Future<void> back() async {
|
||||||
|
|
@ -142,6 +178,10 @@ class BrowserController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final url = _stack[index];
|
final url = _stack[index];
|
||||||
|
if (_internalPages?.handles(url) ?? false) {
|
||||||
|
target.value = await _internalPages!.pageFor(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final cached = await _db.cachedPage(url);
|
final cached = await _db.cachedPage(url);
|
||||||
if (_stackIndex != expectedIndex) return;
|
if (_stackIndex != expectedIndex) return;
|
||||||
if (cached == null) {
|
if (cached == null) {
|
||||||
|
|
@ -159,6 +199,22 @@ class BrowserController {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
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 cached = await _db.cachedPage(url);
|
||||||
final result = await _fetch.send(FetchPage(
|
final result = await _fetch.send(FetchPage(
|
||||||
url: url,
|
url: url,
|
||||||
|
|
|
||||||
165
app/lib/application/internal_pages.dart
Normal file
165
app/lib/application/internal_pages.dart
Normal file
|
|
@ -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<MewsPage?> 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<MewsPage> _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<MewsPage> _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<MewsPage> _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 = <MewsBlock>[
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -91,6 +91,27 @@ class MewsDatabase extends _$MewsDatabase {
|
||||||
return row?.url;
|
return row?.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The most recent external visits, newest first.
|
||||||
|
Future<List<HistoryItem>> 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<List<Bookmark>> bookmarksByDate() async {
|
||||||
|
final query = (select(bookmarks)
|
||||||
|
..orderBy([
|
||||||
|
(r) => OrderingTerm.desc(r.createdAt),
|
||||||
|
(r) => OrderingTerm.desc(r.id),
|
||||||
|
]));
|
||||||
|
return query.get();
|
||||||
|
}
|
||||||
|
|
||||||
// Bookmarks -----------------------------------------------------------
|
// Bookmarks -----------------------------------------------------------
|
||||||
|
|
||||||
Future<List<Bookmark>> allBookmarks() => select(bookmarks).get();
|
Future<List<Bookmark>> allBookmarks() => select(bookmarks).get();
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ Future<void> main() async {
|
||||||
final strings = Strings();
|
final strings = Strings();
|
||||||
await strings.load();
|
await strings.load();
|
||||||
|
|
||||||
final deps = AppDependencies();
|
final deps = AppDependencies(strings: strings);
|
||||||
await deps.start();
|
await deps.start();
|
||||||
|
|
||||||
runApp(MewsApp(deps: deps, strings: strings));
|
runApp(MewsApp(deps: deps, strings: strings));
|
||||||
|
|
|
||||||
|
|
@ -5,26 +5,38 @@ import 'package:signals/signals_flutter.dart' show SignalBuilder;
|
||||||
import '../application/actors/fetch_actor.dart';
|
import '../application/actors/fetch_actor.dart';
|
||||||
import '../application/controllers/browser_controller.dart';
|
import '../application/controllers/browser_controller.dart';
|
||||||
import '../application/controllers/reader_settings_controller.dart';
|
import '../application/controllers/reader_settings_controller.dart';
|
||||||
|
import '../application/internal_pages.dart';
|
||||||
import '../database/database.dart';
|
import '../database/database.dart';
|
||||||
import '../l10n/strings.dart';
|
import '../l10n/strings.dart';
|
||||||
|
import '../mews/document.dart';
|
||||||
|
import 'reader/palettes.dart';
|
||||||
import 'reader/reader_screen.dart';
|
import 'reader/reader_screen.dart';
|
||||||
/// Wires the app's dependencies together and provides them to the tree.
|
/// 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
|
/// Everything is local-first: the database is created up front and the fetch
|
||||||
/// actor is the only path to the network.
|
/// actor is the only path to the network.
|
||||||
class AppDependencies {
|
class AppDependencies {
|
||||||
AppDependencies({MewsDatabase? database, Actor<FetchCommand, FetchResult>? fetch})
|
AppDependencies({
|
||||||
: db = database ?? MewsDatabase(),
|
MewsDatabase? database,
|
||||||
fetch = fetch ?? Actor.create(FetchHandler.new) {
|
Actor<FetchCommand, FetchResult>? fetch,
|
||||||
browser = BrowserController(db: db, fetch: this.fetch);
|
Strings? strings,
|
||||||
|
}) : db = database ?? MewsDatabase(),
|
||||||
|
fetch = fetch ?? Actor.create(FetchHandler.new),
|
||||||
|
strings = strings ?? Strings() {
|
||||||
settings = ReaderSettingsController(db);
|
settings = ReaderSettingsController(db);
|
||||||
library = LibraryController(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 MewsDatabase db;
|
||||||
final Actor<FetchCommand, FetchResult> fetch;
|
final Actor<FetchCommand, FetchResult> fetch;
|
||||||
late final BrowserController browser;
|
late final BrowserController browser;
|
||||||
late final ReaderSettingsController settings;
|
late final ReaderSettingsController settings;
|
||||||
late final LibraryController library;
|
late final LibraryController library;
|
||||||
|
late final InternalPages internalPages;
|
||||||
|
|
||||||
Future<void> start() async {
|
Future<void> start() async {
|
||||||
await settings.load();
|
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 {
|
class _ThemedApp extends StatelessWidget {
|
||||||
const _ThemedApp();
|
const _ThemedApp();
|
||||||
|
|
||||||
|
|
@ -82,21 +96,55 @@ class _ThemedApp extends StatelessWidget {
|
||||||
'dark' => ThemeMode.dark,
|
'dark' => ThemeMode.dark,
|
||||||
_ => ThemeMode.system,
|
_ => 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(
|
return MaterialApp(
|
||||||
title: 'Mews',
|
title: 'Mews',
|
||||||
themeMode: mode,
|
themeMode: mode,
|
||||||
theme: ThemeData(
|
theme: _themeFor(paletteFor(pageVariant: variant, readerVariant: 'site', dark: false)),
|
||||||
useMaterial3: true,
|
darkTheme: _themeFor(paletteFor(pageVariant: variant, readerVariant: 'site', dark: 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),
|
|
||||||
),
|
|
||||||
home: const ReaderScreen(),
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,9 @@ class _MewsPageViewState extends State<MewsPageView> {
|
||||||
textDirection: widget.page.direction == TextDirection.rtl
|
textDirection: widget.page.direction == TextDirection.rtl
|
||||||
? ui.TextDirection.rtl
|
? ui.TextDirection.rtl
|
||||||
: ui.TextDirection.ltr,
|
: 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,
|
color: palette.background,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
controller: widget.scrollController,
|
controller: widget.scrollController,
|
||||||
|
|
@ -94,6 +96,7 @@ class _MewsPageViewState extends State<MewsPageView> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ const _monoDark = MewsPalette(
|
||||||
/// Resolves the palette: the reader's variant choice overrides the page's own
|
/// Resolves the palette: the reader's variant choice overrides the page's own
|
||||||
/// variant unless the choice is "site" (spec 5.3, 8.2).
|
/// variant unless the choice is "site" (spec 5.3, 8.2).
|
||||||
MewsPalette paletteFor({required MewsVariant pageVariant, required String readerVariant, required bool dark}) {
|
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)) {
|
return switch ((variant, dark)) {
|
||||||
(MewsVariant.warm, false) => _warmLight,
|
(MewsVariant.warm, false) => _warmLight,
|
||||||
(MewsVariant.warm, true) => _warmDark,
|
(MewsVariant.warm, true) => _warmDark,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart' show SignalBuilder;
|
import 'package:signals/signals_flutter.dart' show SignalBuilder;
|
||||||
|
|
||||||
import '../../application/controllers/reader_settings_controller.dart';
|
import '../../application/controllers/reader_settings_controller.dart';
|
||||||
|
import '../../application/internal_pages.dart';
|
||||||
import '../app.dart';
|
import '../app.dart';
|
||||||
import 'reader_fonts.dart';
|
import 'reader_fonts.dart';
|
||||||
|
|
||||||
|
|
@ -44,6 +45,24 @@ class SettingsSheet extends StatelessWidget {
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
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')),
|
_heading(context, strings.t('settings-font-heading')),
|
||||||
RadioGroup<String>(
|
RadioGroup<String>(
|
||||||
groupValue: settings.font.value,
|
groupValue: settings.font.value,
|
||||||
|
|
@ -125,7 +144,7 @@ class SettingsSheet extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
if (url != null) ...[
|
if (url != null && !InternalPages.isInternal(url)) ...[
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(library.isBookmarked(url) ? Icons.star : Icons.star_border),
|
leading: Icon(library.isBookmarked(url) ? Icons.star : Icons.star_border),
|
||||||
title: Text(
|
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(
|
Widget _heading(BuildContext context, String text) => Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,10 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
|
||||||
|
|
||||||
bool get _settledAnimating => _settling != null && _settle.isAnimating;
|
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) {
|
void _onDragStart(DragStartDetails details) {
|
||||||
if (_settledAnimating) return;
|
if (_settledAnimating) return;
|
||||||
setState(() => _dragging = true);
|
setState(() => _dragging = true);
|
||||||
|
|
@ -161,24 +165,37 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return GestureDetector(
|
return Stack(
|
||||||
behavior: HitTestBehavior.deferToChild,
|
children: [
|
||||||
onHorizontalDragStart: _onDragStart,
|
if (peek != null) Positioned.fill(child: peek),
|
||||||
onHorizontalDragUpdate: _onDragUpdate,
|
Transform.translate(
|
||||||
onHorizontalDragEnd: _onDragEnd,
|
offset: Offset(dx, 0),
|
||||||
child: Stack(
|
child: widget.child,
|
||||||
children: [
|
),
|
||||||
if (peek != null) Positioned.fill(child: peek),
|
// Only horizontal drags that begin at a screen edge navigate;
|
||||||
Transform.translate(
|
// the strips are translucent, so taps, vertical scrolling and
|
||||||
offset: Offset(dx, 0),
|
// text selection still pass through everywhere.
|
||||||
child: widget.child,
|
_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.
|
/// The neighboring page's fallback panel while it is still being parsed.
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,21 @@ settings-subscribe = Subscribe to this site
|
||||||
settings-unsubscribe = Stop subscribing
|
settings-unsubscribe = Stop subscribing
|
||||||
settings-subscribed-notice = New posts from this site appear here.
|
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
|
## New posts
|
||||||
|
|
||||||
# Shown when a subscribed site has new posts. Format the number in code.
|
# Shown when a subscribed site has new posts. Format the number in code.
|
||||||
|
|
|
||||||
|
|
@ -91,19 +91,21 @@ String _page(String title, String body) => '''
|
||||||
void main() {
|
void main() {
|
||||||
late MewsDatabase db;
|
late MewsDatabase db;
|
||||||
late AppDependencies deps;
|
late AppDependencies deps;
|
||||||
|
late Strings strings;
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
db = MewsDatabase.test(NativeDatabase.memory());
|
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 {
|
tearDown(() async {
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Pumps the full app for [deps]; library pages get real strings.
|
||||||
Future<void> pumpApp(WidgetTester tester) async {
|
Future<void> 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.pumpWidget(MewsApp(deps: deps, strings: strings));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
@ -152,13 +154,16 @@ void main() {
|
||||||
showReaderSettingsSheet(tester.element(find.byType(ReaderScreen)));
|
showReaderSettingsSheet(tester.element(find.byType(ReaderScreen)));
|
||||||
await tester.pumpAndSettle();
|
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.tap(find.text('Atkinson Hyperlegible'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byIcon(Icons.add));
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.scrollUntilVisible(find.text('Dark'), 200, scrollable: sheet);
|
||||||
await tester.tap(find.text('Dark'));
|
await tester.tap(find.text('Dark'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.scrollUntilVisible(find.text('Warm'), 200, scrollable: sheet);
|
||||||
await tester.tap(find.text('Warm'));
|
await tester.tap(find.text('Warm'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|
@ -168,7 +173,6 @@ void main() {
|
||||||
|
|
||||||
// Bookmark and subscribe the current page; both tiles sit at the bottom
|
// Bookmark and subscribe the current page; both tiles sit at the bottom
|
||||||
// of the sheet, so scroll the sheet down to the end first.
|
// 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.drag(sheet, const Offset(0, -1000));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Add bookmark'));
|
await tester.tap(find.text('Add bookmark'));
|
||||||
|
|
@ -224,9 +228,8 @@ void main() {
|
||||||
final stubDeps = AppDependencies(
|
final stubDeps = AppDependencies(
|
||||||
database: MewsDatabase.test(NativeDatabase.memory()),
|
database: MewsDatabase.test(NativeDatabase.memory()),
|
||||||
fetch: _FakeFetch(sources),
|
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.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|
@ -235,10 +238,14 @@ void main() {
|
||||||
// The swipe neighbor is parsed in the background.
|
// The swipe neighbor is parsed in the background.
|
||||||
await _pumpUntil(tester, () => stubDeps.browser.backPage.value != null);
|
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 center = tester.getCenter(find.byType(SwipeNavigator));
|
||||||
final gesture = await tester.startGesture(center);
|
final gesture = await tester.startGesture(Offset(10, center.dy));
|
||||||
await gesture.moveBy(const Offset(400, 0));
|
for (var i = 0; i < 6; i++) {
|
||||||
await tester.pump();
|
await gesture.moveBy(const Offset(70, 0));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
// The real previous page is on screen mid-swipe, not a placeholder.
|
// The real previous page is on screen mid-swipe, not a placeholder.
|
||||||
expect(find.text('Page A'), findsOneWidget);
|
expect(find.text('Page A'), findsOneWidget);
|
||||||
|
|
@ -253,6 +260,132 @@ void main() {
|
||||||
expect(find.text('https://example.com/a.html'), findsOneWidget);
|
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<MaterialApp>(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', '<h1>Wap site</h1><p>Content lives here.</p>'),
|
||||||
|
}),
|
||||||
|
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<LoadFailed>());
|
||||||
|
});
|
||||||
|
|
||||||
|
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', '<h1>Page A</h1><p>The first page.</p>'),
|
||||||
|
'https://example.com/b.html':
|
||||||
|
_page('Page B', '<h1>Page B</h1><p>The second page.</p>'),
|
||||||
|
}),
|
||||||
|
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 {
|
testWidgets('reports new posts once per page', (tester) async {
|
||||||
await pumpApp(tester);
|
await pumpApp(tester);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,9 @@ void main() {
|
||||||
onForward: () => forward++,
|
onForward: () => forward++,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
|
// Swipes begin at the screen edge; mid-page drags select text.
|
||||||
final center = tester.getCenter(find.byType(SwipeNavigator));
|
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 gesture.moveBy(const Offset(300, 0));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
|
|
@ -69,7 +70,7 @@ void main() {
|
||||||
)));
|
)));
|
||||||
|
|
||||||
final center = tester.getCenter(find.byType(SwipeNavigator));
|
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 gesture.moveBy(const Offset(-300, 0));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('Next page'), findsOneWidget);
|
expect(find.text('Next page'), findsOneWidget);
|
||||||
|
|
@ -89,7 +90,7 @@ void main() {
|
||||||
)));
|
)));
|
||||||
|
|
||||||
final center = tester.getCenter(find.byType(SwipeNavigator));
|
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 gesture.moveBy(const Offset(80, 0));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await gesture.up();
|
await gesture.up();
|
||||||
|
|
@ -112,7 +113,7 @@ void main() {
|
||||||
)));
|
)));
|
||||||
|
|
||||||
final center = tester.getCenter(find.byType(SwipeNavigator));
|
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 gesture.moveBy(const Offset(400, 0));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
// No neighbor title shows when there is nothing to go back to.
|
// No neighbor title shows when there is nothing to go back to.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue