feat(trackers): show a QR code in every tracker sign-in dialog and fit them on TV screens
Every tracker auth dialog (Trakt/Simkl/MDBList device-code and MAL/AniList OAuth proxy) now shares the same PendingAuthDialog affordances: a QR code for the sign-in URL, a large copyable URL with the scheme stripped, the browser launch button (hidden on Apple TV, which has no browser), and the polling spinner. On wide viewports (TV logical 960x540, desktop, phone landscape) the QR pane sits beside the instructions so the dialog no longer clips on tvOS, and the content is scrollable as an overflow safety net. Device activation codes scale down instead of wrapping.
This commit is contained in:
@@ -6,12 +6,15 @@ import '../models/trackers/device_code.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import 'pending_auth_dialog.dart';
|
||||
|
||||
/// Shared device-code activation dialog for Trakt and Simkl (RFC 8628).
|
||||
/// Shared device-code activation dialog for Trakt, Simkl, and MDBList
|
||||
/// (RFC 8628).
|
||||
///
|
||||
/// Shows the `userCode` with copy-to-clipboard, a button to launch the
|
||||
/// verification URL in the browser, and a "waiting for authorization…" spinner
|
||||
/// while the poll loop runs. Dismissing calls [onCancel] so the provider can
|
||||
/// abort the poll.
|
||||
/// The [PendingAuthDialog] shell contributes the QR code (encoding the
|
||||
/// code-prefilled verification URL when the service provides one), the
|
||||
/// readable copyable verification URL, the browser launch button, and the
|
||||
/// "waiting for authorization…" spinner; this dialog adds the `userCode` with
|
||||
/// copy-to-clipboard. Dismissing calls [onCancel] so the provider can abort
|
||||
/// the poll.
|
||||
class DeviceCodeDialog extends StatelessWidget {
|
||||
final DeviceCode code;
|
||||
final String serviceName;
|
||||
@@ -30,8 +33,9 @@ class DeviceCodeDialog extends StatelessWidget {
|
||||
final theme = Theme.of(context);
|
||||
return PendingAuthDialog(
|
||||
title: t.services.deviceCode.title(service: serviceName),
|
||||
body: t.services.deviceCode.body(url: code.verificationUrl),
|
||||
body: t.services.deviceCode.instructions,
|
||||
url: code.verificationUrlComplete ?? code.verificationUrl,
|
||||
displayUrl: code.verificationUrl,
|
||||
openLabel: t.services.deviceCode.openToActivate(service: serviceName),
|
||||
onCancel: onCancel,
|
||||
children: [
|
||||
@@ -41,13 +45,19 @@ class DeviceCodeDialog extends StatelessWidget {
|
||||
semanticLabel: t.services.deviceCode.copyCode,
|
||||
semanticValue: code.userCode,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
child: Text(
|
||||
code.userCode,
|
||||
style: theme.textTheme.displaySmall?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
letterSpacing: 4,
|
||||
fontWeight: .w600,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
// Scale down instead of wrapping: user codes vary in length and
|
||||
// a wrapped activation code reads as two codes.
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
code.userCode,
|
||||
maxLines: 1,
|
||||
style: theme.textTheme.displaySmall?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
letterSpacing: 4,
|
||||
fontWeight: .w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/trackers/oauth_proxy_client.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import 'pending_auth_dialog.dart';
|
||||
|
||||
/// Sign-in dialog for OAuth-proxy flows (MAL, AniList).
|
||||
///
|
||||
/// Shows a QR code plus a "open in browser" button — works uniformly on
|
||||
/// phones (user taps the button), desktops (same), and TVs without a browser
|
||||
/// (user scans the QR with a phone).
|
||||
/// The [PendingAuthDialog] shell contributes everything this flow needs — QR
|
||||
/// code, readable copyable URL, browser launch button, and the waiting
|
||||
/// spinner — so it works uniformly on phones (user taps the button), desktops
|
||||
/// (same), and TVs without a browser (user scans the QR with a phone).
|
||||
class OAuthProxyDialog extends StatelessWidget {
|
||||
final OAuthProxyStart start;
|
||||
final String serviceName;
|
||||
@@ -19,52 +17,15 @@ class OAuthProxyDialog extends StatelessWidget {
|
||||
|
||||
const OAuthProxyDialog({super.key, required this.start, required this.serviceName, required this.onCancel});
|
||||
|
||||
Future<void> _copyUrl(BuildContext context) async {
|
||||
await Clipboard.setData(ClipboardData(text: start.url));
|
||||
if (!context.mounted) return;
|
||||
showAppSnackBar(context, t.services.oauthProxy.urlCopied);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return PendingAuthDialog(
|
||||
title: t.services.oauthProxy.title(service: serviceName),
|
||||
body: t.services.oauthProxy.body,
|
||||
url: start.url,
|
||||
openLabel: t.services.oauthProxy.openToSignIn(service: serviceName),
|
||||
onCancel: onCancel,
|
||||
children: [
|
||||
// QrImageView doesn't support intrinsic sizing; wrap in SizedBox so
|
||||
// AlertDialog's IntrinsicWidth walk sees a concrete width.
|
||||
Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 220,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: QrImageView(data: start.url, size: 220, version: QrVersions.auto, backgroundColor: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CopyTapRegion(
|
||||
onCopy: () => _copyUrl(context),
|
||||
semanticLabel: t.services.oauthProxy.copyUrl,
|
||||
semanticValue: start.url,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Text(
|
||||
start.url,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
children: const [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
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.
|
||||
///
|
||||
/// Shows [body], the service-specific [children], a button that launches [url]
|
||||
/// in the browser, and a "waiting for authorization…" spinner while the poll
|
||||
/// loop runs. Dismissing calls [onCancel] so the provider can abort the poll.
|
||||
/// 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.
|
||||
/// Dismissing calls [onCancel] so the provider can abort the poll.
|
||||
class PendingAuthDialog extends StatelessWidget {
|
||||
final String title;
|
||||
final String body;
|
||||
@@ -21,7 +31,14 @@ class PendingAuthDialog extends StatelessWidget {
|
||||
/// 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;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
@@ -31,6 +48,7 @@ class PendingAuthDialog extends StatelessWidget {
|
||||
required this.body,
|
||||
required this.children,
|
||||
required this.url,
|
||||
this.displayUrl,
|
||||
required this.openLabel,
|
||||
required this.onCancel,
|
||||
});
|
||||
@@ -39,19 +57,56 @@ class PendingAuthDialog extends StatelessWidget {
|
||||
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);
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(body, style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 16),
|
||||
...children,
|
||||
SizedBox(
|
||||
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,
|
||||
@@ -62,17 +117,80 @@ class PendingAuthDialog extends StatelessWidget {
|
||||
onPressed: _open,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
const LoadingIndicatorBox(size: 16),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(t.services.deviceCode.waitingForAuthorization, style: theme.textTheme.bodySmall)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final waitingRow = Row(
|
||||
children: [
|
||||
const LoadingIndicatorBox(size: 16),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(t.services.deviceCode.waitingForAuthorization, style: theme.textTheme.bodySmall)),
|
||||
],
|
||||
);
|
||||
|
||||
return 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(
|
||||
onPressed: () {
|
||||
|
||||
Reference in New Issue
Block a user