fix: большой фикс недочетов и фикс пары язв
This commit is contained in:
@@ -14,6 +14,7 @@ import 'proxy_settings_sheet.dart';
|
||||
import 'server_settings_sheet.dart';
|
||||
import '../profile/spoof_screen.dart';
|
||||
import '../profile/debug_menu_screen.dart';
|
||||
import '../digital_id/digital_id_web_screen.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/adaptive_shell.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
@@ -53,6 +54,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
Future<void> _onBackPressed() async {
|
||||
final returnId = widget.returnToAccountId;
|
||||
if (returnId != null) {
|
||||
await resetDigitalIdSession();
|
||||
try {
|
||||
await accountModule.switchAccount(returnId);
|
||||
} catch (_) {}
|
||||
|
||||
@@ -19,6 +19,7 @@ import '../calls/calls_tab.dart';
|
||||
import '../contacts/contacts_tab.dart';
|
||||
import '../profile/settings_tab.dart';
|
||||
import '../auth/login_screen.dart';
|
||||
import '../digital_id/digital_id_web_screen.dart';
|
||||
import '../../widgets/account_switcher_overlay.dart';
|
||||
import '../../../backend/api.dart';
|
||||
import '../../../core/utils/haptics.dart';
|
||||
@@ -2318,6 +2319,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
if (!mounted) return;
|
||||
if (accountId == null) {
|
||||
final previousId = await TokenStorage.getActiveAccountId();
|
||||
await resetDigitalIdSession();
|
||||
try {
|
||||
await accountModule.beginAddAccount();
|
||||
} catch (_) {}
|
||||
@@ -2330,6 +2332,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
);
|
||||
return;
|
||||
}
|
||||
await resetDigitalIdSession();
|
||||
try {
|
||||
await accountModule.switchAccount(accountId);
|
||||
} catch (e) {
|
||||
|
||||
@@ -141,7 +141,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
Timer? _shimmerStartTimer;
|
||||
bool _historyKickedOff = false;
|
||||
List<CachedMessage> _messages = [];
|
||||
int _messagesRevision = 0;
|
||||
final ValueNotifier<int> _messagesRev = ValueNotifier(0);
|
||||
List<Object>? _combinedItemsCache;
|
||||
int? _combinedItemsKey;
|
||||
bool _floatingDateScheduled = false;
|
||||
@@ -234,7 +234,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
.toList();
|
||||
setState(() {
|
||||
_messages = first;
|
||||
_messagesRevision++;
|
||||
_messagesRev.value++;
|
||||
_isLoading = false;
|
||||
_onLoadingFinished();
|
||||
});
|
||||
@@ -303,8 +303,9 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
widget.chatId,
|
||||
limit: 100,
|
||||
);
|
||||
if (mounted && fullRows.length > _messages.length) {
|
||||
_applyMergedMessages(fullRows);
|
||||
final fullDecoded = await CachedMessage.fromDbRowsAsync(fullRows);
|
||||
if (mounted && fullDecoded.length > _messages.length) {
|
||||
_applyMergedMessages(fullDecoded);
|
||||
}
|
||||
|
||||
if (!ChatsModule.isChatDirty(widget.chatId) && fullRows.isNotEmpty) {
|
||||
@@ -326,8 +327,9 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
widget.chatId,
|
||||
limit: 100,
|
||||
);
|
||||
final updatedDecoded = await CachedMessage.fromDbRowsAsync(updatedRows);
|
||||
if (mounted) {
|
||||
_applyMergedMessages(updatedRows, markLoaded: true);
|
||||
_applyMergedMessages(updatedDecoded, markLoaded: true);
|
||||
}
|
||||
unawaited(
|
||||
ChatsModule.reconcileLastMessageIfPlaceholder(_myId, widget.chatId),
|
||||
@@ -345,13 +347,12 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
|
||||
void _applyMergedMessages(
|
||||
List<Map<String, dynamic>> rowsDesc, {
|
||||
List<CachedMessage> decodedDesc, {
|
||||
bool markLoaded = false,
|
||||
}) {
|
||||
final byId = <String, CachedMessage>{for (final m in _messages) m.id: m};
|
||||
final merged = <CachedMessage>[];
|
||||
for (final row in rowsDesc.reversed) {
|
||||
final fresh = CachedMessage.fromDbRow(row);
|
||||
for (final fresh in decodedDesc.reversed) {
|
||||
final old = byId[fresh.id];
|
||||
merged.add(old != null && _sameMessage(old, fresh) ? old : fresh);
|
||||
}
|
||||
@@ -361,7 +362,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
setState(() {
|
||||
if (changed) {
|
||||
_messages = merged;
|
||||
_messagesRevision++;
|
||||
_messagesRev.value++;
|
||||
}
|
||||
if (markLoaded) {
|
||||
_isLoading = false;
|
||||
@@ -437,6 +438,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
_typingTimers.clear();
|
||||
_headerStatusNotifier.dispose();
|
||||
_otherReadTime.dispose();
|
||||
_messagesRev.dispose();
|
||||
_finishPrankReveal();
|
||||
_uploadStatus.dispose();
|
||||
_attachAnim.dispose();
|
||||
@@ -594,17 +596,20 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
}
|
||||
|
||||
void _bumpMessages() {
|
||||
_combinedItemsCache = null;
|
||||
_messagesRev.value++;
|
||||
}
|
||||
|
||||
void _onMessageEvent(MessageEvent event) {
|
||||
if (!mounted) return;
|
||||
switch (event) {
|
||||
case MessageAddedEvent(:final message):
|
||||
if (message.senderId == _myId) return;
|
||||
if (_messages.any((m) => m.id == message.id)) return;
|
||||
setState(() {
|
||||
_lastSentId = message.id;
|
||||
_messages.add(message);
|
||||
_messagesRevision++;
|
||||
});
|
||||
_lastSentId = message.id;
|
||||
_messages.add(message);
|
||||
_bumpMessages();
|
||||
_clearTyping(message.senderId);
|
||||
Haptics.tap();
|
||||
_scrollToBottom();
|
||||
@@ -612,17 +617,13 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
case MessageEditedEvent(:final message):
|
||||
final idx = _messages.indexWhere((m) => m.id == message.id);
|
||||
if (idx == -1) return;
|
||||
setState(() {
|
||||
_messages[idx] = message;
|
||||
_messagesRevision++;
|
||||
});
|
||||
_messages[idx] = message;
|
||||
_bumpMessages();
|
||||
case MessageRemovedEvent(:final messageId):
|
||||
final idx = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (idx == -1) return;
|
||||
setState(() {
|
||||
_messages.removeAt(idx);
|
||||
_messagesRevision++;
|
||||
});
|
||||
_messages.removeAt(idx);
|
||||
_bumpMessages();
|
||||
_reactionNotifiers.remove(messageId)?.dispose();
|
||||
case MessageReactionsChangedEvent(:final messageId, :final reactionInfo):
|
||||
_reactionNotifiers[messageId]?.value = reactionInfo;
|
||||
@@ -724,12 +725,10 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
);
|
||||
|
||||
_hasText.value = false;
|
||||
setState(() {
|
||||
_lastSentId = tempId;
|
||||
_messages.add(tempMessage);
|
||||
_messagesRevision++;
|
||||
_messageController.clear();
|
||||
});
|
||||
_lastSentId = tempId;
|
||||
_messages.add(tempMessage);
|
||||
_messageController.clear();
|
||||
_bumpMessages();
|
||||
unawaited(_persistOutgoing(tempMessage));
|
||||
|
||||
// Instant tactile "whoosh" the moment the message leaves the composer,
|
||||
@@ -756,10 +755,8 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
time: now,
|
||||
status: 'sent',
|
||||
);
|
||||
setState(() {
|
||||
_messages[index] = sent;
|
||||
_messagesRevision++;
|
||||
});
|
||||
_messages[index] = sent;
|
||||
_bumpMessages();
|
||||
unawaited(_persistOutgoing(sent, removeId: tempId));
|
||||
}
|
||||
|
||||
@@ -785,10 +782,8 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
time: now,
|
||||
status: 'error',
|
||||
);
|
||||
setState(() {
|
||||
_messages[index] = failed;
|
||||
_messagesRevision++;
|
||||
});
|
||||
_messages[index] = failed;
|
||||
_bumpMessages();
|
||||
unawaited(_persistOutgoing(failed));
|
||||
}
|
||||
}
|
||||
@@ -871,7 +866,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
|
||||
if (anyChanged) {
|
||||
setState(() => _messagesRevision++);
|
||||
_bumpMessages();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -888,7 +883,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
|
||||
List<Object> _buildCombinedItems() {
|
||||
final key = Object.hash(_messagesRevision, _messages.length);
|
||||
final key = Object.hash(_messagesRev.value, _messages.length);
|
||||
final cached = _combinedItemsCache;
|
||||
if (cached != null && _combinedItemsKey == key) return cached;
|
||||
|
||||
@@ -1232,6 +1227,13 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
|
||||
Widget _buildMessagesList() {
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _messagesRev,
|
||||
builder: (context, _, _) => _buildMessagesListContent(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessagesListContent() {
|
||||
if (_messages.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
@@ -1301,7 +1303,10 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
? _SentMessageAnimation(
|
||||
key: ValueKey('anim_${message.id}'),
|
||||
onComplete: () {
|
||||
if (mounted) setState(() => _lastSentId = null);
|
||||
if (mounted) {
|
||||
_lastSentId = null;
|
||||
_bumpMessages();
|
||||
}
|
||||
},
|
||||
child: pressable,
|
||||
)
|
||||
@@ -1692,11 +1697,9 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
status: 'sending',
|
||||
attachments: [attachment],
|
||||
);
|
||||
setState(() {
|
||||
_lastSentId = tempId;
|
||||
_messages.add(msg);
|
||||
_messagesRevision++;
|
||||
});
|
||||
_lastSentId = tempId;
|
||||
_messages.add(msg);
|
||||
_bumpMessages();
|
||||
Haptics.send();
|
||||
_scrollToBottom();
|
||||
return tempId;
|
||||
@@ -1711,20 +1714,18 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||||
if (idx == -1) return;
|
||||
final old = _messages[idx];
|
||||
setState(() {
|
||||
_messages[idx] = CachedMessage(
|
||||
id: tempId,
|
||||
accountId: old.accountId,
|
||||
chatId: old.chatId,
|
||||
senderId: old.senderId,
|
||||
text: old.text,
|
||||
time: old.time,
|
||||
status: status,
|
||||
payload: old.payload,
|
||||
attachments: attachment != null ? [attachment] : old.attachments,
|
||||
);
|
||||
_messagesRevision++;
|
||||
});
|
||||
_messages[idx] = CachedMessage(
|
||||
id: tempId,
|
||||
accountId: old.accountId,
|
||||
chatId: old.chatId,
|
||||
senderId: old.senderId,
|
||||
text: old.text,
|
||||
time: old.time,
|
||||
status: status,
|
||||
payload: old.payload,
|
||||
attachments: attachment != null ? [attachment] : old.attachments,
|
||||
);
|
||||
_bumpMessages();
|
||||
}
|
||||
|
||||
Future<void> _sendHistoryFile(FileHistoryEntry entry) async {
|
||||
|
||||
@@ -159,13 +159,14 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
|
||||
if (!mounted) return;
|
||||
navigator.pop();
|
||||
pushSwipeable(
|
||||
context,
|
||||
(_) => ChatScreen(
|
||||
chatId: chat.id,
|
||||
name: chat.title ?? title,
|
||||
imageUrl: chat.iconUrl ?? '',
|
||||
chatType: chat.type,
|
||||
navigator.push(
|
||||
SwipeRoute(
|
||||
builder: (_) => ChatScreen(
|
||||
chatId: chat.id,
|
||||
name: chat.title ?? title,
|
||||
imageUrl: chat.iconUrl ?? '',
|
||||
chatType: chat.type,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -270,9 +270,9 @@ class _SearchContactSheetState extends State<_SearchContactSheet> {
|
||||
if (n is Map) name = n['name']?.toString();
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
final navigator = Navigator.of(context);
|
||||
navigator.pop();
|
||||
navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ContactProfileScreen(
|
||||
contactId: id,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../backend/modules/digital_id.dart';
|
||||
import '../../../backend/modules/webapp.dart';
|
||||
import '../../../core/utils/webview_support.dart';
|
||||
import '../../../main.dart' show digitalIdModule;
|
||||
import '../../../models/digital_id.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
@@ -89,6 +90,13 @@ class _DigitalIdScreenState extends State<DigitalIdScreen> {
|
||||
|
||||
Future<void> _linkGosuslugi() async {
|
||||
if (_busy) return;
|
||||
if (!webViewSupported) {
|
||||
showCustomNotification(
|
||||
context,
|
||||
'Привязка Госуслуг недоступна на этой платформе. Сделайте это в приложении на телефоне.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final link = await digitalIdModule.createEsiaLink();
|
||||
@@ -173,14 +181,16 @@ class _DigitalIdScreenState extends State<DigitalIdScreen> {
|
||||
if (_error != null) {
|
||||
return _ErrorView(message: _error!, onRetry: _load);
|
||||
}
|
||||
if (_docs == null) {
|
||||
return _buildOnboarding(cs);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
children: [
|
||||
if (_docs != null) ..._buildProfile(cs, _docs!),
|
||||
if (_docs == null) _buildOnboarding(cs),
|
||||
..._buildProfile(cs, _docs!),
|
||||
if (_cards.isNotEmpty) ..._buildCards(cs),
|
||||
const SizedBox(height: 16),
|
||||
_buildBiometryInfo(cs),
|
||||
@@ -190,51 +200,79 @@ class _DigitalIdScreenState extends State<DigitalIdScreen> {
|
||||
}
|
||||
|
||||
Widget _buildOnboarding(ColorScheme cs) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Symbols.badge, size: 40, color: cs.primary),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Цифровой ID не настроен',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.onSurface,
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Symbols.badge, size: 72, color: cs.primary),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Цифровой ID не настроен',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
_needsGosuslugi
|
||||
? 'Привяжите аккаунт Госуслуг, чтобы документы появились в Цифровом ID. Номер телефона в MAX должен совпадать с номером в профиле Госуслуг.'
|
||||
: 'Привяжите Госуслуги, чтобы получить доступ к документам, или обновите страницу, если уже настраивали Цифровой ID.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_needsGosuslugi
|
||||
? 'Привяжите аккаунт Госуслуг, чтобы документы появились в Цифровом ID. Номер телефона в MAX должен совпадать с номером в профиле Госуслуг.'
|
||||
: 'Привяжите Госуслуги, чтобы получить доступ к документам, или обновите страницу, если уже настраивали Цифровой ID.',
|
||||
style: TextStyle(fontSize: 14, color: cs.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: _busy ? null : _linkGosuslugi,
|
||||
icon: _busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Symbols.link),
|
||||
label: const Text('Привязать Госуслуги'),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
TextButton.icon(
|
||||
onPressed: _busy ? null : _loadDocsExplicit,
|
||||
icon: const Icon(Symbols.sync),
|
||||
label: const Text('Загрузить документы'),
|
||||
),
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _busy ? null : _loadDocsExplicit,
|
||||
icon: const Icon(Symbols.sync, size: 18),
|
||||
label: const Text(
|
||||
'Загрузить документы',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _busy ? null : _linkGosuslugi,
|
||||
icon: _busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Symbols.link, size: 18),
|
||||
label: const Text(
|
||||
'Привязать Госуслуги',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildBiometryInfo(cs),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../backend/modules/webapp.dart';
|
||||
import '../../../main.dart' show webAppModule;
|
||||
import '../../../main.dart' show webAppModule, digitalIdModule;
|
||||
import '../../widgets/webview_permission_prompt.dart';
|
||||
|
||||
Future<void> resetDigitalIdWebData() async {
|
||||
await CookieManager.instance().deleteAllCookies();
|
||||
@@ -14,6 +15,13 @@ Future<void> resetDigitalIdWebData() async {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> resetDigitalIdSession() async {
|
||||
digitalIdModule.reset();
|
||||
try {
|
||||
await resetDigitalIdWebData();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const String _kBridge = r'''
|
||||
(function(){
|
||||
var sawOpenLink = false;
|
||||
@@ -256,12 +264,8 @@ class _DigitalIdWebScreenState extends State<DigitalIdWebScreen> {
|
||||
},
|
||||
);
|
||||
},
|
||||
onPermissionRequest: (controller, request) async {
|
||||
return PermissionResponse(
|
||||
resources: request.resources,
|
||||
action: PermissionResponseAction.GRANT,
|
||||
);
|
||||
},
|
||||
onPermissionRequest: (controller, request) =>
|
||||
askWebViewPermission(context, request),
|
||||
shouldOverrideUrlLoading: (controller, action) async {
|
||||
final uri = action.request.url;
|
||||
final url = uri?.toString() ?? '';
|
||||
|
||||
@@ -32,10 +32,11 @@ class _InfoScreenState extends State<InfoScreen> {
|
||||
return;
|
||||
}
|
||||
final jsonStr = await AppDatabase.getLoginInfo(accountId);
|
||||
if (!mounted) return;
|
||||
if (jsonStr != null) {
|
||||
setState(() => _info = jsonDecode(jsonStr) as Map<String, dynamic>);
|
||||
}
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
setState(() => _isLoading = false);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Error: $e');
|
||||
|
||||
@@ -16,7 +16,6 @@ class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
|
||||
bool _isLoading = true;
|
||||
bool _is2faEnabled = false;
|
||||
bool _isAuthenticated = false;
|
||||
String? _verifiedPassword;
|
||||
TwoFactorDetails? _details;
|
||||
|
||||
final _passwordController = TextEditingController();
|
||||
@@ -47,9 +46,9 @@ class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isAuthenticated = true;
|
||||
_verifiedPassword = _passwordController.text;
|
||||
_details = details;
|
||||
});
|
||||
_passwordController.clear();
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _errorMessage = 'Неверный пароль');
|
||||
} finally {
|
||||
@@ -57,6 +56,45 @@ class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _promptPassword() async {
|
||||
final controller = TextEditingController();
|
||||
try {
|
||||
return await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Подтвердите пароль'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
obscureText: true,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(hintText: 'Текущий пароль'),
|
||||
onSubmitted: (v) => Navigator.of(ctx).pop(v),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(controller.text),
|
||||
child: const Text('Продолжить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
controller.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openWithPassword(Widget Function(String password) builder) async {
|
||||
final password = await _promptPassword();
|
||||
if (password == null || password.isEmpty || !mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => builder(password)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _check2faStatus() async {
|
||||
try {
|
||||
bool is2faEnabled;
|
||||
@@ -336,13 +374,8 @@ class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
|
||||
icon: Symbols.password,
|
||||
label: 'Изменить пароль',
|
||||
isLast: false,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TwoFactorPasswordChangeScreen(
|
||||
currentPassword: _verifiedPassword!,
|
||||
),
|
||||
),
|
||||
onTap: () => _openWithPassword(
|
||||
(pwd) => TwoFactorPasswordChangeScreen(currentPassword: pwd),
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
@@ -354,13 +387,8 @@ class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
|
||||
icon: Icons.email_outlined,
|
||||
label: 'Изменить почту',
|
||||
isLast: false,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TwoFactorEmailChangeScreen(
|
||||
currentPassword: _verifiedPassword!,
|
||||
),
|
||||
),
|
||||
onTap: () => _openWithPassword(
|
||||
(pwd) => TwoFactorEmailChangeScreen(currentPassword: pwd),
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
@@ -373,13 +401,8 @@ class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
|
||||
label: 'Удалить пароль',
|
||||
isLast: true,
|
||||
textColor: cs.error,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TwoFactorRemoveScreen(
|
||||
currentPassword: _verifiedPassword!,
|
||||
),
|
||||
),
|
||||
onTap: () => _openWithPassword(
|
||||
(pwd) => TwoFactorRemoveScreen(currentPassword: pwd),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -541,6 +564,7 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
||||
break;
|
||||
}
|
||||
final trackId = await accountModule.create2faTrack();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_trackId = trackId;
|
||||
_step = 1;
|
||||
@@ -555,12 +579,14 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
||||
_trackId!,
|
||||
_passwordController.text,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _step = 2);
|
||||
break;
|
||||
case 2:
|
||||
if (_hintController.text.isNotEmpty) {
|
||||
await accountModule.set2faHint(_trackId!, _hintController.text);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _step = 3);
|
||||
break;
|
||||
case 3:
|
||||
@@ -573,6 +599,7 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
||||
break;
|
||||
}
|
||||
await accountModule.verify2faEmail(_trackId!, _emailController.text);
|
||||
if (!mounted) return;
|
||||
setState(() => _step = 4);
|
||||
break;
|
||||
case 4:
|
||||
@@ -585,7 +612,7 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _errorMessage = e.toString());
|
||||
if (mounted) setState(() => _errorMessage = e.toString());
|
||||
} finally {
|
||||
if (mounted) {
|
||||
_isLoading.value = false;
|
||||
@@ -976,7 +1003,7 @@ class _TwoFactorPasswordChangeScreenState
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _errorMessage = e.toString());
|
||||
if (mounted) setState(() => _errorMessage = e.toString());
|
||||
} finally {
|
||||
if (mounted) _isLoading.value = false;
|
||||
}
|
||||
@@ -1144,6 +1171,7 @@ class _TwoFactorEmailChangeScreenState
|
||||
}
|
||||
final trackId = await _ensureTrack();
|
||||
await accountModule.verify2faEmail(trackId, _emailController.text);
|
||||
if (!mounted) return;
|
||||
setState(() => _step = 1);
|
||||
break;
|
||||
case 1:
|
||||
@@ -1164,7 +1192,7 @@ class _TwoFactorEmailChangeScreenState
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _errorMessage = e.toString());
|
||||
if (mounted) setState(() => _errorMessage = e.toString());
|
||||
} finally {
|
||||
if (mounted) _isLoading.value = false;
|
||||
}
|
||||
@@ -1333,7 +1361,7 @@ class _TwoFactorRemoveScreenState extends State<TwoFactorRemoveScreen> {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _errorMessage = e.toString());
|
||||
if (mounted) setState(() => _errorMessage = e.toString());
|
||||
} finally {
|
||||
if (mounted) _isLoading.value = false;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../../widgets/sheet_helpers.dart';
|
||||
import '../auth/login_screen.dart';
|
||||
import '../auth/proxy_settings_sheet.dart';
|
||||
import '../../../core/config/app_digital_id_mode.dart';
|
||||
import '../../../core/utils/webview_support.dart';
|
||||
import '../digital_id/digital_id_screen.dart';
|
||||
import '../digital_id/digital_id_web_screen.dart';
|
||||
import '../webapp/web_app_screen.dart';
|
||||
@@ -212,6 +213,7 @@ class _SettingsTabState extends State<SettingsTab> {
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ChatsModule.resetForAccountSwitch();
|
||||
await resetDigitalIdSession();
|
||||
try {
|
||||
await api.connect();
|
||||
} catch (_) {}
|
||||
@@ -259,7 +261,9 @@ class _SettingsTabState extends State<SettingsTab> {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AppDigitalIdNative.current.value
|
||||
builder: (context) =>
|
||||
AppDigitalIdNative.current.value ||
|
||||
!webViewSupported
|
||||
? const DigitalIdScreen()
|
||||
: const DigitalIdWebScreen(),
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../backend/modules/webapp.dart';
|
||||
import '../../widgets/webview_permission_prompt.dart';
|
||||
|
||||
class WebAppScreen extends StatefulWidget {
|
||||
final String title;
|
||||
@@ -118,6 +119,8 @@ class _WebAppScreenState extends State<WebAppScreen> {
|
||||
useHybridComposition: true,
|
||||
),
|
||||
onWebViewCreated: (controller) => _controller = controller,
|
||||
onPermissionRequest: (controller, request) =>
|
||||
askWebViewPermission(context, request),
|
||||
onProgressChanged: (controller, progress) {
|
||||
if (!mounted) return;
|
||||
setState(() => _progress = progress / 100);
|
||||
|
||||
@@ -11,7 +11,7 @@ void showCustomNotificationOnOverlay(OverlayState overlay, String message) {
|
||||
);
|
||||
overlay.insert(entry);
|
||||
Future.delayed(const Duration(milliseconds: 2600), () {
|
||||
entry.remove();
|
||||
if (entry.mounted) entry.remove();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ class _BubbleCtx {
|
||||
}) : dim = text.withValues(alpha: 0.7);
|
||||
}
|
||||
|
||||
final Expando<MessageType> _contentTypeCache = Expando<MessageType>();
|
||||
final Expando<String> _clockTextCache = Expando<String>();
|
||||
|
||||
class MessageBubble extends StatelessWidget {
|
||||
static const double photoMaxSize = 280.0;
|
||||
static const double photoMinSize = 100.0;
|
||||
@@ -129,6 +132,13 @@ class MessageBubble extends StatelessWidget {
|
||||
return BubbleShape.groupedMiddle;
|
||||
}
|
||||
|
||||
MessageType get _contentType =>
|
||||
_contentTypeCache[message] ??= _computeContentType();
|
||||
|
||||
String get _clockText =>
|
||||
_clockTextCache[message] ??=
|
||||
formatClock(DateTime.fromMillisecondsSinceEpoch(message.time));
|
||||
|
||||
MessageType _computeContentType() {
|
||||
if (message.isControl) return MessageType.control;
|
||||
final attachments = message.attachments;
|
||||
@@ -269,7 +279,7 @@ class MessageBubble extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final contentType = _computeContentType();
|
||||
final contentType = _contentType;
|
||||
|
||||
if (message.isControl) {
|
||||
const controlShape = BubbleShape.singleMiddle;
|
||||
@@ -577,8 +587,8 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
final metaWidget = Text(
|
||||
message.status == 'EDITED'
|
||||
? '${formatClock(DateTime.fromMillisecondsSinceEpoch(message.time))} ред.'
|
||||
: formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)),
|
||||
? '$_clockText ред.'
|
||||
: _clockText,
|
||||
style: TextStyle(color: ctx.dim, fontSize: 10),
|
||||
);
|
||||
|
||||
@@ -1833,7 +1843,7 @@ class MessageBubble extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)),
|
||||
_clockText,
|
||||
style: TextStyle(color: ctx.dim, fontSize: 11),
|
||||
),
|
||||
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)],
|
||||
@@ -1854,7 +1864,7 @@ class MessageBubble extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)),
|
||||
_clockText,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
@@ -2202,6 +2212,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||||
|
||||
TranscriptionCache.put(widget.messageId, result);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_transcriptionLoading = false;
|
||||
if (result.status == 1) {
|
||||
@@ -2215,6 +2226,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_transcriptionLoading = false;
|
||||
_transcriptionText = 'ошибка транскрибации';
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
String _resourceLabel(PermissionResourceType type) {
|
||||
if (type == PermissionResourceType.CAMERA) return 'камера';
|
||||
if (type == PermissionResourceType.MICROPHONE) return 'микрофон';
|
||||
if (type == PermissionResourceType.CAMERA_AND_MICROPHONE) {
|
||||
return 'камера и микрофон';
|
||||
}
|
||||
if (type == PermissionResourceType.GEOLOCATION) return 'геолокация';
|
||||
return 'дополнительный доступ';
|
||||
}
|
||||
|
||||
Future<PermissionResponse> askWebViewPermission(
|
||||
BuildContext context,
|
||||
PermissionRequest request,
|
||||
) async {
|
||||
PermissionResponse deny() => PermissionResponse(
|
||||
resources: request.resources,
|
||||
action: PermissionResponseAction.DENY,
|
||||
);
|
||||
|
||||
if (!context.mounted) return deny();
|
||||
|
||||
final labels = <String>{
|
||||
for (final r in request.resources) _resourceLabel(r),
|
||||
}.join(', ');
|
||||
final host = request.origin.host.isNotEmpty
|
||||
? request.origin.host
|
||||
: 'Веб-страница';
|
||||
|
||||
final granted = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Запрос доступа'),
|
||||
content: Text('$host запрашивает доступ к: $labels.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Запретить'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Разрешить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return PermissionResponse(
|
||||
resources: request.resources,
|
||||
action: granted == true
|
||||
? PermissionResponseAction.GRANT
|
||||
: PermissionResponseAction.DENY,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user