fix: большой фикс недочетов и фикс пары язв

This commit is contained in:
Jganenok
2026-06-09 01:00:04 +07:00
parent 73ad6dd1e6
commit 580e30897b
32 changed files with 590 additions and 244 deletions
@@ -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) {
+59 -58
View File
@@ -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();
});
}
+17 -5
View File
@@ -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,
);
}