Files
plezy/lib/widgets/pending_auth_dialog.dart
edde746 5c08862194 fix(trackers): purge queued writes on disconnect, evict stale anime snapshots, and cancel dismissed auth dialogs
Queued tracker writes carried no account identity, so rows created under account A replayed against account B after a reconnect; explicit disconnect now purges that service's queue. An in-flight markWatched/reconcile that failed after the purge was re-enqueued anyway (the write scope only tracked profile generation) and replayed into whichever account connected next: Tracker.accountBinding (client identity, rebound synchronously on every disconnect) is captured before each write and re-checked before queueing a retry, and because the check sits in the same synchronous segment as the enqueue, a row that passes it is claimed ahead of the purge in the queue mutex and is still removed by it.

The memoized anime-list snapshot survived writes, so sequential rewatch updates computed from pre-write state; successful writes now evict the entry.

A device-code dialog dismissed by system back never cancelled the poll (blocking new attempts until the deadline), and a user-cancelled sign-in showed the connection-failed snackbar; both now route through one cancel path.
2026-08-21 19:23:41 +02:00

250 lines
8.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_wrapper.dart';
import '../i18n/strings.g.dart';
import '../utils/platform_detector.dart';
import '../utils/snackbar_helper.dart';
import 'app_icon.dart';
import 'dialog_action_button.dart';
import 'loading_indicator_box.dart';
/// Shell for the "waiting for out-of-band authorization" dialogs.
///
/// Every flow gets the same affordances: a QR code for [url], a readable
/// copyable URL underneath it, the service-specific [children], a button that
/// launches [url] in the browser (hidden on Apple TV, which has none), and a
/// "waiting for authorization…" spinner while the poll loop runs.
///
/// On wide viewports (TV, desktop, phone landscape) the QR pane sits beside
/// the instructions so the dialog stays short enough for TV screens — tvOS
/// renders at 540 logical pixels of height, where the stacked layout clips.
/// Every dismissal — the Cancel button, a system back, or a programmatic pop —
/// routes through one [PopScope], which calls [onCancel] exactly once per pop
/// so the provider can abort the poll.
class PendingAuthDialog extends StatelessWidget {
final String title;
final String body;
/// Sits between the body text and the launch button, and carries its own
/// trailing spacing.
final List<Widget> children;
/// Encoded in the QR code, opened by the browser button, and copied by the
/// URL tap region.
final String url;
/// Short human-readable form of [url] shown under the QR code. Defaults to
/// [url]; the scheme is stripped for display either way.
final String? displayUrl;
final String openLabel;
/// Invoked when the dialog's route pops, whatever popped it. Callers that
/// pop the dialog themselves after the flow resolves must make this a no-op
/// for that pop (see `launchTrackerConnect`).
final VoidCallback onCancel;
const PendingAuthDialog({
super.key,
required this.title,
required this.body,
required this.children,
required this.url,
this.displayUrl,
required this.openLabel,
required this.onCancel,
});
Future<void> _open() async {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
Future<void> _copyUrl(BuildContext context) async {
await Clipboard.setData(ClipboardData(text: url));
if (!context.mounted) return;
showAppSnackBar(context, t.services.pendingAuth.urlCopied);
}
String get _urlDisplayText {
var text = displayUrl ?? url;
text = text.replaceFirst(RegExp('^https?://'), '');
while (text.endsWith('/')) {
text = text.substring(0, text.length - 1);
}
return text;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final screen = MediaQuery.sizeOf(context);
// Side-by-side on anything wide enough (TV, desktop, phone landscape);
// stacked on portrait phones.
final wide = screen.width >= 600;
final qrSize = wide ? (screen.height * 0.45).clamp(160.0, 220.0) : (screen.width - 160).clamp(140.0, 200.0);
final qr = SizedBox.square(
dimension: qrSize,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: QrImageView(data: url, size: qrSize, version: QrVersions.auto, backgroundColor: Colors.white),
),
);
Widget urlChip({required TextAlign textAlign}) => CopyTapRegion(
onCopy: () => _copyUrl(context),
semanticLabel: t.services.pendingAuth.copyUrl,
semanticValue: url,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text(
_urlDisplayText,
style: theme.textTheme.titleMedium?.copyWith(fontFamily: 'monospace', fontWeight: .w600),
textAlign: textAlign,
),
),
);
final bodyText = Text(body, style: theme.textTheme.bodyMedium);
// tvOS has no browser, so a launch button would be a dead focus target.
final openButton = PlatformDetector.isAppleTV()
? null
: SizedBox(
width: double.infinity,
child: FocusableButton(
onPressed: _open,
useBackgroundFocus: true,
child: FilledButton.icon(
icon: const AppIcon(Symbols.open_in_new_rounded),
label: Text(openLabel),
onPressed: _open,
),
),
);
final waitingRow = Row(
children: [
const LoadingIndicatorBox(size: 16),
const SizedBox(width: 12),
Expanded(child: Text(t.services.deviceCode.waitingForAuthorization, style: theme.textTheme.bodySmall)),
],
);
return PopScope(
// The route still pops normally; this only observes the pop, so the
// Cancel button below and a system back share one cancel invocation.
onPopInvokedWithResult: (didPop, _) {
if (didPop) onCancel();
},
child: AlertDialog(
scrollable: true,
title: Text(title),
// Wide: the QR fills the left pane and everything textual lives in the
// right column — body at the QR's top edge, button/spinner at its bottom
// edge, the URL above the button, and any service extras (activation
// code) centered in the remaining space. Both flows fill their column,
// so neither pane floats in empty space.
content: wide
? IntrinsicHeight(
child: Row(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
Column(mainAxisSize: .min, mainAxisAlignment: .center, children: [qr]),
const SizedBox(width: 28),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Column(
crossAxisAlignment: .stretch,
children: [
bodyText,
Expanded(
child: children.isEmpty
? const SizedBox.shrink()
: Column(mainAxisAlignment: .center, children: children),
),
Align(
alignment: .centerLeft,
child: urlChip(textAlign: TextAlign.start),
),
const SizedBox(height: 12),
if (openButton != null) ...[openButton, const SizedBox(height: 16)],
waitingRow,
],
),
),
],
),
)
: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
bodyText,
const SizedBox(height: 16),
...children,
Center(
child: Column(
mainAxisSize: .min,
children: [
qr,
const SizedBox(height: 8),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: qrSize + 32),
child: urlChip(textAlign: TextAlign.center),
),
],
),
),
const SizedBox(height: 16),
if (openButton != null) ...[openButton, const SizedBox(height: 16)],
waitingRow,
],
),
actions: [
DialogActionButton(
// The PopScope above invokes [onCancel]; calling it here too would
// cancel twice.
onPressed: () => Navigator.of(context).pop(),
label: t.common.cancel,
),
],
),
);
}
}
/// Tap/D-pad target that copies the value it displays to the clipboard.
class CopyTapRegion extends StatelessWidget {
final VoidCallback onCopy;
final String semanticLabel;
/// Announced after [semanticLabel] so screen readers read out the value that
/// will be copied instead of just the action.
final String? semanticValue;
final Widget child;
const CopyTapRegion({
super.key,
required this.onCopy,
required this.semanticLabel,
this.semanticValue,
required this.child,
});
@override
Widget build(BuildContext context) {
return FocusableWrapper(
onSelect: onCopy,
semanticLabel: semanticLabel,
semanticValue: semanticValue,
descendantsAreFocusable: false,
useBackgroundFocus: true,
borderRadius: 8,
child: InkWell(canRequestFocus: false, onTap: onCopy, borderRadius: BorderRadius.circular(8), child: child),
);
}
}