diff --git a/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt b/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt index 96e54c1..1764ee4 100644 --- a/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt +++ b/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt @@ -188,6 +188,7 @@ class KometNotifier(private val ctx: Context) { .setWhen(ts) .setShowWhen(true) .setNumber(entries.size) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) .setContentTitle("Komet") .setContentText(boldLine(senderName, text)) .setStyle(inbox) @@ -250,6 +251,8 @@ class KometNotifier(private val ctx: Context) { .setIntent(intent) .setPerson(person) .setIcon(person.icon) + .setCategories(setOf(ShortcutInfoCompat.SHORTCUT_CATEGORY_CONVERSATION)) + .setLocusId(LocusIdCompat(id)) .build() ShortcutManagerCompat.pushDynamicShortcut(ctx, shortcut) } catch (e: Exception) { diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index 3dc2e74..98fd9c2 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -312,6 +312,11 @@ class MainActivity : FlutterActivity() { CallForegroundService.start(applicationContext, caller) result.success(null) } + "ensureOngoing" -> { + val caller = call.argument("caller") ?: "Звонок" + CallForegroundService.start(applicationContext, caller) + result.success(null) + } "setScreenShare" -> { val enabled = call.argument("enabled") ?: false val caller = call.argument("caller") ?: "Звонок" diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index c95ecc9..63f8a12 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -475,6 +475,7 @@ class ContactsModule { int from = 0, int count = 25, }) async { + if (contactId <= 0) return ContactPhotos.empty; final map = await api.sendRequestMap(Opcode.contactPhotos, { 'contactId': contactId, 'from': from, @@ -502,14 +503,45 @@ class ContactsModule { } static const List _debugFirstNames = [ - 'Алиса', 'Борис', 'Вера', 'Глеб', 'Дарья', 'Егор', 'Жанна', 'Захар', - 'Ирина', 'Кирилл', 'Лия', 'Максим', 'Нина', 'Олег', 'Полина', 'Роман', - 'София', 'Тимур', 'Ульяна', 'Фёдор', 'Ханна', 'Цветана', 'Чеслав', 'Шура', + 'Алиса', + 'Борис', + 'Вера', + 'Глеб', + 'Дарья', + 'Егор', + 'Жанна', + 'Захар', + 'Ирина', + 'Кирилл', + 'Лия', + 'Максим', + 'Нина', + 'Олег', + 'Полина', + 'Роман', + 'София', + 'Тимур', + 'Ульяна', + 'Фёдор', + 'Ханна', + 'Цветана', + 'Чеслав', + 'Шура', ]; static const List _debugLastNames = [ - 'Иванов', 'Петров', 'Сидоров', 'Кузнецов', 'Смирнов', 'Попов', 'Волков', - 'Соколов', 'Морозов', 'Новиков', 'Фёдоров', 'Козлов', + 'Иванов', + 'Петров', + 'Сидоров', + 'Кузнецов', + 'Смирнов', + 'Попов', + 'Волков', + 'Соколов', + 'Морозов', + 'Новиков', + 'Фёдоров', + 'Козлов', ]; static List debugContacts() { diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 98b24af..14c01d0 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -149,16 +149,92 @@ class TranscriptionResult { class TranscriptionCache { static final Map _cache = {}; + static final Map> _listeners = {}; + static final Set _expanded = {}; - static void put(String messageId, TranscriptionResult result) { + static void put( + String messageId, + TranscriptionResult result, { + bool expanded = false, + }) { _cache[messageId] = result; + if (expanded) _expanded.add(messageId); + final listeners = _listeners[messageId]; + if (listeners == null) return; + for (final listener in listeners.toList()) { + listener(); + } } static TranscriptionResult? get(String messageId) => _cache[messageId]; static bool has(String messageId) => _cache.containsKey(messageId); - static void clear() => _cache.clear(); + static bool isExpanded(String messageId) => _expanded.contains(messageId); + + static void setExpanded(String messageId, bool value) { + if (value) { + _expanded.add(messageId); + } else { + _expanded.remove(messageId); + } + } + + static void listen(String messageId, VoidCallback listener) { + _listeners.putIfAbsent(messageId, () => {}).add(listener); + } + + static void unlisten(String messageId, VoidCallback listener) { + final listeners = _listeners[messageId]; + if (listeners == null) return; + listeners.remove(listener); + if (listeners.isEmpty) _listeners.remove(messageId); + } + + static void clear() { + _cache.clear(); + _expanded.clear(); + } +} + +class TranscriptionPushHandler { + static StreamSubscription? _sub; + + static void attach(Api api) { + _sub?.cancel(); + _sub = api.pushStream + .where((p) => p.opcode == Opcode.transcriptionResult) + .listen(_onPush); + } + + static void _onPush(Packet packet) { + final payload = packet.payload; + if (payload is! Map) return; + final source = payload['message'] is Map + ? payload['message'] as Map + : payload; + + final messageId = (source['messageId'] ?? source['msgId'])?.toString(); + if (messageId == null || messageId.isEmpty) return; + + final status = source['transcriptionStatus'] as int? ?? 1; + final rawText = source['transcription'] as String?; + if (status != 1) return; + + TranscriptionCache.put( + messageId, + TranscriptionResult( + status: 1, + text: (rawText == null || rawText.isEmpty) + ? 'не удалось распознать текст' + : rawText, + messageId: messageId, + chatId: source['chatId'] as int?, + mediaId: source['mediaId'] as int?, + ), + expanded: true, + ); + } } class FileHistoryEntry { diff --git a/lib/core/calls/call_bridge.dart b/lib/core/calls/call_bridge.dart index e8f9092..3de35ab 100644 --- a/lib/core/calls/call_bridge.dart +++ b/lib/core/calls/call_bridge.dart @@ -78,6 +78,15 @@ class CallBridge { } } + Future ensureOngoing({String? caller}) async { + if (!_android) return; + try { + await _method.invokeMethod('ensureOngoing', {'caller': caller}); + } catch (e) { + logger.w('CallBridge.ensureOngoing: $e'); + } + } + Future setScreenShare(bool enabled, {String? caller}) async { if (!_android) return; try { diff --git a/lib/core/calls/call_session.dart b/lib/core/calls/call_session.dart index dac6913..0eea9d6 100644 --- a/lib/core/calls/call_session.dart +++ b/lib/core/calls/call_session.dart @@ -78,6 +78,7 @@ class CallSession { int _peerDeviceIdx = 0; bool _muted = false; + bool _speakerOn = false; bool _accepted = false; bool _peerMuted = false; bool _peerVideo = false; @@ -207,6 +208,7 @@ class CallSession { bool get peerIsKomet => _peerIsKomet; bool get isMuted => _muted; + bool get isSpeaker => _speakerOn; bool get peerMuted => _peerMuted; bool get peerVideo => _peerVideo; bool get mediaConnected => _mediaConnected; @@ -857,6 +859,7 @@ class CallSession { if (role == CallRole.joiner || _topology == 'SERVER') { _setState(CallSessionState.active); } + unawaited(applyAudioRoute()); unawaited(_resolvePath()); unawaited(_collectReceivers()); } @@ -874,6 +877,7 @@ class CallSession { } Future _addLocalMedia(RTCPeerConnection pc) async { + await _prepareAudioSession(); _localStream = await navigator.mediaDevices.getUserMedia({ 'audio': true, 'video': _wantVideo, @@ -881,8 +885,43 @@ class CallSession { for (final track in _localStream!.getTracks()) { await pc.addTrack(track, _localStream!); } + await applyAudioRoute(); } + Future setSpeaker(bool on) async { + if (_speakerOn == on) return; + _speakerOn = on; + await applyAudioRoute(); + _notifyInfo(); + } + + Future _prepareAudioSession() async { + if (!_canRouteAudio) return; + if (defaultTargetPlatform == TargetPlatform.android) { + try { + await Helper.setAndroidAudioConfiguration( + AndroidAudioConfiguration.communication, + ); + } catch (e) { + logger.w('[call] setAndroidAudioConfiguration: $e'); + } + } + await applyAudioRoute(); + } + + Future applyAudioRoute() async { + if (!_canRouteAudio) return; + try { + await Helper.setSpeakerphoneOn(_speakerOn); + } catch (e) { + logger.w('[call] setSpeakerphoneOn($_speakerOn) недоступен: $e'); + } + } + + static bool get _canRouteAudio => + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; + Future _openSfuChannels(RTCPeerConnection pc) async { await _closeSfuChannels(); final commands = SfuCommandChannel(); diff --git a/lib/core/config/app_fonts.dart b/lib/core/config/app_fonts.dart index a118948..624468f 100644 --- a/lib/core/config/app_fonts.dart +++ b/lib/core/config/app_fonts.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'custom_font_service.dart'; + const String kDisplayFontFamily = 'Outfit'; @immutable @@ -25,7 +27,14 @@ class AppFont { final String label; final String? fontFamily; - const AppFont({required this.id, required this.label, this.fontFamily}); + final double metricScale; + + const AppFont({ + required this.id, + required this.label, + this.fontFamily, + this.metricScale = 1.0, + }); bool get isSystem => fontFamily == null; bool get isCustom => id.startsWith(AppFonts.customPrefix); @@ -42,8 +51,18 @@ class AppFonts { static const List builtIn = [ AppFont(id: 'system', label: 'Системный'), - AppFont(id: 'inter', label: 'Inter', fontFamily: 'Inter'), - AppFont(id: 'unbounded', label: 'Unbounded', fontFamily: 'Unbounded'), + AppFont( + id: 'inter', + label: 'Inter', + fontFamily: 'Inter', + metricScale: 0.967, + ), + AppFont( + id: 'unbounded', + label: 'Unbounded', + fontFamily: 'Unbounded', + metricScale: 0.933, + ), ]; static AppFont get fallback => builtIn.first; @@ -53,11 +72,19 @@ class AppFonts { static AppFont resolve(String id) { if (id.startsWith(customPrefix)) { final family = id.substring(customPrefix.length); - return AppFont(id: id, label: family, fontFamily: family); + return AppFont( + id: id, + label: family, + fontFamily: family, + metricScale: CustomFontService.metricScaleFor(family), + ); } return builtIn.firstWhere((f) => f.id == id, orElse: () => fallback); } + static double effectiveScale(String id, double userScale) => + userScale * resolve(id).metricScale; + static String? displayFamily(String id) { final font = resolve(id); return font.isSystem ? kDisplayFontFamily : font.fontFamily; diff --git a/lib/core/config/countries.dart b/lib/core/config/countries.dart index b8a3ed0..666f58d 100644 --- a/lib/core/config/countries.dart +++ b/lib/core/config/countries.dart @@ -39,6 +39,25 @@ final Map countriesByCode = { for (final country in allCountries) country.code: country, }; +const Map primaryCountryByPhoneCode = {'+7': 'RU', '+1': 'US'}; + +bool isPrimaryForPhoneCode(CountryName country) => + primaryCountryByPhoneCode[country.phoneCode] == country.code; + +List sortedByDisplayName( + Iterable countries, + String languageCode, +) { + final list = countries.toList(); + list.sort( + (a, b) => a + .displayName(languageCode) + .toLowerCase() + .compareTo(b.displayName(languageCode).toLowerCase()), + ); + return list; +} + List countriesInServerOrder(Iterable codes) { final out = []; for (final raw in codes) { diff --git a/lib/core/config/custom_font_service.dart b/lib/core/config/custom_font_service.dart index bc34bd2..c7b7c39 100644 --- a/lib/core/config/custom_font_service.dart +++ b/lib/core/config/custom_font_service.dart @@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../utils/logger.dart'; +import 'font_metrics.dart'; class CustomFontService { static const String prefKey = 'app_custom_fonts'; @@ -15,6 +16,9 @@ class CustomFontService { 'AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30'; static final Set _loaded = {}; + static final Map _metricScales = {}; + + static double metricScaleFor(String family) => _metricScales[family] ?? 1.0; static Future> families() async { final prefs = await SharedPreferences.getInstance(); @@ -85,6 +89,10 @@ class CustomFontService { ..addFont(Future.value(ByteData.sublistView(bytes))); await loader.load(); _loaded.add(family); + final xHeight = FontMetrics.xHeightRatio(bytes); + if (xHeight != null) { + _metricScales[family] = FontMetrics.scaleForXHeight(xHeight); + } } static bool _isSfnt(Uint8List b) { diff --git a/lib/core/config/font_metrics.dart b/lib/core/config/font_metrics.dart new file mode 100644 index 0000000..abf5bec --- /dev/null +++ b/lib/core/config/font_metrics.dart @@ -0,0 +1,44 @@ +import 'dart:typed_data'; + +abstract class FontMetrics { + static const double referenceXHeight = 0.528; + static const double minScale = 0.85; + static const double maxScale = 1.15; + + static double scaleForXHeight(double xHeightRatio) { + if (xHeightRatio <= 0) return 1.0; + return (referenceXHeight / xHeightRatio) + .clamp(minScale, maxScale) + .toDouble(); + } + + static double? xHeightRatio(Uint8List bytes) { + try { + if (bytes.length < 12) return null; + final data = ByteData.sublistView(bytes); + if (data.getUint32(0) == 0x74746366) return null; + final numTables = data.getUint16(4); + int? headOffset; + int? os2Offset; + for (var i = 0; i < numTables; i++) { + final record = 12 + i * 16; + if (record + 16 > bytes.length) return null; + final tag = String.fromCharCodes(bytes, record, record + 4); + if (tag == 'head') headOffset = data.getUint32(record + 8); + if (tag == 'OS/2') os2Offset = data.getUint32(record + 8); + } + if (headOffset == null || os2Offset == null) return null; + if (headOffset + 20 > bytes.length || os2Offset + 88 > bytes.length) { + return null; + } + final unitsPerEm = data.getUint16(headOffset + 18); + if (unitsPerEm == 0) return null; + if (data.getUint16(os2Offset) < 2) return null; + final xHeight = data.getInt16(os2Offset + 86); + if (xHeight <= 0) return null; + return xHeight / unitsPerEm; + } catch (_) { + return null; + } + } +} diff --git a/lib/core/contacts/device_contacts_service.dart b/lib/core/contacts/device_contacts_service.dart index b663eb5..d5fe4cc 100644 --- a/lib/core/contacts/device_contacts_service.dart +++ b/lib/core/contacts/device_contacts_service.dart @@ -9,6 +9,7 @@ class DeviceContactsService { DeviceContactsService._(); static const _grantedKey = 'phonebook_granted'; + static const _deniedKey = 'phonebook_denied'; static final Map _byLast10 = {}; static bool _loaded = false; @@ -41,12 +42,17 @@ class DeviceContactsService { await _readBook(); } - static Future ensureLoadedInteractive() async { + static Future ensureLoadedInteractive({bool force = false}) async { if (_loaded || !_supported) return false; if (!AppPhonebookNames.current.value) return false; - final granted = await FlutterContacts.requestPermission(readonly: true); - if (!granted) return false; final prefs = await SharedPreferences.getInstance(); + if (!force && prefs.getBool(_deniedKey) == true) return false; + final granted = await FlutterContacts.requestPermission(readonly: true); + if (!granted) { + await prefs.setBool(_deniedKey, true); + return false; + } + await prefs.remove(_deniedKey); await prefs.setBool(_grantedKey, true); return _readBook(); } @@ -54,14 +60,12 @@ class DeviceContactsService { static Future reload() async { _loaded = false; _byLast10.clear(); - return ensureLoadedInteractive(); + return ensureLoadedInteractive(force: true); } static Future _readBook() async { try { - final contacts = await FlutterContacts.getContacts( - withProperties: true, - ); + final contacts = await FlutterContacts.getContacts(withProperties: true); _byLast10.clear(); for (final contact in contacts) { final name = contact.displayName.trim(); diff --git a/lib/frontend/screens/auth/proxy_settings_sheet.dart b/lib/frontend/screens/auth/proxy_settings_sheet.dart index f55ea1b..532fbc0 100644 --- a/lib/frontend/screens/auth/proxy_settings_sheet.dart +++ b/lib/frontend/screens/auth/proxy_settings_sheet.dart @@ -22,6 +22,7 @@ class _ProxySettingsSheetState extends State { final _usernameController = TextEditingController(); final _passwordController = TextEditingController(); ProxyType _selectedType = ProxyType.none; + ProxySettings _applied = const ProxySettings(); bool _busy = false; @override @@ -34,6 +35,7 @@ class _ProxySettingsSheetState extends State { final settings = await ProxyConfig.load(); if (!mounted) return; setState(() { + _applied = settings; _selectedType = settings.type; _hostController.text = settings.host; _portController.text = '${settings.port}'; @@ -58,18 +60,18 @@ class _ProxySettingsSheetState extends State { try { final username = _usernameController.text.trim(); final password = _passwordController.text.trim(); - await ProxyConfig.save( - ProxySettings( - type: _selectedType, - host: host, - port: port, - username: username.isNotEmpty ? username : null, - password: password.isNotEmpty ? password : null, - ), + final settings = ProxySettings( + type: _selectedType, + host: host, + port: port, + username: username.isNotEmpty ? username : null, + password: password.isNotEmpty ? password : null, ); + await ProxyConfig.save(settings); await api.disconnect(); await api.connect(); if (!mounted) return; + setState(() => _applied = settings); if (api.state == SessionState.online) { showCustomNotification(context, l10n.proxySettingsSaved); } else { @@ -84,7 +86,10 @@ class _ProxySettingsSheetState extends State { setState(() => _busy = true); try { await ProxyConfig.clear(); - setState(() => _selectedType = ProxyType.none); + setState(() { + _selectedType = ProxyType.none; + _applied = const ProxySettings(); + }); await api.disconnect(); await api.connect(); if (!mounted) return; @@ -134,6 +139,11 @@ class _ProxySettingsSheetState extends State { fontWeight: FontWeight.w600, ), ), + const SizedBox(height: 4), + Text( + l10n.proxyCurrentState(_appliedLabel(l10n)), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), const SizedBox(height: 20), // Proxy type selector @@ -191,7 +201,9 @@ class _ProxySettingsSheetState extends State { const SizedBox(height: 16), FilledButton( - onPressed: _busy ? null : () => _apply(l10n), + onPressed: (_busy || !(isActive || _applied.isEnabled)) + ? null + : () => _apply(l10n), child: Text(isActive ? l10n.proxyApply : l10n.proxyDisable), ), ], @@ -201,6 +213,14 @@ class _ProxySettingsSheetState extends State { ); } + String _appliedLabel(AppLocalizations l10n) { + if (!_applied.isEnabled) return l10n.proxyTypeNone; + final type = _applied.type == ProxyType.socks5 + ? l10n.proxyTypeSocks5 + : l10n.proxyTypeHttp; + return '$type · ${_applied.host}:${_applied.port}'; + } + Widget _buildTypeSelector(ColorScheme cs, AppLocalizations l10n) { final labels = { ProxyType.none: l10n.proxyTypeNone, diff --git a/lib/frontend/screens/auth/select_country_screen.dart b/lib/frontend/screens/auth/select_country_screen.dart index 31a5793..a32ba21 100644 --- a/lib/frontend/screens/auth/select_country_screen.dart +++ b/lib/frontend/screens/auth/select_country_screen.dart @@ -28,18 +28,24 @@ class _CountrySearchEntry { class _SelectCountryScreenState extends State { bool _isSearching = false; final TextEditingController _searchController = TextEditingController(); - late List _filteredCountries; - late final List<_CountrySearchEntry> _searchEntries; + List _sortedCountries = const []; + List _filteredCountries = const []; + List<_CountrySearchEntry> _searchEntries = const []; + String _lang = ''; @override - void initState() { - super.initState(); - _filteredCountries = widget.countries; - _searchEntries = widget.countries + void didChangeDependencies() { + super.didChangeDependencies(); + final lang = Localizations.localeOf(context).languageCode; + if (lang == _lang) return; + _lang = lang; + _sortedCountries = sortedByDisplayName(widget.countries, lang); + _searchEntries = _sortedCountries .map( (c) => _CountrySearchEntry(c, c.ru.toLowerCase(), c.en.toLowerCase()), ) .toList(); + _filteredCountries = _applyFilter(_searchController.text); } @override @@ -48,23 +54,38 @@ class _SelectCountryScreenState extends State { super.dispose(); } + List _applyFilter(String query) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return _sortedCountries; + final matches = _searchEntries + .where( + (e) => + e.ruLower.contains(q) || + e.enLower.contains(q) || + e.country.phoneCode.contains(q), + ) + .map((e) => e.country) + .toList(); + if (_looksLikePhoneCode(q)) { + matches.sort((a, b) { + final byPrimary = + (isPrimaryForPhoneCode(b) ? 1 : 0) - + (isPrimaryForPhoneCode(a) ? 1 : 0); + if (byPrimary != 0) return byPrimary; + return a + .displayName(_lang) + .toLowerCase() + .compareTo(b.displayName(_lang).toLowerCase()); + }); + } + return matches; + } + + static bool _looksLikePhoneCode(String query) => + RegExp(r'^\+?\d+$').hasMatch(query); + void _filterCountries(String query) { - setState(() { - if (query.isEmpty) { - _filteredCountries = widget.countries; - } else { - final q = query.toLowerCase(); - _filteredCountries = _searchEntries - .where( - (e) => - e.ruLower.contains(q) || - e.enLower.contains(q) || - e.country.phoneCode.contains(q), - ) - .map((e) => e.country) - .toList(); - } - }); + setState(() => _filteredCountries = _applyFilter(query)); } @override @@ -118,7 +139,7 @@ class _SelectCountryScreenState extends State { _isSearching = !_isSearching; if (!_isSearching) { _searchController.clear(); - _filteredCountries = widget.countries; + _filteredCountries = _sortedCountries; } }); }, diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index 92f53f2..67ebf9f 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -2,12 +2,10 @@ import 'dart:async'; import 'dart:math' show cos, pi; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/foundation.dart' show defaultTargetPlatform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' show - Helper, MediaStream, RTCVideoRenderer, RTCVideoValue, @@ -22,7 +20,6 @@ import '../../../core/calls/call_info.dart'; import '../../../core/calls/call_session.dart'; import '../../../core/config/app_colors.dart'; import '../../../core/utils/format.dart'; -import '../../../core/utils/logger.dart'; import '../../../l10n/app_localizations.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; @@ -246,6 +243,7 @@ class _CallScreenState extends State with TickerProviderStateMixin { _resolveParticipants(); _syncVideo(); _syncLocalPreview(); + _isSpeaker = session.isSpeaker; setState(() {}); }); _remoteStreamSub = session.remoteStreamStream.listen(_attachStream); @@ -259,6 +257,7 @@ class _CallScreenState extends State with TickerProviderStateMixin { if (existing != null) _attachStream(existing); _resolveParticipants(); _syncVideo(); + _isSpeaker = session.isSpeaker; } void _showKometBadge() { @@ -356,19 +355,12 @@ class _CallScreenState extends State with TickerProviderStateMixin { await _session?.setMuted(next); } - static bool get _hasSpeakerphone => - defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS; - Future _toggleSpeaker() async { - final next = !_isSpeaker; + final session = _session; + if (session == null) return; + final next = !session.isSpeaker; setState(() => _isSpeaker = next); - if (!_hasSpeakerphone) return; - try { - await Helper.setSpeakerphoneOn(next); - } catch (e) { - logger.w('[call] setSpeakerphoneOn недоступен: $e'); - } + await session.setSpeaker(next); } bool _videoBusy = false; diff --git a/lib/frontend/screens/chats/chat/view/chat_header.dart b/lib/frontend/screens/chats/chat/view/chat_header.dart index a91a15e..5c800f8 100644 --- a/lib/frontend/screens/chats/chat/view/chat_header.dart +++ b/lib/frontend/screens/chats/chat/view/chat_header.dart @@ -406,7 +406,10 @@ class ChatHeaderRow extends StatelessWidget { ); } - int get _storyOwnerId => chatType == 'DIALOG' ? chatId ^ myId : chatId; + bool get _isSavedMessages => chatId == 0; + + int get _storyOwnerId => + chatType == 'DIALOG' ? (_isSavedMessages ? 0 : chatId ^ myId) : chatId; Widget _heroAvatar( double size, @@ -469,7 +472,8 @@ class ChatHeaderRow extends StatelessWidget { Widget _withOnlineDot(ColorScheme cs, Widget avatar, {double dotSize = 12}) { final otherId = chatId ^ myId; - final showDot = chatType == 'DIALOG' && myId != 0 && otherId > 0; + final showDot = + chatType == 'DIALOG' && myId != 0 && otherId > 0 && !_isSavedMessages; return Stack( clipBehavior: Clip.none, children: [ diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index fae7f0c..ab5e264 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -172,6 +172,7 @@ class _ChatInfoScreenState extends State double _headerDelta = 0; bool _expandArmed = false; + bool _headerDragging = false; bool _headerEverExpanded = false; @override @@ -648,11 +649,15 @@ class _ChatInfoScreenState extends State bool _onHeaderScrollNotification(ScrollNotification n, double delta) { if (n.depth != 0) return false; if (n is ScrollStartNotification) { + _headerDragging = n.dragDetails != null; if (n.dragDetails != null) { _expandArmed = delta > 0 && n.metrics.pixels <= delta + 8; } } else if (n is ScrollEndNotification) { - _snapHeader(delta); + if (_headerDragging) { + _headerDragging = false; + _snapHeader(delta); + } } return false; } @@ -660,12 +665,10 @@ class _ChatInfoScreenState extends State void _snapHeader(double delta) { final c = _bodyScrollController; if (c == null || !c.hasClients || delta <= 0) return; + final collapsed = math.min(delta, c.position.maxScrollExtent); final offset = c.offset; - if (offset <= 0 || offset >= delta) return; - final target = (offset < delta / 2 ? 0.0 : delta).clamp( - 0.0, - c.position.maxScrollExtent, - ); + if (offset <= 0 || offset >= collapsed) return; + final target = offset < collapsed / 2 ? 0.0 : collapsed; if ((target - offset).abs() < 1) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !c.hasClients) return; diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 92e34a1..5913abb 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -94,6 +94,8 @@ import '../downloads_screen.dart'; import '../../widgets/media_playback_pill.dart'; import '../../../core/config/app_fonts.dart'; +const String _savedWelcomeKey = 'welcome.saved.dialog.message'; + class _StoriesScrollPhysics extends BouncingScrollPhysics { final bool Function() blockPositive; final bool Function() allowPullOverscrollTop; @@ -1894,6 +1896,8 @@ class _ChatListScreenState extends State ); } else { final isPlaceholder = chat.isLastMsgDeleted; + final isSavedWelcome = + chat.id == 0 && chat.lastMsgText == _savedWelcomeKey; final sender = chat.lastMsgSenderId != null ? ContactCache.get(chat.lastMsgSenderId!) : null; @@ -1906,6 +1910,10 @@ class _ChatListScreenState extends State : ""; final body = isPlaceholder ? 'зайдите в чат для подгрузки' + : isSavedWelcome + ? AppLocalizations.of( + context, + )!.savedMessagesEmptyPreview : (chat.lastMsgTextOneLine ?? ''); return _animateChatTile( @@ -1924,16 +1932,16 @@ class _ChatListScreenState extends State isVerified: chat.isOfficial, isPinned: isPinned, chatType: chat.type, - messageItalic: isPlaceholder, + messageItalic: isPlaceholder || isSavedWelcome, draft: chat.id == 0 ? null : _draftFor(chat.id), ownStatus: _ownStatusFor(chat, isPlaceholder), ownRead: chat.lastMsgReadByOthers, - messageRanges: isPlaceholder + messageRanges: isPlaceholder || isSavedWelcome ? const [] : chat.lastMsgFormatRanges, previewMessageId: isPlaceholder ? null : chat.lastMsgId, previewPrefix: senderPrefix, - previewCipherText: isPlaceholder + previewCipherText: isPlaceholder || isSavedWelcome ? null : chat.lastMsgText, previewMedia: isPlaceholder ? null : chat.lastMsgMedia, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index b0805b8..7cb7f57 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -3247,6 +3247,7 @@ class _ChatScreenState extends State showCall: !_commentsMode && widget.chatType == 'DIALOG' && + widget.chatId != 0 && !_peerIsBot, onClose: widget.onClose, onOpenInfo: _commentsMode ? () {} : _openChatInfo, @@ -4137,6 +4138,7 @@ class _ChatScreenState extends State int? _resolveOtherId() { if (widget.chatType != 'DIALOG' || _myId == 0) return null; + if (widget.chatId == 0) return null; final id = widget.chatId ^ _myId; return id > 0 ? id : null; } diff --git a/lib/frontend/screens/chats/search_screen.dart b/lib/frontend/screens/chats/search_screen.dart index 2b94d6f..30b0671 100644 --- a/lib/frontend/screens/chats/search_screen.dart +++ b/lib/frontend/screens/chats/search_screen.dart @@ -145,6 +145,33 @@ class _SearchScreenState extends State { return ContactCache.get(result.id) ?? result.name ?? 'User #${result.id}'; } + ({String name, String? avatar, String type}) _chatIdentity( + int chatId, + String? type, + String? title, + String? iconUrl, + ) { + final fallbackType = type ?? 'CHAT'; + if (chatId == 0) { + return (name: 'Избранное', avatar: iconUrl, type: fallbackType); + } + final me = _accountId ?? 0; + final peer = me == 0 ? 0 : chatId ^ me; + if ((type != null && type != 'DIALOG') || peer <= 0) { + return (name: title ?? '', avatar: iconUrl, type: fallbackType); + } + final cachedName = ContactCache.get(peer); + final cachedAvatar = ContactCache.getAvatar(peer); + final known = cachedName != null && cachedName.isNotEmpty; + return ( + name: known ? cachedName : (title ?? ''), + avatar: (cachedAvatar != null && cachedAvatar.isNotEmpty) + ? cachedAvatar + : iconUrl, + type: known ? 'DIALOG' : fallbackType, + ); + } + void _openChat(int chatId, String name, String? avatarUrl, String type) { pushSwipeable( context, @@ -273,17 +300,7 @@ class _SearchScreenState extends State { ], if (_chats.isNotEmpty) ...[ _sectionHeader(cs, 'Чаты'), - for (final row in _chats) - _ResultTile( - name: (row['title'] as String?) ?? '', - imageUrl: row['icon_url'] as String?, - onTap: () => _openChat( - row['id'] as int, - (row['title'] as String?) ?? '', - row['icon_url'] as String?, - (row['type'] as String?) ?? 'CHAT', - ), - ), + for (final row in _chats) _localChatTile(row), ], if (_messages.isNotEmpty) ...[ _sectionHeader(cs, 'Сообщения'), @@ -305,16 +322,36 @@ class _SearchScreenState extends State { onTap: () => _openChat(hit.id, hit.title ?? '', hit.avatarUrl, hit.type), ); + Widget _localChatTile(Map row) { + final chatId = row['id'] as int; + final identity = _chatIdentity( + chatId, + (row['type'] as String?) ?? 'CHAT', + row['title'] as String?, + row['icon_url'] as String?, + ); + return _ResultTile( + name: identity.name, + imageUrl: identity.avatar, + onTap: () => + _openChat(chatId, identity.name, identity.avatar, identity.type), + ); + } + Widget _messageTile(MessageSearchHit hit) { final meta = _msgChatMeta[hit.chatId]; - final title = (meta?['title'] as String?) ?? 'Чат'; - final icon = meta?['icon_url'] as String?; - final type = (meta?['type'] as String?) ?? 'CHAT'; + final identity = _chatIdentity( + hit.chatId, + meta?['type'] as String?, + meta?['title'] as String?, + meta?['icon_url'] as String?, + ); + final name = identity.name.isEmpty ? 'Чат' : identity.name; return _ResultTile( - name: title, - imageUrl: icon, + name: name, + imageUrl: identity.avatar, subtitle: hit.text?.trim(), - onTap: () => _openChat(hit.chatId, title, icon, type), + onTap: () => _openChat(hit.chatId, name, identity.avatar, identity.type), ); } diff --git a/lib/frontend/screens/profile/blacklist_screen.dart b/lib/frontend/screens/profile/blacklist_screen.dart new file mode 100644 index 0000000..aaf86e8 --- /dev/null +++ b/lib/frontend/screens/profile/blacklist_screen.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/modules/account.dart' show BlockedContact; +import '../../../backend/modules/contacts.dart'; +import '../../../core/config/app_fonts.dart'; +import '../../../core/config/app_shape.dart'; +import '../../../core/utils/names.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../main.dart'; +import '../../widgets/connection_status.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/glossy_pill.dart'; +import '../../widgets/komet_avatar.dart'; +import '../../widgets/reload_on_reconnect.dart'; +import '../../widgets/small_spinner.dart'; +import '../contacts/open_contact_profile.dart'; + +class BlacklistScreen extends StatefulWidget { + const BlacklistScreen({super.key, this.initialContacts}); + + final List? initialContacts; + + @override + State createState() => _BlacklistScreenState(); +} + +class _BlacklistScreenState extends State + with ReloadOnReconnect { + late List _contacts = widget.initialContacts ?? const []; + late bool _isLoading = widget.initialContacts == null; + final Set _pending = {}; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void reloadAfterReconnect() => _load(); + + Future _load() async { + try { + final contacts = await accountModule.getBlockedContacts(); + if (!mounted) return; + setState(() { + _contacts = contacts; + _isLoading = false; + }); + } catch (_) { + if (!mounted) return; + setState(() => _isLoading = false); + showCustomNotification( + context, + AppLocalizations.of(context)!.blacklistLoadError, + ); + } + } + + String _nameOf(BlockedContact contact) => displayName( + contact.firstName, + contact.lastName, + fallback: 'ID ${contact.id}', + ); + + Future _unblock(BlockedContact contact) async { + if (_pending.contains(contact.id)) return; + final l10n = AppLocalizations.of(context)!; + setState(() => _pending.add(contact.id)); + final ok = await ContactsModule.setBlocked(api, contact.id, false); + if (!mounted) return; + setState(() { + _pending.remove(contact.id); + if (ok) _contacts = _contacts.where((c) => c.id != contact.id).toList(); + }); + showCustomNotification( + context, + ok ? l10n.chatInfoUnblockDone : l10n.chatInfoBlockFailed, + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: Column( + children: [ + _buildAppBar(cs), + Expanded(child: _buildBody(cs)), + ], + ), + ), + ); + } + + Widget _buildAppBar(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + children: [ + IconButton( + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + size: 24, + weight: 400, + ), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + ConnectionTitleText( + AppLocalizations.of(context)!.securityBlacklistTitle, + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: displayFontOf(context), + ), + ), + ], + ), + ); + } + + Widget _buildBody(ColorScheme cs) { + if (_isLoading) return const Center(child: SmallSpinner(size: 36)); + if (_contacts.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.block, size: 48, color: cs.outline, weight: 400), + const SizedBox(height: 12), + Text( + AppLocalizations.of(context)!.blacklistEmpty, + style: TextStyle(color: cs.outline, fontSize: 15), + ), + ], + ), + ); + } + return ListView.separated( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 120), + itemCount: _contacts.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) => _buildTile(cs, _contacts[index]), + ); + } + + Widget _buildTile(ColorScheme cs, BlockedContact contact) { + final name = _nameOf(contact); + final busy = _pending.contains(contact.id); + return GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: AppShape.cardRadius, + depth: 6, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => openContactDialogProfile( + context, + contactId: contact.id, + name: name, + avatarUrl: contact.baseUrl, + ), + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + KometAvatar(name: name, size: 44, imageUrl: contact.baseUrl), + const SizedBox(width: 14), + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + busy + ? SmallSpinner(size: 20, color: cs.primary) + : TextButton( + onPressed: () => _unblock(contact), + child: Text( + AppLocalizations.of(context)!.chatInfoMenuUnblock, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart index 05cbd67..251fe30 100644 --- a/lib/frontend/screens/profile/security_screen.dart +++ b/lib/frontend/screens/profile/security_screen.dart @@ -14,6 +14,7 @@ import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; import '../../widgets/small_spinner.dart'; +import 'blacklist_screen.dart'; import 'password_entry_screen.dart'; import '../../../core/config/app_fonts.dart'; import '../../../core/config/app_shape.dart'; @@ -780,6 +781,20 @@ class _SecurityScreenState extends State ); } + Future _openBlacklist() async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BlacklistScreen(initialContacts: _blockedContacts), + ), + ); + if (!mounted) return; + try { + final contacts = await accountModule.getBlockedContacts(); + if (mounted) setState(() => _blockedContacts = contacts); + } catch (_) {} + } + Widget _buildBlacklistSection(ColorScheme cs) { final l10n = AppLocalizations.of(context)!; final count = _blockedContacts.length; @@ -790,10 +805,7 @@ class _SecurityScreenState extends State child: Material( color: Colors.transparent, child: InkWell( - onTap: () => showCustomNotification( - context, - l10n.securityBlacklistNotification('$count'), - ), + onTap: _openBlacklist, borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 192a1b7..15be359 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -63,6 +63,7 @@ class _SettingsTabState extends State with SpectrumSurface { double _headerDelta = 0; bool _headerEverExpanded = false; bool _expandArmed = false; + bool _headerDragging = false; bool _zoneHapticFired = false; bool _pastCommitPoint = false; String? _appVersionLabel; @@ -116,6 +117,7 @@ class _SettingsTabState extends State with SpectrumSurface { bool _handleScrollNotification(ScrollNotification n, double delta) { if (n.depth != 0) return false; if (n is ScrollStartNotification) { + _headerDragging = n.dragDetails != null; if (n.dragDetails != null) { final px = n.metrics.pixels; _expandArmed = delta > 0 && px <= delta + 8; @@ -140,7 +142,10 @@ class _SettingsTabState extends State with SpectrumSurface { } } } else if (n is ScrollEndNotification) { - _snapHeader(delta); + if (_headerDragging) { + _headerDragging = false; + _snapHeader(delta); + } } return false; } @@ -148,9 +153,11 @@ class _SettingsTabState extends State with SpectrumSurface { void _snapHeader(double delta) { final c = _scrollController; if (c == null || !c.hasClients || delta <= 0) return; + final collapsed = math.min(delta, c.position.maxScrollExtent); final offset = c.offset; - if (offset <= 0 || offset >= delta) return; - final target = offset < delta / 2 ? 0.0 : delta; + if (offset <= 0 || offset >= collapsed) return; + final target = offset < collapsed / 2 ? 0.0 : collapsed; + if ((target - offset).abs() < 1) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !c.hasClients) return; c.animateTo( diff --git a/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart index 62a598a..c22adae 100644 --- a/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart @@ -55,6 +55,8 @@ class PhotoBubble extends StatelessWidget { ); } else if (count == 2) { photosWidget = _buildTwoPhotos(ctx, photos[0], photos[1]); + } else if (count == 3) { + photosWidget = _buildThreePhotos(ctx, photos); } else { photosWidget = _buildPhotoGrid(ctx, photos); } @@ -307,6 +309,39 @@ class PhotoBubble extends StatelessWidget { ); } + Widget _buildThreePhotos(BubbleContext ctx, List photos) { + final matchTop = + ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop; + final matchBottom = + ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleBottom; + + return ClipRRect( + borderRadius: _multiPhotoCornerRadius( + matchTop: matchTop, + matchBottom: matchBottom, + isMe: ctx.isMe, + ), + child: AspectRatio( + aspectRatio: 3 / 2, + child: Row( + children: [ + Expanded(flex: 2, child: _buildFillTile(ctx, photos[0], 0)), + const SizedBox(width: 2), + Expanded( + child: Column( + children: [ + Expanded(child: _buildFillTile(ctx, photos[1], 1)), + const SizedBox(height: 2), + Expanded(child: _buildFillTile(ctx, photos[2], 2)), + ], + ), + ), + ], + ), + ), + ); + } + Widget _buildPhotoGrid(BubbleContext ctx, List photos) { final displayCount = photos.length > 4 ? 4 : photos.length; final remaining = photos.length - 4; @@ -361,30 +396,30 @@ class PhotoBubble extends StatelessWidget { return _buildPhotoTile(ctx, photos[index], index); } - Widget _buildPhotoTile(BubbleContext ctx, PhotoAttachment photo, int index) { + Widget _buildPhotoTile(BubbleContext ctx, PhotoAttachment photo, int index) => + AspectRatio(aspectRatio: 1, child: _buildFillTile(ctx, photo, index)); + + Widget _buildFillTile(BubbleContext ctx, PhotoAttachment photo, int index) { final cachePx = (BubbleContext.photoMaxSize / 2 * MediaQuery.of(ctx.context).devicePixelRatio) .round(); - return AspectRatio( - aspectRatio: 1, - child: Stack( - children: [ - _buildPhotoImage( - ctx, - photo, - double.infinity, - double.infinity, - memWidth: cachePx, - memHeight: cachePx, - ), - if (ctx.uploadProgress != null) - _buildUploadOverlay(ctx.uploadProgress!, index), - if (ctx.uploadProgress == null) - _buildTileTapTarget(ctx, index, cachePx), - ], - ), + return Stack( + children: [ + _buildPhotoImage( + ctx, + photo, + double.infinity, + double.infinity, + memWidth: cachePx, + memHeight: cachePx, + ), + if (ctx.uploadProgress != null) + _buildUploadOverlay(ctx.uploadProgress!, index), + if (ctx.uploadProgress == null) + _buildTileTapTarget(ctx, index, cachePx), + ], ); } diff --git a/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart index 90de289..d6d3125 100644 --- a/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart @@ -59,6 +59,8 @@ class VoiceMessageBubble extends StatefulWidget { State createState() => _VoiceMessageBubbleState(); } +const double _transcriptionMaxHeight = 132; + class _VoiceMessageBubbleState extends State { bool _transcriptionVisible = false; String? _transcriptionText; @@ -82,15 +84,39 @@ class _VoiceMessageBubbleState extends State { fallbackDuration: Duration(seconds: widget.duration), ); _audio.failure.addListener(_onFailure); + TranscriptionCache.listen(_sourceMessageId, _onTranscriptionPush); + _adoptCachedTranscription(); } @override void dispose() { + TranscriptionCache.unlisten(_sourceMessageId, _onTranscriptionPush); _audio.failure.removeListener(_onFailure); MediaPlayback.instance.releaseVoice(_audio); super.dispose(); } + void _adoptCachedTranscription() { + final cached = TranscriptionCache.get(_sourceMessageId); + if (cached == null || cached.status != 1) return; + _transcriptionText = cached.text ?? 'не удалось распознать текст'; + _transcriptionVisible = TranscriptionCache.isExpanded(_sourceMessageId); + } + + void _onTranscriptionPush() { + if (!mounted) return; + setState(() { + _transcriptionLoading = false; + _adoptCachedTranscription(); + }); + } + + void _showTranscription(String text) { + _transcriptionText = text; + _transcriptionVisible = true; + TranscriptionCache.setExpanded(_sourceMessageId, true); + } + String get _cacheName => '${widget.audioId ?? _sourceMessageId}.ogg'; int get _sourceChatId => widget.sourceChatId ?? widget.chatId; @@ -359,15 +385,21 @@ class _VoiceMessageBubbleState extends State { curve: Curves.easeOut, alignment: Alignment.topLeft, child: _transcriptionVisible - ? Text( - _transcriptionText ?? '', - style: TextStyle( - color: widget.textColor.withValues(alpha: 0.8), - fontSize: 12, - height: 1.3, + ? ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: _transcriptionMaxHeight, + ), + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Text( + _transcriptionText ?? '', + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.8), + fontSize: 12, + height: 1.3, + ), + ), ), - maxLines: 10, - overflow: TextOverflow.ellipsis, ) : const SizedBox.shrink(), ), @@ -438,16 +470,16 @@ class _VoiceMessageBubbleState extends State { if (_transcriptionVisible && _transcriptionText != null) { setState(() { _transcriptionVisible = false; + TranscriptionCache.setExpanded(_sourceMessageId, false); }); return; } if (TranscriptionCache.has(_sourceMessageId)) { final cached = TranscriptionCache.get(_sourceMessageId)!; - setState(() { - _transcriptionText = cached.text ?? 'не удалось распознать текст'; - _transcriptionVisible = true; - }); + setState( + () => _showTranscription(cached.text ?? 'не удалось распознать текст'), + ); return; } @@ -468,13 +500,13 @@ class _VoiceMessageBubbleState extends State { setState(() { _transcriptionLoading = false; if (result.status == 1) { - _transcriptionText = (result.text == null || result.text!.isEmpty) - ? 'не удалось распознать текст' - : result.text; - _transcriptionVisible = true; + _showTranscription( + (result.text == null || result.text!.isEmpty) + ? 'не удалось распознать текст' + : result.text!, + ); } else if (result.status == 0) { - _transcriptionText = 'транскрибация...'; - _transcriptionVisible = true; + _showTranscription('транскрибация...'); } }); } catch (e) { @@ -482,8 +514,7 @@ class _VoiceMessageBubbleState extends State { if (!mounted) return; setState(() { _transcriptionLoading = false; - _transcriptionText = 'ошибка транскрибации'; - _transcriptionVisible = true; + _showTranscription('ошибка транскрибации'); }); } } diff --git a/lib/frontend/widgets/avatar_history_screen.dart b/lib/frontend/widgets/avatar_history_screen.dart index 4fa47b4..09afe26 100644 --- a/lib/frontend/widgets/avatar_history_screen.dart +++ b/lib/frontend/widgets/avatar_history_screen.dart @@ -64,9 +64,15 @@ class _AvatarHistoryScreenState extends State { final current = widget.currentAvatarUrl; _current = (current != null && current.isNotEmpty) ? current : null; _rebuildPages(); - _load(); + if (_hasHistory) { + _load(); + } else { + _loading = false; + } } + bool get _hasHistory => widget.contactId > 0; + @override void dispose() { _pageController.dispose(); @@ -100,7 +106,9 @@ class _AvatarHistoryScreenState extends State { } Future _loadMore() async { - if (_loadingMore || _history.length >= _historyTotal) return; + if (!_hasHistory || _loadingMore || _history.length >= _historyTotal) { + return; + } _loadingMore = true; final photos = await ContactsModule.fetchPhotos( api, @@ -286,9 +294,8 @@ class _AvatarHistoryScreenState extends State { imageUrl: _pages[i], fit: BoxFit.contain, fadeInDuration: const Duration(milliseconds: 120), - placeholder: (_, _) => const Center( - child: SmallSpinner(size: 36, color: Colors.white), - ), + placeholder: (_, _) => + const Center(child: SmallSpinner(size: 36, color: Colors.white)), errorWidget: (_, _, _) => const Icon(Symbols.broken_image, color: Colors.white54, size: 64), ), diff --git a/lib/frontend/widgets/profile_header_scroll.dart b/lib/frontend/widgets/profile_header_scroll.dart index b4a8b2e..8e34b74 100644 --- a/lib/frontend/widgets/profile_header_scroll.dart +++ b/lib/frontend/widgets/profile_header_scroll.dart @@ -29,6 +29,21 @@ class HeaderPullScrollPhysics extends ScrollPhysics { ); } + @override + double applyBoundaryConditions(ScrollMetrics position, double value) { + if (delta <= 0) { + if (value < position.pixels && + position.pixels <= position.minScrollExtent) { + return value - position.pixels; + } + if (value < position.minScrollExtent && + position.minScrollExtent < position.pixels) { + return value - position.minScrollExtent; + } + } + return super.applyBoundaryConditions(position, value); + } + @override double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { if (delta <= 0 || offset <= 0 || position.pixels <= 0) { @@ -107,8 +122,10 @@ class MorphHeaderDelegate extends SliverPersistentHeaderDelegate { double get maxExtent => expandedExtent; @override - OverScrollHeaderStretchConfiguration get stretchConfiguration => - OverScrollHeaderStretchConfiguration(); + OverScrollHeaderStretchConfiguration? get stretchConfiguration => + expandedExtent > collapsedExtent + ? OverScrollHeaderStretchConfiguration() + : null; @override Widget build( diff --git a/lib/frontend/widgets/profile_hero.dart b/lib/frontend/widgets/profile_hero.dart index edc4d9f..d28bb88 100644 --- a/lib/frontend/widgets/profile_hero.dart +++ b/lib/frontend/widgets/profile_hero.dart @@ -33,9 +33,12 @@ class ProfileHeroAvatar extends StatelessWidget { final from = _AvatarHeroChild.of(fromHeroContext); final to = _AvatarHeroChild.of(toHeroContext); final sharpest = from.size >= to.size ? from : to; - return FittedBox( - fit: BoxFit.fill, - child: SizedBox.square(dimension: sharpest.size, child: sharpest.child), + return Material( + type: MaterialType.transparency, + child: FittedBox( + fit: BoxFit.fill, + child: SizedBox.square(dimension: sharpest.size, child: sharpest.child), + ), ); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b5cb329..2cc1dee 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1150,5 +1150,16 @@ "type": "String" } } - } + }, + "savedMessagesEmptyPreview": "Save something here", + "proxyCurrentState": "Currently: {value}", + "@proxyCurrentState": { + "placeholders": { + "value": { + "type": "String" + } + } + }, + "blacklistEmpty": "Nobody is blocked", + "blacklistLoadError": "Failed to load the blacklist" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 240898e..885303c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5023,6 +5023,30 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'{value} MB/s'** String uploadSpeedMb(String value); + + /// No description provided for @savedMessagesEmptyPreview. + /// + /// In en, this message translates to: + /// **'Save something here'** + String get savedMessagesEmptyPreview; + + /// No description provided for @proxyCurrentState. + /// + /// In en, this message translates to: + /// **'Currently: {value}'** + String proxyCurrentState(String value); + + /// No description provided for @blacklistEmpty. + /// + /// In en, this message translates to: + /// **'Nobody is blocked'** + String get blacklistEmpty; + + /// No description provided for @blacklistLoadError. + /// + /// In en, this message translates to: + /// **'Failed to load the blacklist'** + String get blacklistLoadError; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index cfc8ac6..33c5f80 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2635,4 +2635,18 @@ class AppLocalizationsEn extends AppLocalizations { String uploadSpeedMb(String value) { return '$value MB/s'; } + + @override + String get savedMessagesEmptyPreview => 'Save something here'; + + @override + String proxyCurrentState(String value) { + return 'Currently: $value'; + } + + @override + String get blacklistEmpty => 'Nobody is blocked'; + + @override + String get blacklistLoadError => 'Failed to load the blacklist'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 56889e0..e7df9e0 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2649,4 +2649,18 @@ class AppLocalizationsRu extends AppLocalizations { String uploadSpeedMb(String value) { return '$value МБ/с'; } + + @override + String get savedMessagesEmptyPreview => 'Сохраните что-нибудь'; + + @override + String proxyCurrentState(String value) { + return 'Сейчас: $value'; + } + + @override + String get blacklistEmpty => 'Никто не заблокирован'; + + @override + String get blacklistLoadError => 'Не удалось загрузить чёрный список'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index f656361..933fceb 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -894,5 +894,16 @@ "type": "String" } } - } + }, + "savedMessagesEmptyPreview": "Сохраните что-нибудь", + "proxyCurrentState": "Сейчас: {value}", + "@proxyCurrentState": { + "placeholders": { + "value": { + "type": "String" + } + } + }, + "blacklistEmpty": "Никто не заблокирован", + "blacklistLoadError": "Не удалось загрузить чёрный список" } diff --git a/lib/main.dart b/lib/main.dart index ede895a..ccf1086 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -195,6 +195,7 @@ void main(List args) async { attachInfoCacheApi(api); chats.attachGlobalPushHandlers(api); FoldersModule.attachGlobalPushHandlers(api); + TranscriptionPushHandler.attach(api); commentsModule.attachPushHandlers(api); storiesModule.attach(); unawaited(storiesModule.loadCache()); @@ -595,6 +596,9 @@ class KometAppState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { CallController.instance.appResumed = state == AppLifecycleState.resumed; + if (state == AppLifecycleState.inactive && CallController.instance.isBusy) { + unawaited(CallBridge.instance.ensureOngoing()); + } if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden || state == AppLifecycleState.detached) { @@ -999,10 +1003,11 @@ class KometAppState extends State child: child ?? const SizedBox.shrink(), builder: (context, scale, appChild) { Widget scaledChild = appChild!; - if ((scale - 1.0).abs() > 0.001) { + final effective = AppFonts.effectiveScale(_fontId, scale); + if ((effective - 1.0).abs() > 0.001) { scaledChild = MediaQuery.withClampedTextScaling( - minScaleFactor: scale, - maxScaleFactor: scale, + minScaleFactor: effective, + maxScaleFactor: effective, child: scaledChild, ); }