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">
|
<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,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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user