From 3dd8b39e235dceb62a0c5950e14b2a90dcd1dd45 Mon Sep 17 00:00:00 2001 From: klockky Date: Mon, 6 Apr 2026 15:06:12 +0300 Subject: [PATCH] feat(devices): QR approve login for web and desktop MAX --- android/app/src/main/AndroidManifest.xml | 1 + android/gradle.properties | 1 + ios/Runner/Info.plist | 2 + lib/backend/modules/account.dart | 16 ++ .../screens/profile/devices_screen.dart | 203 ++++++++++++++++++ .../screens/profile/web_qr_scan_screen.dart | 117 ++++++++++ pubspec.lock | 8 + pubspec.yaml | 1 + 8 files changed, 349 insertions(+) create mode 100644 lib/frontend/screens/profile/web_qr_scan_screen.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a503dd6..b2c538d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + UIApplicationSupportsIndirectInputEvents + NSCameraUsageDescription + Камера нужна для сканирования QR-кода входа в веб-версию и приложение MAX на компьютере. diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 67aeb2d..bb4f03d 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -288,6 +288,22 @@ class AccountModule { _checkPacketError(packet, 'terminateOtherSessions'); } + Future authorizeWebQrLogin(String qrLink) async { + _ensureOnline(); + final link = qrLink.trim(); + if (link.isEmpty) { + throw ArgumentError('Пустая ссылка из QR'); + } + + await _api.sendRequest(Opcode.ping, {'interactive': true}); + await _api.sendRequest(Opcode.sessionsInfo, {}); + await Future.delayed(const Duration(milliseconds: 300)); + final packet = await _api.sendRequest(Opcode.authQrApprove, { + 'qrLink': link, + }); + _checkPacketError(packet, 'authorizeWebQrLogin'); + } + Future switchAccount(int accountId) async { final profile = await AppDatabase.loadProfile(accountId); if (profile == null) { diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index 40ab60d..8030921 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -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 } } + Future _showPasteQrDialog() async { + final tec = TextEditingController(); + try { + return await showDialog( + 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 _confirmQrWebLoginSheet() async { + final agreed = await showModalBottomSheet( + 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 _startWebQrAuth() async { + final canScan = !kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS); + + final String? qr; + if (canScan) { + qr = await Navigator.push( + 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( + 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 _terminateOthers() async { try { await accountModule.terminateOtherSessions(); @@ -229,6 +414,24 @@ class _DevicesScreenState extends State 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, + ), + ), + ), ], ), ), diff --git a/lib/frontend/screens/profile/web_qr_scan_screen.dart b/lib/frontend/screens/profile/web_qr_scan_screen.dart new file mode 100644 index 0000000..c512811 --- /dev/null +++ b/lib/frontend/screens/profile/web_qr_scan_screen.dart @@ -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 createState() => _WebQrScanScreenState(); +} + +class _WebQrScanScreenState extends State { + 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(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( + 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, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 5a852a3..62a257f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -341,6 +341,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce + url: "https://pub.dev" + source: hosted + version: "7.2.0" msgpack_dart: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index d849066..0341c9a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,6 +52,7 @@ dependencies: dynamic_color: ^1.8.1 shared_preferences: ^2.5.4 package_info_plus: ^9.0.1 + mobile_scanner: ^7.2.0 dev_dependencies: flutter_test: