feat(devices): QR approve login for web and desktop MAX

This commit is contained in:
klockky
2026-04-06 15:07:15 +03:00
parent a4719c91b8
commit 55313abb12
8 changed files with 349 additions and 0 deletions
@@ -1,12 +1,15 @@
import 'dart:io';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../main.dart' show accountModule;
import '../../../backend/modules/account.dart' show SessionInfo;
import '../../widgets/custom_notification.dart';
import 'web_qr_scan_screen.dart';
class DevicesScreen extends StatefulWidget {
const DevicesScreen({super.key});
@@ -57,6 +60,188 @@ class _DevicesScreenState extends State<DevicesScreen>
}
}
Future<String?> _showPasteQrDialog() async {
final tec = TextEditingController();
try {
return await showDialog<String>(
context: context,
builder: (dialogContext) {
final cs = Theme.of(dialogContext).colorScheme;
return AlertDialog(
backgroundColor: cs.surfaceContainerHigh,
title: Text(
'Ссылка из QR',
style: GoogleFonts.outfit(
fontWeight: FontWeight.w600,
fontSize: 18,
color: cs.onSurface,
),
),
content: TextField(
controller: tec,
decoration: const InputDecoration(
hintText: 'Вставьте содержимое QR-кода',
),
autofocus: true,
maxLines: 4,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(
'Отмена',
style: TextStyle(color: cs.onSurfaceVariant),
),
),
FilledButton(
onPressed: () {
final v = tec.text.trim();
Navigator.pop(dialogContext, v.isEmpty ? null : v);
},
child: const Text('Подтвердить'),
),
],
);
},
);
} finally {
tec.dispose();
}
}
Future<bool> _confirmQrWebLoginSheet() async {
final agreed = await showModalBottomSheet<bool>(
context: context,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) {
final cs = Theme.of(sheetContext).colorScheme;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 20),
Text(
'Вход по QR',
style: GoogleFonts.outfit(
fontSize: 20,
fontWeight: FontWeight.w700,
color: cs.onSurface,
),
),
const SizedBox(height: 12),
Text(
'Вы точно хотите войти в аккаунт через веб или приложение MAX на компьютере?',
style: TextStyle(
fontSize: 15,
height: 1.35,
color: cs.onSurfaceVariant,
),
),
const SizedBox(height: 28),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () =>
Navigator.of(sheetContext).pop(false),
child: Text(
'Отмена',
style: TextStyle(color: cs.onSurface),
),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () =>
Navigator.of(sheetContext).pop(true),
child: const Text('Войти'),
),
),
],
),
],
),
),
);
},
);
return agreed ?? false;
}
Future<void> _startWebQrAuth() async {
final canScan = !kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS);
final String? qr;
if (canScan) {
qr = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (context) => const WebQrScanScreen()),
);
} else {
qr = await _showPasteQrDialog();
}
if (!mounted) return;
if (qr == null || qr.trim().isEmpty) return;
final confirmed = await _confirmQrWebLoginSheet();
if (!mounted) return;
if (!confirmed) return;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) {
final cs = Theme.of(ctx).colorScheme;
return PopScope(
canPop: false,
child: Center(
child: Card(
color: cs.surfaceContainerHigh,
child: const Padding(
padding: EdgeInsets.all(28),
child: CircularProgressIndicator(),
),
),
),
);
},
);
try {
await accountModule.authorizeWebQrLogin(qr.trim());
if (mounted) {
Navigator.of(context, rootNavigator: true).pop();
showCustomNotification(context, 'Вход по QR подтверждён');
_loadSessions();
}
} catch (e) {
if (mounted) {
Navigator.of(context, rootNavigator: true).pop();
showCustomNotification(context, 'Ошибка: $e');
}
}
}
Future<void> _terminateOthers() async {
try {
await accountModule.terminateOtherSessions();
@@ -229,6 +414,24 @@ class _DevicesScreenState extends State<DevicesScreen>
height: 1.3,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: _startWebQrAuth,
icon: const Icon(Symbols.qr_code_scanner, size: 22),
label: Text(
'Сканировать QR',
style: GoogleFonts.outfit(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 14,
),
),
),
],
),
),
@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
class WebQrScanScreen extends StatefulWidget {
const WebQrScanScreen({super.key});
@override
State<WebQrScanScreen> createState() => _WebQrScanScreenState();
}
class _WebQrScanScreenState extends State<WebQrScanScreen> {
final MobileScannerController _controller = MobileScannerController();
bool _handled = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onDetect(BarcodeCapture capture) {
if (_handled) return;
final barcodes = capture.barcodes;
if (barcodes.isEmpty) return;
final raw = barcodes.first.rawValue;
if (raw == null || raw.isEmpty) return;
_handled = true;
_controller.stop();
if (mounted) Navigator.of(context).pop<String>(raw);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Symbols.chevron_left, size: 28),
onPressed: () => Navigator.pop(context),
),
title: Text(
'QR для веба и ПК',
style: GoogleFonts.outfit(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
centerTitle: true,
actions: [
IconButton(
icon: ValueListenableBuilder<MobileScannerState>(
valueListenable: _controller,
builder: (context, state, _) {
final on = state.torchState == TorchState.on;
return Icon(
on ? Symbols.flash_on : Symbols.flash_off,
color: Colors.white,
);
},
),
onPressed: () => _controller.toggleTorch(),
),
],
),
body: Stack(
fit: StackFit.expand,
children: [
MobileScanner(
controller: _controller,
onDetect: _onDetect,
errorBuilder: (context, error) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
error.errorDetails?.message ?? 'Камера недоступна',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70, fontSize: 15),
),
),
);
},
),
Positioned(
left: 0,
right: 0,
bottom: 48,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
'Наведите камеру на QR-код на экране компьютера',
textAlign: TextAlign.center,
style: GoogleFonts.outfit(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Colors.white,
shadows: const [
Shadow(
blurRadius: 8,
color: Colors.black54,
),
],
),
),
),
),
],
),
);
}
}