feat(devices): QR approve login for web and desktop MAX
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<application
|
||||
android:label="komet"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
kotlin.incremental=false
|
||||
dev.steenbakker.mobile_scanner.useUnbundled=true
|
||||
|
||||
@@ -45,5 +45,7 @@
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Камера нужна для сканирования QR-кода входа в веб-версию и приложение MAX на компьютере.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -288,6 +288,22 @@ class AccountModule {
|
||||
_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 {
|
||||
final profile = await AppDatabase.loadProfile(accountId);
|
||||
if (profile == null) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user