feat(links): поддержка max.ru-ссылок и komet:// диплинков
- резолв max.ru/{join,joincall,u,c,:auth,<channel>}
- открытие чатов/профилей/звонков и подтверждение веб-входа в приложении
- внешние диплинки: intent-filter (Android), URL-схемы (iOS/macOS), komet://-регистрация в рантайме (Windows/Linux)
- fix(calls): не слать opcode 79 до login (жижа я тебя выебу)
This commit is contained in:
@@ -48,6 +48,22 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="https" android:host="max.ru"/>
|
||||
<data android:scheme="https" android:host="www.max.ru"/>
|
||||
<data android:scheme="http" android:host="max.ru"/>
|
||||
<data android:scheme="http" android:host="www.max.ru"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="komet"/>
|
||||
<data android:scheme="max"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity-alias
|
||||
android:name=".MinimalIcon"
|
||||
|
||||
@@ -70,5 +70,17 @@
|
||||
<string>Доступ к галерее нужен, чтобы отправлять фото и видео в чатах.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Доступ к галерее нужен, чтобы сохранять полученные фото и видео.</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>ru.komet.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>komet</string>
|
||||
<string>max</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -428,11 +428,20 @@ class LoginResult {
|
||||
class AccountModule {
|
||||
final Api _api;
|
||||
final _loginStatusController = StreamController<LoginStatus>.broadcast();
|
||||
bool _loggedIn = false;
|
||||
|
||||
AccountModule(this._api);
|
||||
AccountModule(this._api) {
|
||||
_api.stateStream.listen((state) {
|
||||
if (state != SessionState.online) _loggedIn = false;
|
||||
});
|
||||
}
|
||||
|
||||
Stream<LoginStatus> get loginStatusStream => _loginStatusController.stream;
|
||||
|
||||
/// `true`, только когда сервер считает сессию ONLINE — после успешного
|
||||
/// login (opcode 19), а не просто после хэндшейка (opcode 6).
|
||||
bool get isLoggedIn => _loggedIn;
|
||||
|
||||
Future<PrivacyConfig> getPrivacyConfig() async {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
@@ -999,6 +1008,7 @@ class AccountModule {
|
||||
}
|
||||
|
||||
final result = await _processLoginResponse(dataMap, resolvedAccountId);
|
||||
_loggedIn = true;
|
||||
_loginStatusController.add(LoginStatus.success);
|
||||
return result;
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../api.dart';
|
||||
|
||||
sealed class ResolvedLink {
|
||||
const ResolvedLink();
|
||||
}
|
||||
|
||||
class ResolvedChat extends ResolvedLink {
|
||||
final Map<dynamic, dynamic> chat;
|
||||
final Map<dynamic, dynamic>? message;
|
||||
|
||||
const ResolvedChat(this.chat, this.message);
|
||||
}
|
||||
|
||||
class ResolvedUser extends ResolvedLink {
|
||||
final Map<dynamic, dynamic> contact;
|
||||
|
||||
const ResolvedUser(this.contact);
|
||||
}
|
||||
|
||||
class ResolvedLinkError extends ResolvedLink {
|
||||
final String message;
|
||||
|
||||
const ResolvedLinkError(this.message);
|
||||
}
|
||||
|
||||
abstract class LinkModule {
|
||||
static Future<ResolvedLink?> resolve(Api api, String url) async {
|
||||
final Packet response;
|
||||
try {
|
||||
response = await api.sendRequest(Opcode.linkInfo, {'link': url});
|
||||
} on TimeoutException {
|
||||
return const ResolvedLinkError('Превышено время ожидания');
|
||||
} on PacketError catch (e) {
|
||||
return ResolvedLinkError(e.message);
|
||||
}
|
||||
|
||||
final payload = response.payload;
|
||||
if (payload is! Map) return null;
|
||||
if (!response.isOk) {
|
||||
return ResolvedLinkError(messageFromErrorPayload(payload));
|
||||
}
|
||||
|
||||
final chat = payload['chat'];
|
||||
if (chat is Map) {
|
||||
final message = payload['message'];
|
||||
return ResolvedChat(chat, message is Map ? message : null);
|
||||
}
|
||||
|
||||
final user = payload['user'];
|
||||
if (user is Map && user['contact'] is Map) {
|
||||
return ResolvedUser(user['contact'] as Map);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> join(Api api, String url) async {
|
||||
try {
|
||||
final response = await api.sendRequest(Opcode.chatJoin, {'link': url});
|
||||
if (response.isOk) return null;
|
||||
return messageFromErrorPayload(response.payload);
|
||||
} on TimeoutException {
|
||||
return 'Превышено время ожидания';
|
||||
} on PacketError catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:app_links/app_links.dart';
|
||||
|
||||
import '../../backend/api.dart';
|
||||
import '../../frontend/widgets/max_link_handler.dart';
|
||||
import '../../main.dart';
|
||||
import 'desktop_url_scheme.dart';
|
||||
|
||||
class DeepLinkService {
|
||||
DeepLinkService._();
|
||||
|
||||
static final DeepLinkService instance = DeepLinkService._();
|
||||
|
||||
final AppLinks _appLinks = AppLinks();
|
||||
StreamSubscription<Uri>? _sub;
|
||||
StreamSubscription<SessionState>? _stateSub;
|
||||
String? _pending;
|
||||
bool _ready = false;
|
||||
bool _started = false;
|
||||
|
||||
Future<void> init() async {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
|
||||
await DesktopUrlScheme.register();
|
||||
|
||||
_stateSub = api.stateStream.listen((state) {
|
||||
if (state == SessionState.online) _flushPending();
|
||||
});
|
||||
|
||||
_sub = _appLinks.uriLinkStream.listen(_onUri);
|
||||
try {
|
||||
final initial = await _appLinks.getInitialLink();
|
||||
if (initial != null) _onUri(initial);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void markReady() {
|
||||
_ready = true;
|
||||
_flushPending();
|
||||
}
|
||||
|
||||
void _onUri(Uri uri) {
|
||||
final url = _normalize(uri);
|
||||
if (url == null) return;
|
||||
_pending = url;
|
||||
_flushPending();
|
||||
}
|
||||
|
||||
void _flushPending() {
|
||||
final pending = _pending;
|
||||
if (pending == null || !_ready) return;
|
||||
if (api.state != SessionState.online) return;
|
||||
final context = KometApp.navigatorKey.currentContext;
|
||||
if (context == null) return;
|
||||
|
||||
_pending = null;
|
||||
tryHandleMaxLink(context, pending);
|
||||
}
|
||||
|
||||
String? _normalize(Uri uri) {
|
||||
final scheme = uri.scheme.toLowerCase();
|
||||
|
||||
if (scheme == 'https' || scheme == 'http') {
|
||||
final host = uri.host.toLowerCase();
|
||||
if (host == 'max.ru' || host == 'www.max.ru') return uri.toString();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (scheme == 'komet' || scheme == 'max') {
|
||||
final segments = <String>[
|
||||
if (uri.host.isNotEmpty && uri.host.toLowerCase() != 'max.ru') uri.host,
|
||||
...uri.pathSegments,
|
||||
].where((s) => s.isNotEmpty).toList();
|
||||
if (segments.isEmpty) return null;
|
||||
final query = uri.query.isNotEmpty ? '?${uri.query}' : '';
|
||||
return 'https://max.ru/${segments.join('/')}$query';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
_sub = null;
|
||||
_stateSub?.cancel();
|
||||
_stateSub = null;
|
||||
_started = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../utils/logger.dart';
|
||||
|
||||
const List<String> _schemes = ['komet', 'max'];
|
||||
|
||||
abstract class DesktopUrlScheme {
|
||||
static Future<void> register() async {
|
||||
try {
|
||||
if (Platform.isWindows) {
|
||||
await _registerWindows();
|
||||
} else if (Platform.isLinux) {
|
||||
await _registerLinux();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('DesktopUrlScheme: регистрация не удалась: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _registerWindows() async {
|
||||
final exe = Platform.resolvedExecutable;
|
||||
for (final scheme in _schemes) {
|
||||
final capitalized = '${scheme[0].toUpperCase()}${scheme.substring(1)}';
|
||||
final base = 'HKCU\\Software\\Classes\\$scheme';
|
||||
|
||||
await _run('reg', ['add', base, '/ve', '/d', 'URL:$capitalized', '/f']);
|
||||
await _run('reg', ['add', base, '/v', 'URL Protocol', '/d', '', '/f']);
|
||||
await _run('reg', [
|
||||
'add',
|
||||
'$base\\shell\\open\\command',
|
||||
'/ve',
|
||||
'/d',
|
||||
'"$exe" "%1"',
|
||||
'/f',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _registerLinux() async {
|
||||
final home = Platform.environment['HOME'];
|
||||
if (home == null || home.isEmpty) return;
|
||||
|
||||
final exe = Platform.resolvedExecutable;
|
||||
final appsDir = Directory('$home/.local/share/applications');
|
||||
await appsDir.create(recursive: true);
|
||||
|
||||
const fileName = 'komet-url-handler.desktop';
|
||||
final mimeTypes = _schemes.map((s) => 'x-scheme-handler/$s').join(';');
|
||||
final desktop = '[Desktop Entry]\n'
|
||||
'Type=Application\n'
|
||||
'Name=Komet\n'
|
||||
'Exec="$exe" %u\n'
|
||||
'Terminal=false\n'
|
||||
'NoDisplay=true\n'
|
||||
'MimeType=$mimeTypes;\n';
|
||||
|
||||
final file = File('${appsDir.path}/$fileName');
|
||||
if (!file.existsSync() || await file.readAsString() != desktop) {
|
||||
await file.writeAsString(desktop);
|
||||
}
|
||||
|
||||
for (final scheme in _schemes) {
|
||||
await _run('xdg-mime', ['default', fileName, 'x-scheme-handler/$scheme']);
|
||||
}
|
||||
await _run('update-desktop-database', [appsDir.path]);
|
||||
}
|
||||
|
||||
static Future<void> _run(String executable, List<String> args) async {
|
||||
try {
|
||||
await Process.run(executable, args);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
enum MaxLinkKind { call, invite, user, content, public, auth }
|
||||
|
||||
class MaxLink {
|
||||
final MaxLinkKind kind;
|
||||
final String url;
|
||||
|
||||
const MaxLink(this.kind, this.url);
|
||||
|
||||
static final RegExp _host = RegExp(
|
||||
r'^https?://(?:www\.)?max\.ru/(.+)$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
static final RegExp _segment = RegExp(r'^[A-Za-z0-9_]+$');
|
||||
|
||||
static const Set<String> _reserved = {
|
||||
'join',
|
||||
'joincall',
|
||||
'u',
|
||||
'c',
|
||||
'login',
|
||||
'ps',
|
||||
'tos',
|
||||
'privacy',
|
||||
'about',
|
||||
'help',
|
||||
};
|
||||
|
||||
static bool isMaxLink(String url) => parse(url) != null;
|
||||
|
||||
static MaxLink? parse(String input) {
|
||||
final url = input.trim();
|
||||
final match = _host.firstMatch(url);
|
||||
if (match == null) return null;
|
||||
|
||||
final path = match.group(1)!.split('?').first.split('#').first;
|
||||
final segments =
|
||||
path.split('/').where((s) => s.isNotEmpty).toList(growable: false);
|
||||
if (segments.isEmpty) return null;
|
||||
|
||||
switch (segments.first.toLowerCase()) {
|
||||
case ':auth':
|
||||
return segments.length >= 2 ? MaxLink(MaxLinkKind.auth, url) : null;
|
||||
case 'joincall':
|
||||
return segments.length >= 2 ? MaxLink(MaxLinkKind.call, url) : null;
|
||||
case 'join':
|
||||
return segments.length >= 2 ? MaxLink(MaxLinkKind.invite, url) : null;
|
||||
case 'u':
|
||||
return segments.length >= 2 ? MaxLink(MaxLinkKind.user, url) : null;
|
||||
case 'c':
|
||||
return segments.length >= 3 ? MaxLink(MaxLinkKind.content, url) : null;
|
||||
}
|
||||
|
||||
if (_reserved.contains(segments.first.toLowerCase())) return null;
|
||||
if (!_segment.hasMatch(segments.first)) return null;
|
||||
return MaxLink(MaxLinkKind.public, url);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../frontend/widgets/call_link_handler.dart';
|
||||
import '../../frontend/widgets/custom_notification.dart';
|
||||
import '../../frontend/widgets/max_link_handler.dart';
|
||||
|
||||
Future<void> openExternalUrl(BuildContext context, String url) async {
|
||||
if (await tryHandleCallLink(context, url)) return;
|
||||
if (await tryHandleMaxLink(context, url)) return;
|
||||
if (!context.mounted) return;
|
||||
|
||||
final uri = Uri.tryParse(url);
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../main.dart' show api, accountModule;
|
||||
import '../../../backend/api.dart';
|
||||
import '../../../backend/modules/account.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
@@ -27,7 +26,7 @@ class _CallsTabState extends State<CallsTab> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (api.state == SessionState.online) {
|
||||
if (accountModule.isLoggedIn) {
|
||||
_loadHistory();
|
||||
} else {
|
||||
_loginSub = accountModule.loginStatusStream.listen((status) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import '../../../backend/modules/account.dart' show SessionInfo;
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/connection_status.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import '../../widgets/web_qr_login.dart';
|
||||
import 'web_qr_scan_screen.dart';
|
||||
|
||||
class DevicesScreen extends StatefulWidget {
|
||||
@@ -113,71 +113,6 @@ class _DevicesScreenState extends State<DevicesScreen>
|
||||
}
|
||||
}
|
||||
|
||||
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: [
|
||||
const Center(child: SheetGrabber(margin: EdgeInsets.zero)),
|
||||
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 &&
|
||||
@@ -197,43 +132,8 @@ class _DevicesScreenState extends State<DevicesScreen>
|
||||
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');
|
||||
}
|
||||
}
|
||||
final success = await confirmAndAuthorizeWebQrLogin(context, qr.trim());
|
||||
if (success && mounted) _loadSessions();
|
||||
}
|
||||
|
||||
Future<void> _terminateOthers() async {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../backend/modules/chats.dart';
|
||||
import '../../backend/modules/links.dart';
|
||||
import '../../core/links/max_link.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../main.dart';
|
||||
import '../screens/chats/chat_screen.dart';
|
||||
import '../screens/contacts/contact_profile_screen.dart';
|
||||
import 'call_link_handler.dart';
|
||||
import 'confirm_dialog.dart';
|
||||
import 'custom_notification.dart';
|
||||
import 'swipe_route.dart';
|
||||
import 'web_qr_login.dart';
|
||||
|
||||
Future<bool> tryHandleMaxLink(BuildContext context, String url) async {
|
||||
final link = MaxLink.parse(url);
|
||||
if (link == null) return false;
|
||||
|
||||
if (link.kind == MaxLinkKind.call) {
|
||||
return tryHandleCallLink(context, url);
|
||||
}
|
||||
|
||||
if (link.kind == MaxLinkKind.auth) {
|
||||
await confirmAndAuthorizeWebQrLogin(context, link.url);
|
||||
return true;
|
||||
}
|
||||
|
||||
final resolved = await LinkModule.resolve(api, link.url);
|
||||
if (!context.mounted) return true;
|
||||
|
||||
switch (resolved) {
|
||||
case null:
|
||||
return false;
|
||||
case ResolvedLinkError(:final message):
|
||||
showCustomNotification(context, message);
|
||||
return true;
|
||||
case ResolvedUser(:final contact):
|
||||
_openContact(context, contact);
|
||||
return true;
|
||||
case ResolvedChat():
|
||||
await _openResolvedChat(context, link, resolved);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void _openContact(BuildContext context, Map<dynamic, dynamic> contact) {
|
||||
final id = contact['id'];
|
||||
if (id is! int) {
|
||||
showCustomNotification(context, 'Не удалось открыть профиль');
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ContactProfileScreen(
|
||||
contactId: id,
|
||||
initialName: _contactName(contact),
|
||||
initialAvatarUrl: contact['baseUrl'] as String?,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openResolvedChat(
|
||||
BuildContext context,
|
||||
MaxLink link,
|
||||
ResolvedChat resolved,
|
||||
) async {
|
||||
final chat = resolved.chat;
|
||||
final id = chat['id'];
|
||||
if (id is! int) {
|
||||
showCustomNotification(context, 'Не удалось открыть чат');
|
||||
return;
|
||||
}
|
||||
|
||||
final title = (chat['title'] as String?)?.trim() ?? '';
|
||||
final type = (chat['type'] as String?) ?? 'CHAT';
|
||||
final icon = (chat['baseIconUrl'] as String?) ?? '';
|
||||
final access = chat['access'];
|
||||
|
||||
final profile = await AppDatabase.loadActiveProfile();
|
||||
final myId = profile?.id ?? 0;
|
||||
final participants = chat['participants'];
|
||||
final isMember = myId != 0 &&
|
||||
participants is Map &&
|
||||
participants.containsKey(myId.toString());
|
||||
|
||||
await ChatsModule.cacheServerChat(chat, myId, inList: isMember);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (link.kind == MaxLinkKind.invite && access == 'PRIVATE' && !isMember) {
|
||||
final label = title.isEmpty ? 'этот чат' : '«$title»';
|
||||
final confirmed = await showConfirmDialog(
|
||||
context,
|
||||
title: 'Вступить',
|
||||
message: 'Вступить в $label?',
|
||||
confirmLabel: 'Вступить',
|
||||
);
|
||||
if (!confirmed || !context.mounted) return;
|
||||
|
||||
final error = await LinkModule.join(api, link.url);
|
||||
if (error != null) {
|
||||
if (context.mounted) showCustomNotification(context, error);
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
}
|
||||
|
||||
pushSwipeable(
|
||||
context,
|
||||
(_) => ChatScreen(
|
||||
chatId: id,
|
||||
name: title,
|
||||
imageUrl: icon,
|
||||
chatType: type,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _contactName(Map<dynamic, dynamic> contact) {
|
||||
final names = contact['names'];
|
||||
if (names is List && names.isNotEmpty && names.first is Map) {
|
||||
final entry = names.first as Map;
|
||||
final full = (entry['name'] as String?)?.trim();
|
||||
if (full != null && full.isNotEmpty) return full;
|
||||
final first = (entry['firstName'] as String?)?.trim() ?? '';
|
||||
final last = (entry['lastName'] as String?)?.trim() ?? '';
|
||||
final joined = '$first $last'.trim();
|
||||
if (joined.isNotEmpty) return joined;
|
||||
}
|
||||
return 'Профиль';
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import '../../main.dart' show accountModule;
|
||||
import 'custom_notification.dart';
|
||||
import 'sheet_helpers.dart';
|
||||
|
||||
Future<bool> showWebQrLoginConfirmSheet(BuildContext context) 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: [
|
||||
const Center(child: SheetGrabber(margin: EdgeInsets.zero)),
|
||||
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<bool> confirmAndAuthorizeWebQrLogin(
|
||||
BuildContext context,
|
||||
String qrLink,
|
||||
) async {
|
||||
final confirmed = await showWebQrLoginConfirmSheet(context);
|
||||
if (!confirmed || !context.mounted) return false;
|
||||
|
||||
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(qrLink.trim());
|
||||
if (context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
showCustomNotification(context, 'Вход подтверждён');
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
showCustomNotification(context, 'Не удалось подтвердить вход: $e');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import 'backend/modules/self_check.dart';
|
||||
import 'backend/modules/webapp.dart';
|
||||
import 'backend/modules/digital_id.dart';
|
||||
import 'core/calls/call_controller.dart';
|
||||
import 'core/links/deep_link_service.dart';
|
||||
import 'frontend/screens/calls/call_screen.dart';
|
||||
import 'core/push/push_service.dart';
|
||||
import 'core/storage/app_database.dart';
|
||||
@@ -95,6 +96,7 @@ void main() async {
|
||||
}
|
||||
attachInfoCacheApi(api);
|
||||
ChatsModule.attachGlobalPushHandlers(api);
|
||||
unawaited(DeepLinkService.instance.init());
|
||||
|
||||
final packageInfoFuture = PackageInfo.fromPlatform();
|
||||
final localeFuture = _loadInitialLocale();
|
||||
@@ -265,6 +267,7 @@ class KometAppState extends State<KometApp>
|
||||
|
||||
_loginStatusSub = accountModule.loginStatusStream.listen((status) async {
|
||||
if (status == LoginStatus.success) {
|
||||
DeepLinkService.instance.markReady();
|
||||
CallController.instance.init(api);
|
||||
OutboxService.instance.init(api, messagesModule);
|
||||
SelfCheckService.instance.init(api);
|
||||
|
||||
@@ -28,5 +28,17 @@
|
||||
<string>MainMenu</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>ru.komet.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>komet</string>
|
||||
<string>max</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
+44
-4
@@ -17,6 +17,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
app_links:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: app_links
|
||||
sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
app_links_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_linux
|
||||
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
app_links_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_platform_interface
|
||||
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
app_links_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_web
|
||||
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -597,6 +629,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
gtk:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: gtk
|
||||
sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -785,10 +825,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
version: "1.17.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1174,10 +1214,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
version: "0.7.10"
|
||||
timezone:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -63,6 +63,7 @@ dependencies:
|
||||
path_provider: ^2.1.4
|
||||
open_filex: ^4.5.0
|
||||
url_launcher: ^6.3.1
|
||||
app_links: ^6.3.0
|
||||
video_player: ^2.9.2
|
||||
firebase_core: ^4.1.1
|
||||
firebase_messaging: ^16.0.2
|
||||
|
||||
Reference in New Issue
Block a user