2026-10-11 22:32:44 +03:00
|
|
|
import 'dart:io' show Process;
|
|
|
|
|
|
2026-10-11 22:32:44 +03:00
|
|
|
import 'package:flutter/foundation.dart'
|
|
|
|
|
show defaultTargetPlatform, TargetPlatform;
|
2026-10-11 22:32:44 +03:00
|
|
|
import 'package:url_launcher/url_launcher.dart';
|
2026-10-11 09:33:25 +03:00
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter/rendering.dart' show ScrollDirection;
|
2026-10-11 22:32:44 +03:00
|
|
|
import 'package:flutter/services.dart' show LogicalKeyboardKey;
|
2026-10-11 09:33:25 +03:00
|
|
|
import 'package:signals/signals_flutter.dart' show SignalBuilder;
|
|
|
|
|
|
|
|
|
|
import '../../application/controllers/browser_controller.dart'
|
2026-10-11 22:07:25 +03:00
|
|
|
show
|
|
|
|
|
BrokenPage,
|
|
|
|
|
CertificateChanged,
|
|
|
|
|
ClientCertificateRequired,
|
|
|
|
|
LoadFailed,
|
|
|
|
|
ProtocolFailure,
|
|
|
|
|
RedirectLoop,
|
|
|
|
|
UnsupportedPage;
|
|
|
|
|
import '../../database/database.dart' show ClientCertificate;
|
2026-10-11 14:03:44 +03:00
|
|
|
import '../../application/pages/mews_urls.dart';
|
2026-10-11 09:33:25 +03:00
|
|
|
import '../../mews/document.dart';
|
|
|
|
|
import '../../mews/parser.dart' show mewsPlainText;
|
|
|
|
|
import '../app.dart';
|
|
|
|
|
import 'address_bar.dart';
|
|
|
|
|
import 'mews_page_view.dart';
|
|
|
|
|
import 'palettes.dart';
|
2026-10-11 10:38:50 +03:00
|
|
|
import 'swipe_navigator.dart';
|
2026-10-11 09:33:25 +03:00
|
|
|
|
|
|
|
|
/// The whole v1 UI: an address bar that hides on scroll, the page reader with
|
2026-10-11 12:51:18 +03:00
|
|
|
/// swipe back/forward, and error states that never guess.
|
2026-10-11 09:33:25 +03:00
|
|
|
class ReaderScreen extends StatefulWidget {
|
|
|
|
|
const ReaderScreen({super.key});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<ReaderScreen> createState() => _ReaderScreenState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _ReaderScreenState extends State<ReaderScreen> {
|
|
|
|
|
final _scroll = ScrollController();
|
2026-10-11 22:32:44 +03:00
|
|
|
final _addressFocus = FocusNode();
|
2026-10-11 10:28:54 +03:00
|
|
|
|
2026-10-11 12:14:32 +03:00
|
|
|
/// The address bar is on screen from the start, hidden while the reader
|
2026-10-11 22:32:44 +03:00
|
|
|
/// scrolls down, back on any upward scroll. A pointer host with a keyboard
|
|
|
|
|
/// has room to spare and keys to reach the bar, so it never hides.
|
2026-10-11 10:28:54 +03:00
|
|
|
bool _chromeVisible = true;
|
2026-10-11 09:33:25 +03:00
|
|
|
bool _showBrokenText = false;
|
|
|
|
|
final _checkedSubscriptions = <String>{};
|
|
|
|
|
|
2026-10-11 22:32:44 +03:00
|
|
|
/// Touch devices navigate with gestures; the others are pointer hosts
|
|
|
|
|
/// whose readers reach the chrome with the mouse and the keyboard.
|
|
|
|
|
static bool get _isTouch => switch (defaultTargetPlatform) {
|
|
|
|
|
TargetPlatform.android || TargetPlatform.iOS => true,
|
|
|
|
|
_ => false,
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_scroll.addListener(_onScroll);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_scroll.removeListener(_onScroll);
|
|
|
|
|
_scroll.dispose();
|
2026-10-11 22:32:44 +03:00
|
|
|
_addressFocus.dispose();
|
2026-10-11 09:33:25 +03:00
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _onScroll() {
|
2026-10-11 22:32:44 +03:00
|
|
|
// On a pointer host the bar stays on screen, so scrolling changes
|
|
|
|
|
// nothing about it.
|
|
|
|
|
if (!_isTouch) return;
|
2026-10-11 09:33:25 +03:00
|
|
|
if (!_scroll.hasClients) return;
|
|
|
|
|
final position = _scroll.position;
|
|
|
|
|
|
2026-10-11 12:51:18 +03:00
|
|
|
// The bar hides when the reader scrolls down and comes back on any
|
|
|
|
|
// upward scroll; it is always visible at the top of the page.
|
2026-10-11 10:28:54 +03:00
|
|
|
final wantVisible = position.pixels <= 0 ||
|
2026-10-11 09:33:25 +03:00
|
|
|
position.userScrollDirection == ScrollDirection.forward;
|
|
|
|
|
|
2026-10-11 10:28:54 +03:00
|
|
|
if (wantVisible != _chromeVisible) {
|
|
|
|
|
setState(() => _chromeVisible = wantVisible);
|
2026-10-11 09:33:25 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-10-11 12:14:32 +03:00
|
|
|
final deps = AppScope.of(context).deps;
|
2026-10-11 09:33:25 +03:00
|
|
|
|
2026-10-11 22:32:44 +03:00
|
|
|
final reader = Scaffold(
|
2026-10-11 09:33:25 +03:00
|
|
|
body: Column(
|
|
|
|
|
children: [
|
|
|
|
|
AnimatedSlide(
|
2026-10-11 10:28:54 +03:00
|
|
|
offset: _chromeVisible ? Offset.zero : const Offset(0, -1),
|
2026-10-11 09:33:25 +03:00
|
|
|
duration: const Duration(milliseconds: 200),
|
|
|
|
|
curve: Curves.easeOut,
|
|
|
|
|
child: AnimatedSize(
|
|
|
|
|
duration: const Duration(milliseconds: 200),
|
|
|
|
|
curve: Curves.easeOut,
|
|
|
|
|
alignment: Alignment.topCenter,
|
2026-10-11 10:28:54 +03:00
|
|
|
child: _chromeVisible
|
2026-10-11 22:32:44 +03:00
|
|
|
? AddressBar(focusNode: _addressFocus)
|
2026-10-11 09:33:25 +03:00
|
|
|
: const SizedBox(width: double.infinity),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: SignalBuilder(builder: (context) {
|
|
|
|
|
final browser = deps.browser;
|
|
|
|
|
final page = browser.currentPage.value;
|
2026-10-11 10:38:50 +03:00
|
|
|
// The swipe peek uses the current page's palette when there is
|
|
|
|
|
// one, so the neighbor slides in on matching paper.
|
|
|
|
|
final peekBackground = page == null
|
|
|
|
|
? Theme.of(context).scaffoldBackgroundColor
|
|
|
|
|
: paletteFor(
|
|
|
|
|
pageVariant: page.variant,
|
|
|
|
|
readerVariant: deps.settings.variant.value,
|
|
|
|
|
dark: Theme.of(context).brightness == Brightness.dark,
|
2026-10-11 22:32:44 +03:00
|
|
|
url: page.url,
|
2026-10-11 10:38:50 +03:00
|
|
|
).background;
|
2026-10-11 09:33:25 +03:00
|
|
|
|
2026-10-11 11:53:27 +03:00
|
|
|
// The 40em column plus its padding: swipes are accepted
|
|
|
|
|
// anywhere in the margin beside it.
|
|
|
|
|
final contentWidth =
|
|
|
|
|
42 * 17.0 * deps.settings.fontScale.value;
|
|
|
|
|
|
2026-10-11 10:38:50 +03:00
|
|
|
return SwipeNavigator(
|
|
|
|
|
canGoBack: browser.canGoBack,
|
|
|
|
|
canGoForward: browser.canGoForward,
|
2026-10-11 11:05:03 +03:00
|
|
|
backView: _neighborView(browser.backPage.value),
|
|
|
|
|
forwardView: _neighborView(browser.forwardPage.value),
|
|
|
|
|
backTitle: browser.backPage.value?.title,
|
|
|
|
|
forwardTitle: browser.forwardPage.value?.title,
|
2026-10-11 10:38:50 +03:00
|
|
|
background: peekBackground,
|
2026-10-11 11:53:27 +03:00
|
|
|
contentWidth: page == null ? null : contentWidth,
|
2026-10-11 10:38:50 +03:00
|
|
|
onBack: browser.back,
|
|
|
|
|
onForward: browser.forward,
|
2026-10-11 09:33:25 +03:00
|
|
|
child: Stack(
|
|
|
|
|
children: [
|
|
|
|
|
Positioned.fill(child: _body(context, page)),
|
|
|
|
|
if (browser.loading.value)
|
|
|
|
|
const Positioned(
|
|
|
|
|
top: 8,
|
|
|
|
|
left: 0,
|
|
|
|
|
right: 0,
|
|
|
|
|
child: LinearProgressIndicator(minHeight: 2),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-10-11 22:32:44 +03:00
|
|
|
|
|
|
|
|
// A pointer host drives the reader from the keyboard as well: the bar
|
|
|
|
|
// takes an address (Ctrl or Cmd+L, which selects what is in it), the page
|
|
|
|
|
// reloads (Ctrl or Cmd+R), and the walk goes back and forward
|
|
|
|
|
// (Alt+Left/Right, or Cmd+Left/Right and Cmd+[ and Cmd+] on a Mac).
|
|
|
|
|
// Touch hosts navigate with gestures instead.
|
|
|
|
|
if (_isTouch) return reader;
|
|
|
|
|
return CallbackShortcuts(
|
|
|
|
|
bindings: {
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.keyL, control: true):
|
|
|
|
|
_addressFocus.requestFocus,
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.keyL, meta: true):
|
|
|
|
|
_addressFocus.requestFocus,
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.keyR, control: true):
|
|
|
|
|
deps.browser.reload,
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.keyR, meta: true):
|
|
|
|
|
deps.browser.reload,
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true):
|
|
|
|
|
deps.browser.back,
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true):
|
|
|
|
|
deps.browser.forward,
|
|
|
|
|
// The same walk, with the keys a Mac reader reaches for.
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true):
|
|
|
|
|
deps.browser.back,
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true):
|
|
|
|
|
deps.browser.forward,
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.bracketLeft, meta: true):
|
|
|
|
|
deps.browser.back,
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.bracketRight, meta: true):
|
|
|
|
|
deps.browser.forward,
|
|
|
|
|
},
|
|
|
|
|
// Key events reach only the focused branch, and a session starts with
|
|
|
|
|
// nothing focused; this node gives them a place to arrive. Tapping the
|
|
|
|
|
// address field or a control moves the focus deeper in, where the
|
|
|
|
|
// shortcuts still apply.
|
|
|
|
|
child: Focus(autofocus: true, skipTraversal: true, child: reader),
|
|
|
|
|
);
|
2026-10-11 09:33:25 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _body(BuildContext context, MewsPage? page) {
|
|
|
|
|
final deps = AppScope.of(context).deps;
|
|
|
|
|
|
|
|
|
|
if (page == null) {
|
|
|
|
|
return SignalBuilder(builder: (context) {
|
|
|
|
|
final error = deps.browser.error.value;
|
|
|
|
|
return switch (error) {
|
|
|
|
|
BrokenPage(:final source) => _errorPage(context, source),
|
|
|
|
|
LoadFailed() => _errorPage(context, null),
|
2026-10-11 22:07:25 +03:00
|
|
|
// A protocol status carries the site's own words for what went
|
|
|
|
|
// wrong.
|
|
|
|
|
ProtocolFailure(:final message) =>
|
|
|
|
|
_plainErrorPage(context, 'error-protocol-failed', detail: message),
|
|
|
|
|
// A certificate request is a choice, not a failure: the reader
|
|
|
|
|
// presents one of their identities, or none. A refused or invalid
|
|
|
|
|
// certificate is a failure the reader cannot fix by choosing again.
|
|
|
|
|
ClientCertificateRequired(:final status, :final meta) => status == 60
|
|
|
|
|
? _identityPickerPage(context, meta)
|
|
|
|
|
: _plainErrorPage(
|
|
|
|
|
context,
|
|
|
|
|
status == 61
|
|
|
|
|
? 'error-certificate-not-authorized'
|
|
|
|
|
: 'error-certificate-invalid',
|
|
|
|
|
detail: meta),
|
|
|
|
|
RedirectLoop() => _plainErrorPage(context, 'error-redirect-loop'),
|
|
|
|
|
UnsupportedPage() => _plainErrorPage(context, 'error-unsupported-type'),
|
|
|
|
|
CertificateChanged() => _certificateErrorPage(context),
|
2026-10-11 09:33:25 +03:00
|
|
|
null => _emptyPage(context),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_checkSubscription(context, page);
|
|
|
|
|
_showBrokenText = false;
|
|
|
|
|
|
|
|
|
|
return SignalBuilder(builder: (context) {
|
|
|
|
|
final settings = deps.settings;
|
|
|
|
|
final font = settings.font.value;
|
|
|
|
|
final fontScale = settings.fontScale.value;
|
|
|
|
|
final variant = settings.variant.value;
|
|
|
|
|
final visited = deps.browser.visitedUrls.value;
|
|
|
|
|
|
|
|
|
|
final palette = paletteFor(
|
|
|
|
|
pageVariant: page.variant,
|
|
|
|
|
readerVariant: variant,
|
|
|
|
|
dark: Theme.of(context).brightness == Brightness.dark,
|
2026-10-11 22:32:44 +03:00
|
|
|
url: page.url,
|
2026-10-11 09:33:25 +03:00
|
|
|
);
|
|
|
|
|
return MewsPageView(
|
|
|
|
|
key: ValueKey(page.url),
|
|
|
|
|
page: page,
|
2026-10-11 14:03:44 +03:00
|
|
|
ownPage: MewsUrls.isInternal(page.url),
|
2026-10-11 09:33:25 +03:00
|
|
|
palette: palette,
|
|
|
|
|
fontFamily: font,
|
|
|
|
|
fontScale: fontScale,
|
|
|
|
|
visitedUrls: visited,
|
|
|
|
|
onLinkTap: (url) => deps.browser.open(url),
|
|
|
|
|
onFormSubmit: (form, values) =>
|
|
|
|
|
deps.browser.submitForm(form.action, form.method, values),
|
2026-10-11 22:07:25 +03:00
|
|
|
fetchImageBytes: deps.browser.fetchImageBytes,
|
2026-10-11 09:33:25 +03:00
|
|
|
scrollController: _scroll,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 11:05:03 +03:00
|
|
|
/// Renders a neighboring page for the swipe peek, non-interactive: it is a
|
|
|
|
|
/// look ahead, and committing the navigation brings the real thing.
|
|
|
|
|
Widget? _neighborView(MewsPage? page) {
|
|
|
|
|
if (page == null) return null;
|
|
|
|
|
final deps = AppScope.of(context).deps;
|
|
|
|
|
final settings = deps.settings;
|
|
|
|
|
final palette = paletteFor(
|
|
|
|
|
pageVariant: page.variant,
|
|
|
|
|
readerVariant: settings.variant.value,
|
|
|
|
|
dark: Theme.of(context).brightness == Brightness.dark,
|
2026-10-11 22:32:44 +03:00
|
|
|
url: page.url,
|
2026-10-11 11:05:03 +03:00
|
|
|
);
|
|
|
|
|
return ColoredBox(
|
|
|
|
|
color: palette.background,
|
|
|
|
|
child: MewsPageView(
|
|
|
|
|
page: page,
|
2026-10-11 14:03:44 +03:00
|
|
|
ownPage: MewsUrls.isInternal(page.url),
|
2026-10-11 11:05:03 +03:00
|
|
|
palette: palette,
|
|
|
|
|
fontFamily: settings.font.value,
|
|
|
|
|
fontScale: settings.fontScale.value,
|
|
|
|
|
visitedUrls: deps.browser.visitedUrls.value,
|
|
|
|
|
onLinkTap: (_) {},
|
|
|
|
|
onFormSubmit: (_, _) {},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
Widget _emptyPage(BuildContext context) {
|
|
|
|
|
// No page yet: the address bar is the whole UI.
|
|
|
|
|
return const SizedBox.expand();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:07:25 +03:00
|
|
|
/// An error page that says [messageId], optionally the site's own words
|
|
|
|
|
/// under it, and offers to try again.
|
|
|
|
|
Widget _plainErrorPage(BuildContext context, String messageId, {String? detail}) {
|
|
|
|
|
final strings = AppScope.of(context).strings;
|
|
|
|
|
final deps = AppScope.of(context).deps;
|
|
|
|
|
return Center(
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.all(24),
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
Text(strings.t(messageId), textAlign: TextAlign.center),
|
|
|
|
|
if (detail != null && detail.isNotEmpty) ...[
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
Text(detail, textAlign: TextAlign.center),
|
|
|
|
|
],
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
FilledButton(
|
|
|
|
|
onPressed: () => deps.browser.reload(),
|
|
|
|
|
child: Text(strings.t('error-try-again')),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The choice a certificate request asks for: one of the reader's
|
|
|
|
|
/// identities, presented from now on to the site that asked.
|
|
|
|
|
Widget _identityPickerPage(BuildContext context, String meta) {
|
|
|
|
|
final strings = AppScope.of(context).strings;
|
|
|
|
|
final deps = AppScope.of(context).deps;
|
|
|
|
|
return FutureBuilder<List<ClientCertificate>>(
|
|
|
|
|
future: deps.db.allClientCertificates(),
|
|
|
|
|
builder: (context, snapshot) {
|
|
|
|
|
final held = snapshot.data ?? const <ClientCertificate>[];
|
|
|
|
|
return Center(
|
|
|
|
|
child: SingleChildScrollView(
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.all(24),
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
Text(strings.t('certificates-choose'), textAlign: TextAlign.center),
|
|
|
|
|
if (meta.isNotEmpty) ...[
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
Text(meta, textAlign: TextAlign.center),
|
|
|
|
|
],
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
if (held.isEmpty)
|
|
|
|
|
FilledButton(
|
|
|
|
|
onPressed: () => deps.browser.open(MewsUrls.certificates),
|
|
|
|
|
child: Text(strings.t('certificates-add')),
|
|
|
|
|
)
|
|
|
|
|
else
|
|
|
|
|
for (final certificate in held)
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
|
|
|
child: FilledButton.tonal(
|
|
|
|
|
onPressed: () => deps.browser.chooseCertificate(certificate.id),
|
|
|
|
|
child: Text(certificate.name),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
OutlinedButton(
|
|
|
|
|
onPressed: () => deps.browser.reload(),
|
|
|
|
|
child: Text(strings.t('error-try-again')),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A changed certificate is the reader's choice to make or refuse, so the
|
|
|
|
|
/// error page carries both: trust what was presented, or try again.
|
|
|
|
|
Widget _certificateErrorPage(BuildContext context) {
|
|
|
|
|
final strings = AppScope.of(context).strings;
|
|
|
|
|
final deps = AppScope.of(context).deps;
|
|
|
|
|
return Center(
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.all(24),
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
Text(strings.t('error-certificate-changed'), textAlign: TextAlign.center),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
FilledButton(
|
|
|
|
|
onPressed: () => deps.browser.trustPresentedCertificate(),
|
|
|
|
|
child: Text(strings.t('error-certificate-trust')),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
OutlinedButton(
|
|
|
|
|
onPressed: () => deps.browser.reload(),
|
|
|
|
|
child: Text(strings.t('error-try-again')),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:32:44 +03:00
|
|
|
/// Hands [url] to the system's web browser. The reader does not watch
|
|
|
|
|
/// what happens to it there, and a system with no browser answers nothing
|
|
|
|
|
/// rather than failing here.
|
|
|
|
|
///
|
|
|
|
|
/// The launcher asks GIO on Linux, which can fail to see the session's
|
|
|
|
|
/// browser from the environment the app runs in, and a binary built before
|
|
|
|
|
/// the launcher was added answers not at all; in both cases xdg-open asks
|
|
|
|
|
/// the session directly.
|
|
|
|
|
Future<void> _openOutside(String url) async {
|
|
|
|
|
final uri = Uri.tryParse(url);
|
|
|
|
|
if (uri == null) return;
|
|
|
|
|
|
|
|
|
|
var opened = false;
|
|
|
|
|
try {
|
|
|
|
|
opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
|
|
|
} on Exception {
|
|
|
|
|
// No answer; the fallback below asks the session itself.
|
|
|
|
|
}
|
|
|
|
|
if (opened) return;
|
|
|
|
|
|
|
|
|
|
if (defaultTargetPlatform == TargetPlatform.linux) {
|
|
|
|
|
final result = await Process.run('xdg-open', [url]);
|
|
|
|
|
// A missing xdg-open or a session without a browser answers with an
|
|
|
|
|
// error; nothing more to do but stay where we are.
|
|
|
|
|
if (result.exitCode != 0) return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
Widget _errorPage(BuildContext context, String? brokenSource) {
|
|
|
|
|
final strings = AppScope.of(context).strings;
|
|
|
|
|
final deps = AppScope.of(context).deps;
|
|
|
|
|
|
2026-10-11 22:32:44 +03:00
|
|
|
// A web address can be shown by the system's browser, so a page that
|
|
|
|
|
// will not load here can leave; a protocol page has nowhere else to go,
|
|
|
|
|
// so only trying again is offered there.
|
|
|
|
|
final failed = deps.browser.currentAddress;
|
|
|
|
|
final failedScheme = Uri.tryParse(failed)?.scheme;
|
|
|
|
|
final canOpenOutside =
|
|
|
|
|
brokenSource == null && (failedScheme == 'http' || failedScheme == 'https');
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
Widget content;
|
|
|
|
|
if (brokenSource == null) {
|
|
|
|
|
content = Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
Text(strings.t('error-load-failed'), textAlign: TextAlign.center),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
FilledButton(
|
|
|
|
|
onPressed: () => deps.browser.reload(),
|
|
|
|
|
child: Text(strings.t('error-try-again')),
|
|
|
|
|
),
|
2026-10-11 22:32:44 +03:00
|
|
|
if (canOpenOutside) ...[
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
OutlinedButton(
|
|
|
|
|
onPressed: () => _openOutside(failed),
|
|
|
|
|
child: Text(strings.t('error-open-in-browser')),
|
|
|
|
|
),
|
|
|
|
|
],
|
2026-10-11 09:33:25 +03:00
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
} else if (_showBrokenText) {
|
|
|
|
|
content = Column(
|
|
|
|
|
children: [
|
|
|
|
|
Text(strings.t('error-broken-page-text-title'),
|
|
|
|
|
style: Theme.of(context).textTheme.titleMedium),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
Expanded(child: SingleChildScrollView(child: Text(mewsPlainText(brokenSource)))),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
content = Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
Text(strings.t('error-broken-page'), textAlign: TextAlign.center),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
FilledButton(
|
|
|
|
|
onPressed: () => setState(() => _showBrokenText = true),
|
|
|
|
|
child: Text(strings.t('error-broken-page-show-text')),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Center(
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.all(24),
|
|
|
|
|
child: content,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
/// Records the posts of a subscribed page once per page load, so what is new
|
|
|
|
|
/// shows up on the subscriptions and posts pages (spec 6.1). The controller
|
|
|
|
|
/// no-ops for a page the reader is not subscribed to.
|
2026-10-11 09:33:25 +03:00
|
|
|
void _checkSubscription(BuildContext context, MewsPage page) {
|
|
|
|
|
if (_checkedSubscriptions.contains(page.url)) return;
|
|
|
|
|
_checkedSubscriptions.add(page.url);
|
|
|
|
|
final deps = AppScope.of(context).deps;
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
2026-10-11 13:12:48 +03:00
|
|
|
if (mounted) deps.subscriptions.refreshSite(page);
|
2026-10-11 09:33:25 +03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|