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
View File
@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CAMERA"/>
<application <application
android:label="komet" android:label="komet"
android:name="${applicationName}" android:name="${applicationName}"
+1
View File
@@ -1,3 +1,4 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
kotlin.incremental=false kotlin.incremental=false
dev.steenbakker.mobile_scanner.useUnbundled=true
+2
View File
@@ -45,5 +45,7 @@
<true/> <true/>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>NSCameraUsageDescription</key>
<string>Камера нужна для сканирования QR-кода входа в веб-версию и приложение MAX на компьютере.</string>
</dict> </dict>
</plist> </plist>
+16
View File
@@ -288,6 +288,22 @@ class AccountModule {
_checkPacketError(packet, 'terminateOtherSessions'); _checkPacketError(packet, 'terminateOtherSessions');
} }
Future<void> 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<void>.delayed(const Duration(milliseconds: 300));
final packet = await _api.sendRequest(Opcode.authQrApprove, {
'qrLink': link,
});
_checkPacketError(packet, 'authorizeWebQrLogin');
}
Future<ProfileData> switchAccount(int accountId) async { Future<ProfileData> switchAccount(int accountId) async {
final profile = await AppDatabase.loadProfile(accountId); final profile = await AppDatabase.loadProfile(accountId);
if (profile == null) { if (profile == null) {
@@ -1,12 +1,15 @@
import 'dart:io'; import 'dart:io';
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../main.dart' show accountModule; import '../../../main.dart' show accountModule;
import '../../../backend/modules/account.dart' show SessionInfo; import '../../../backend/modules/account.dart' show SessionInfo;
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import 'web_qr_scan_screen.dart';
class DevicesScreen extends StatefulWidget { class DevicesScreen extends StatefulWidget {
const DevicesScreen({super.key}); 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 { Future<void> _terminateOthers() async {
try { try {
await accountModule.terminateOtherSessions(); await accountModule.terminateOtherSessions();
@@ -229,6 +414,24 @@ class _DevicesScreenState extends State<DevicesScreen>
height: 1.3, 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,
),
],
),
),
),
),
],
),
);
}
}
+8
View File
@@ -341,6 +341,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" 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: msgpack_dart:
dependency: "direct main" dependency: "direct main"
description: description:
+1
View File
@@ -52,6 +52,7 @@ dependencies:
dynamic_color: ^1.8.1 dynamic_color: ^1.8.1
shared_preferences: ^2.5.4 shared_preferences: ^2.5.4
package_info_plus: ^9.0.1 package_info_plus: ^9.0.1
mobile_scanner: ^7.2.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: