feat: багофикс по запросам волбачия

This commit is contained in:
Jganenokk
2026-08-15 15:51:52 +07:00
parent 198f137826
commit ecaa132e8f
33 changed files with 904 additions and 154 deletions
@@ -188,6 +188,7 @@ class KometNotifier(private val ctx: Context) {
.setWhen(ts) .setWhen(ts)
.setShowWhen(true) .setShowWhen(true)
.setNumber(entries.size) .setNumber(entries.size)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setContentTitle("Komet") .setContentTitle("Komet")
.setContentText(boldLine(senderName, text)) .setContentText(boldLine(senderName, text))
.setStyle(inbox) .setStyle(inbox)
@@ -250,6 +251,8 @@ class KometNotifier(private val ctx: Context) {
.setIntent(intent) .setIntent(intent)
.setPerson(person) .setPerson(person)
.setIcon(person.icon) .setIcon(person.icon)
.setCategories(setOf(ShortcutInfoCompat.SHORTCUT_CATEGORY_CONVERSATION))
.setLocusId(LocusIdCompat(id))
.build() .build()
ShortcutManagerCompat.pushDynamicShortcut(ctx, shortcut) ShortcutManagerCompat.pushDynamicShortcut(ctx, shortcut)
} catch (e: Exception) { } catch (e: Exception) {
@@ -312,6 +312,11 @@ class MainActivity : FlutterActivity() {
CallForegroundService.start(applicationContext, caller) CallForegroundService.start(applicationContext, caller)
result.success(null) result.success(null)
} }
"ensureOngoing" -> {
val caller = call.argument<String>("caller") ?: "Звонок"
CallForegroundService.start(applicationContext, caller)
result.success(null)
}
"setScreenShare" -> { "setScreenShare" -> {
val enabled = call.argument<Boolean>("enabled") ?: false val enabled = call.argument<Boolean>("enabled") ?: false
val caller = call.argument<String>("caller") ?: "Звонок" val caller = call.argument<String>("caller") ?: "Звонок"
+37 -5
View File
@@ -475,6 +475,7 @@ class ContactsModule {
int from = 0, int from = 0,
int count = 25, int count = 25,
}) async { }) async {
if (contactId <= 0) return ContactPhotos.empty;
final map = await api.sendRequestMap(Opcode.contactPhotos, { final map = await api.sendRequestMap(Opcode.contactPhotos, {
'contactId': contactId, 'contactId': contactId,
'from': from, 'from': from,
@@ -502,14 +503,45 @@ class ContactsModule {
} }
static const List<String> _debugFirstNames = [ static const List<String> _debugFirstNames = [
'Алиса', 'Борис', 'Вера', 'Глеб', 'Дарья', 'Егор', 'Жанна', 'Захар', 'Алиса',
'Ирина', 'Кирилл', 'Лия', 'Максим', 'Нина', 'Олег', 'Полина', 'Роман', 'Борис',
'София', 'Тимур', 'Ульяна', 'Фёдор', 'Ханна', 'Цветана', 'Чеслав', 'Шура', 'Вера',
'Глеб',
'Дарья',
'Егор',
'Жанна',
'Захар',
'Ирина',
'Кирилл',
'Лия',
'Максим',
'Нина',
'Олег',
'Полина',
'Роман',
'София',
'Тимур',
'Ульяна',
'Фёдор',
'Ханна',
'Цветана',
'Чеслав',
'Шура',
]; ];
static const List<String> _debugLastNames = [ static const List<String> _debugLastNames = [
'Иванов', 'Петров', 'Сидоров', 'Кузнецов', 'Смирнов', 'Попов', 'Волков', 'Иванов',
'Соколов', 'Морозов', 'Новиков', 'Фёдоров', 'Козлов', 'Петров',
'Сидоров',
'Кузнецов',
'Смирнов',
'Попов',
'Волков',
'Соколов',
'Морозов',
'Новиков',
'Фёдоров',
'Козлов',
]; ];
static List<CachedContact> debugContacts() { static List<CachedContact> debugContacts() {
+78 -2
View File
@@ -149,16 +149,92 @@ class TranscriptionResult {
class TranscriptionCache { class TranscriptionCache {
static final Map<String, TranscriptionResult> _cache = {}; static final Map<String, TranscriptionResult> _cache = {};
static final Map<String, Set<VoidCallback>> _listeners = {};
static final Set<String> _expanded = {};
static void put(String messageId, TranscriptionResult result) { static void put(
String messageId,
TranscriptionResult result, {
bool expanded = false,
}) {
_cache[messageId] = result; _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 TranscriptionResult? get(String messageId) => _cache[messageId];
static bool has(String messageId) => _cache.containsKey(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, () => <VoidCallback>{}).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<Packet>? _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 { class FileHistoryEntry {
+9
View File
@@ -78,6 +78,15 @@ class CallBridge {
} }
} }
Future<void> ensureOngoing({String? caller}) async {
if (!_android) return;
try {
await _method.invokeMethod<void>('ensureOngoing', {'caller': caller});
} catch (e) {
logger.w('CallBridge.ensureOngoing: $e');
}
}
Future<void> setScreenShare(bool enabled, {String? caller}) async { Future<void> setScreenShare(bool enabled, {String? caller}) async {
if (!_android) return; if (!_android) return;
try { try {
+39
View File
@@ -78,6 +78,7 @@ class CallSession {
int _peerDeviceIdx = 0; int _peerDeviceIdx = 0;
bool _muted = false; bool _muted = false;
bool _speakerOn = false;
bool _accepted = false; bool _accepted = false;
bool _peerMuted = false; bool _peerMuted = false;
bool _peerVideo = false; bool _peerVideo = false;
@@ -207,6 +208,7 @@ class CallSession {
bool get peerIsKomet => _peerIsKomet; bool get peerIsKomet => _peerIsKomet;
bool get isMuted => _muted; bool get isMuted => _muted;
bool get isSpeaker => _speakerOn;
bool get peerMuted => _peerMuted; bool get peerMuted => _peerMuted;
bool get peerVideo => _peerVideo; bool get peerVideo => _peerVideo;
bool get mediaConnected => _mediaConnected; bool get mediaConnected => _mediaConnected;
@@ -857,6 +859,7 @@ class CallSession {
if (role == CallRole.joiner || _topology == 'SERVER') { if (role == CallRole.joiner || _topology == 'SERVER') {
_setState(CallSessionState.active); _setState(CallSessionState.active);
} }
unawaited(applyAudioRoute());
unawaited(_resolvePath()); unawaited(_resolvePath());
unawaited(_collectReceivers()); unawaited(_collectReceivers());
} }
@@ -874,6 +877,7 @@ class CallSession {
} }
Future<void> _addLocalMedia(RTCPeerConnection pc) async { Future<void> _addLocalMedia(RTCPeerConnection pc) async {
await _prepareAudioSession();
_localStream = await navigator.mediaDevices.getUserMedia({ _localStream = await navigator.mediaDevices.getUserMedia({
'audio': true, 'audio': true,
'video': _wantVideo, 'video': _wantVideo,
@@ -881,8 +885,43 @@ class CallSession {
for (final track in _localStream!.getTracks()) { for (final track in _localStream!.getTracks()) {
await pc.addTrack(track, _localStream!); await pc.addTrack(track, _localStream!);
} }
await applyAudioRoute();
} }
Future<void> setSpeaker(bool on) async {
if (_speakerOn == on) return;
_speakerOn = on;
await applyAudioRoute();
_notifyInfo();
}
Future<void> _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<void> 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<void> _openSfuChannels(RTCPeerConnection pc) async { Future<void> _openSfuChannels(RTCPeerConnection pc) async {
await _closeSfuChannels(); await _closeSfuChannels();
final commands = SfuCommandChannel(); final commands = SfuCommandChannel();
+31 -4
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'custom_font_service.dart';
const String kDisplayFontFamily = 'Outfit'; const String kDisplayFontFamily = 'Outfit';
@immutable @immutable
@@ -25,7 +27,14 @@ class AppFont {
final String label; final String label;
final String? fontFamily; 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 isSystem => fontFamily == null;
bool get isCustom => id.startsWith(AppFonts.customPrefix); bool get isCustom => id.startsWith(AppFonts.customPrefix);
@@ -42,8 +51,18 @@ class AppFonts {
static const List<AppFont> builtIn = [ static const List<AppFont> builtIn = [
AppFont(id: 'system', label: 'Системный'), AppFont(id: 'system', label: 'Системный'),
AppFont(id: 'inter', label: 'Inter', fontFamily: 'Inter'), AppFont(
AppFont(id: 'unbounded', label: 'Unbounded', fontFamily: 'Unbounded'), 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; static AppFont get fallback => builtIn.first;
@@ -53,11 +72,19 @@ class AppFonts {
static AppFont resolve(String id) { static AppFont resolve(String id) {
if (id.startsWith(customPrefix)) { if (id.startsWith(customPrefix)) {
final family = id.substring(customPrefix.length); 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); 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) { static String? displayFamily(String id) {
final font = resolve(id); final font = resolve(id);
return font.isSystem ? kDisplayFontFamily : font.fontFamily; return font.isSystem ? kDisplayFontFamily : font.fontFamily;
+19
View File
@@ -39,6 +39,25 @@ final Map<String, CountryName> countriesByCode = {
for (final country in allCountries) country.code: country, for (final country in allCountries) country.code: country,
}; };
const Map<String, String> primaryCountryByPhoneCode = {'+7': 'RU', '+1': 'US'};
bool isPrimaryForPhoneCode(CountryName country) =>
primaryCountryByPhoneCode[country.phoneCode] == country.code;
List<CountryName> sortedByDisplayName(
Iterable<CountryName> countries,
String languageCode,
) {
final list = countries.toList();
list.sort(
(a, b) => a
.displayName(languageCode)
.toLowerCase()
.compareTo(b.displayName(languageCode).toLowerCase()),
);
return list;
}
List<CountryName> countriesInServerOrder(Iterable<String> codes) { List<CountryName> countriesInServerOrder(Iterable<String> codes) {
final out = <CountryName>[]; final out = <CountryName>[];
for (final raw in codes) { for (final raw in codes) {
+8
View File
@@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../utils/logger.dart'; import '../utils/logger.dart';
import 'font_metrics.dart';
class CustomFontService { class CustomFontService {
static const String prefKey = 'app_custom_fonts'; 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'; 'AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30';
static final Set<String> _loaded = <String>{}; static final Set<String> _loaded = <String>{};
static final Map<String, double> _metricScales = <String, double>{};
static double metricScaleFor(String family) => _metricScales[family] ?? 1.0;
static Future<List<String>> families() async { static Future<List<String>> families() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -85,6 +89,10 @@ class CustomFontService {
..addFont(Future<ByteData>.value(ByteData.sublistView(bytes))); ..addFont(Future<ByteData>.value(ByteData.sublistView(bytes)));
await loader.load(); await loader.load();
_loaded.add(family); _loaded.add(family);
final xHeight = FontMetrics.xHeightRatio(bytes);
if (xHeight != null) {
_metricScales[family] = FontMetrics.scaleForXHeight(xHeight);
}
} }
static bool _isSfnt(Uint8List b) { static bool _isSfnt(Uint8List b) {
+44
View File
@@ -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;
}
}
}
+11 -7
View File
@@ -9,6 +9,7 @@ class DeviceContactsService {
DeviceContactsService._(); DeviceContactsService._();
static const _grantedKey = 'phonebook_granted'; static const _grantedKey = 'phonebook_granted';
static const _deniedKey = 'phonebook_denied';
static final Map<String, String> _byLast10 = {}; static final Map<String, String> _byLast10 = {};
static bool _loaded = false; static bool _loaded = false;
@@ -41,12 +42,17 @@ class DeviceContactsService {
await _readBook(); await _readBook();
} }
static Future<bool> ensureLoadedInteractive() async { static Future<bool> ensureLoadedInteractive({bool force = false}) async {
if (_loaded || !_supported) return false; if (_loaded || !_supported) return false;
if (!AppPhonebookNames.current.value) return false; if (!AppPhonebookNames.current.value) return false;
final granted = await FlutterContacts.requestPermission(readonly: true);
if (!granted) return false;
final prefs = await SharedPreferences.getInstance(); 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); await prefs.setBool(_grantedKey, true);
return _readBook(); return _readBook();
} }
@@ -54,14 +60,12 @@ class DeviceContactsService {
static Future<bool> reload() async { static Future<bool> reload() async {
_loaded = false; _loaded = false;
_byLast10.clear(); _byLast10.clear();
return ensureLoadedInteractive(); return ensureLoadedInteractive(force: true);
} }
static Future<bool> _readBook() async { static Future<bool> _readBook() async {
try { try {
final contacts = await FlutterContacts.getContacts( final contacts = await FlutterContacts.getContacts(withProperties: true);
withProperties: true,
);
_byLast10.clear(); _byLast10.clear();
for (final contact in contacts) { for (final contact in contacts) {
final name = contact.displayName.trim(); final name = contact.displayName.trim();
@@ -22,6 +22,7 @@ class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
final _usernameController = TextEditingController(); final _usernameController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
ProxyType _selectedType = ProxyType.none; ProxyType _selectedType = ProxyType.none;
ProxySettings _applied = const ProxySettings();
bool _busy = false; bool _busy = false;
@override @override
@@ -34,6 +35,7 @@ class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
final settings = await ProxyConfig.load(); final settings = await ProxyConfig.load();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_applied = settings;
_selectedType = settings.type; _selectedType = settings.type;
_hostController.text = settings.host; _hostController.text = settings.host;
_portController.text = '${settings.port}'; _portController.text = '${settings.port}';
@@ -58,18 +60,18 @@ class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
try { try {
final username = _usernameController.text.trim(); final username = _usernameController.text.trim();
final password = _passwordController.text.trim(); final password = _passwordController.text.trim();
await ProxyConfig.save( final settings = ProxySettings(
ProxySettings( type: _selectedType,
type: _selectedType, host: host,
host: host, port: port,
port: port, username: username.isNotEmpty ? username : null,
username: username.isNotEmpty ? username : null, password: password.isNotEmpty ? password : null,
password: password.isNotEmpty ? password : null,
),
); );
await ProxyConfig.save(settings);
await api.disconnect(); await api.disconnect();
await api.connect(); await api.connect();
if (!mounted) return; if (!mounted) return;
setState(() => _applied = settings);
if (api.state == SessionState.online) { if (api.state == SessionState.online) {
showCustomNotification(context, l10n.proxySettingsSaved); showCustomNotification(context, l10n.proxySettingsSaved);
} else { } else {
@@ -84,7 +86,10 @@ class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
setState(() => _busy = true); setState(() => _busy = true);
try { try {
await ProxyConfig.clear(); await ProxyConfig.clear();
setState(() => _selectedType = ProxyType.none); setState(() {
_selectedType = ProxyType.none;
_applied = const ProxySettings();
});
await api.disconnect(); await api.disconnect();
await api.connect(); await api.connect();
if (!mounted) return; if (!mounted) return;
@@ -134,6 +139,11 @@ class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
const SizedBox(height: 4),
Text(
l10n.proxyCurrentState(_appliedLabel(l10n)),
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 20), const SizedBox(height: 20),
// Proxy type selector // Proxy type selector
@@ -191,7 +201,9 @@ class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton( FilledButton(
onPressed: _busy ? null : () => _apply(l10n), onPressed: (_busy || !(isActive || _applied.isEnabled))
? null
: () => _apply(l10n),
child: Text(isActive ? l10n.proxyApply : l10n.proxyDisable), child: Text(isActive ? l10n.proxyApply : l10n.proxyDisable),
), ),
], ],
@@ -201,6 +213,14 @@ class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
); );
} }
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) { Widget _buildTypeSelector(ColorScheme cs, AppLocalizations l10n) {
final labels = { final labels = {
ProxyType.none: l10n.proxyTypeNone, ProxyType.none: l10n.proxyTypeNone,
@@ -28,18 +28,24 @@ class _CountrySearchEntry {
class _SelectCountryScreenState extends State<SelectCountryScreen> { class _SelectCountryScreenState extends State<SelectCountryScreen> {
bool _isSearching = false; bool _isSearching = false;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
late List<CountryName> _filteredCountries; List<CountryName> _sortedCountries = const [];
late final List<_CountrySearchEntry> _searchEntries; List<CountryName> _filteredCountries = const [];
List<_CountrySearchEntry> _searchEntries = const [];
String _lang = '';
@override @override
void initState() { void didChangeDependencies() {
super.initState(); super.didChangeDependencies();
_filteredCountries = widget.countries; final lang = Localizations.localeOf(context).languageCode;
_searchEntries = widget.countries if (lang == _lang) return;
_lang = lang;
_sortedCountries = sortedByDisplayName(widget.countries, lang);
_searchEntries = _sortedCountries
.map( .map(
(c) => _CountrySearchEntry(c, c.ru.toLowerCase(), c.en.toLowerCase()), (c) => _CountrySearchEntry(c, c.ru.toLowerCase(), c.en.toLowerCase()),
) )
.toList(); .toList();
_filteredCountries = _applyFilter(_searchController.text);
} }
@override @override
@@ -48,23 +54,38 @@ class _SelectCountryScreenState extends State<SelectCountryScreen> {
super.dispose(); super.dispose();
} }
List<CountryName> _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) { void _filterCountries(String query) {
setState(() { setState(() => _filteredCountries = _applyFilter(query));
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();
}
});
} }
@override @override
@@ -118,7 +139,7 @@ class _SelectCountryScreenState extends State<SelectCountryScreen> {
_isSearching = !_isSearching; _isSearching = !_isSearching;
if (!_isSearching) { if (!_isSearching) {
_searchController.clear(); _searchController.clear();
_filteredCountries = widget.countries; _filteredCountries = _sortedCountries;
} }
}); });
}, },
+6 -14
View File
@@ -2,12 +2,10 @@ import 'dart:async';
import 'dart:math' show cos, pi; import 'dart:math' show cos, pi;
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' import 'package:flutter_webrtc/flutter_webrtc.dart'
show show
Helper,
MediaStream, MediaStream,
RTCVideoRenderer, RTCVideoRenderer,
RTCVideoValue, RTCVideoValue,
@@ -22,7 +20,6 @@ import '../../../core/calls/call_info.dart';
import '../../../core/calls/call_session.dart'; import '../../../core/calls/call_session.dart';
import '../../../core/config/app_colors.dart'; import '../../../core/config/app_colors.dart';
import '../../../core/utils/format.dart'; import '../../../core/utils/format.dart';
import '../../../core/utils/logger.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/glossy_pill.dart'; import '../../widgets/glossy_pill.dart';
@@ -246,6 +243,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
_resolveParticipants(); _resolveParticipants();
_syncVideo(); _syncVideo();
_syncLocalPreview(); _syncLocalPreview();
_isSpeaker = session.isSpeaker;
setState(() {}); setState(() {});
}); });
_remoteStreamSub = session.remoteStreamStream.listen(_attachStream); _remoteStreamSub = session.remoteStreamStream.listen(_attachStream);
@@ -259,6 +257,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
if (existing != null) _attachStream(existing); if (existing != null) _attachStream(existing);
_resolveParticipants(); _resolveParticipants();
_syncVideo(); _syncVideo();
_isSpeaker = session.isSpeaker;
} }
void _showKometBadge() { void _showKometBadge() {
@@ -356,19 +355,12 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
await _session?.setMuted(next); await _session?.setMuted(next);
} }
static bool get _hasSpeakerphone =>
defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
Future<void> _toggleSpeaker() async { Future<void> _toggleSpeaker() async {
final next = !_isSpeaker; final session = _session;
if (session == null) return;
final next = !session.isSpeaker;
setState(() => _isSpeaker = next); setState(() => _isSpeaker = next);
if (!_hasSpeakerphone) return; await session.setSpeaker(next);
try {
await Helper.setSpeakerphoneOn(next);
} catch (e) {
logger.w('[call] setSpeakerphoneOn недоступен: $e');
}
} }
bool _videoBusy = false; bool _videoBusy = false;
@@ -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( Widget _heroAvatar(
double size, double size,
@@ -469,7 +472,8 @@ class ChatHeaderRow extends StatelessWidget {
Widget _withOnlineDot(ColorScheme cs, Widget avatar, {double dotSize = 12}) { Widget _withOnlineDot(ColorScheme cs, Widget avatar, {double dotSize = 12}) {
final otherId = chatId ^ myId; final otherId = chatId ^ myId;
final showDot = chatType == 'DIALOG' && myId != 0 && otherId > 0; final showDot =
chatType == 'DIALOG' && myId != 0 && otherId > 0 && !_isSavedMessages;
return Stack( return Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
@@ -172,6 +172,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
double _headerDelta = 0; double _headerDelta = 0;
bool _expandArmed = false; bool _expandArmed = false;
bool _headerDragging = false;
bool _headerEverExpanded = false; bool _headerEverExpanded = false;
@override @override
@@ -648,11 +649,15 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
bool _onHeaderScrollNotification(ScrollNotification n, double delta) { bool _onHeaderScrollNotification(ScrollNotification n, double delta) {
if (n.depth != 0) return false; if (n.depth != 0) return false;
if (n is ScrollStartNotification) { if (n is ScrollStartNotification) {
_headerDragging = n.dragDetails != null;
if (n.dragDetails != null) { if (n.dragDetails != null) {
_expandArmed = delta > 0 && n.metrics.pixels <= delta + 8; _expandArmed = delta > 0 && n.metrics.pixels <= delta + 8;
} }
} else if (n is ScrollEndNotification) { } else if (n is ScrollEndNotification) {
_snapHeader(delta); if (_headerDragging) {
_headerDragging = false;
_snapHeader(delta);
}
} }
return false; return false;
} }
@@ -660,12 +665,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
void _snapHeader(double delta) { void _snapHeader(double delta) {
final c = _bodyScrollController; final c = _bodyScrollController;
if (c == null || !c.hasClients || delta <= 0) return; if (c == null || !c.hasClients || delta <= 0) return;
final collapsed = math.min(delta, c.position.maxScrollExtent);
final offset = c.offset; final offset = c.offset;
if (offset <= 0 || offset >= delta) return; if (offset <= 0 || offset >= collapsed) return;
final target = (offset < delta / 2 ? 0.0 : delta).clamp( final target = offset < collapsed / 2 ? 0.0 : collapsed;
0.0,
c.position.maxScrollExtent,
);
if ((target - offset).abs() < 1) return; if ((target - offset).abs() < 1) return;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !c.hasClients) return; if (!mounted || !c.hasClients) return;
@@ -94,6 +94,8 @@ import '../downloads_screen.dart';
import '../../widgets/media_playback_pill.dart'; import '../../widgets/media_playback_pill.dart';
import '../../../core/config/app_fonts.dart'; import '../../../core/config/app_fonts.dart';
const String _savedWelcomeKey = 'welcome.saved.dialog.message';
class _StoriesScrollPhysics extends BouncingScrollPhysics { class _StoriesScrollPhysics extends BouncingScrollPhysics {
final bool Function() blockPositive; final bool Function() blockPositive;
final bool Function() allowPullOverscrollTop; final bool Function() allowPullOverscrollTop;
@@ -1894,6 +1896,8 @@ class _ChatListScreenState extends State<ChatListScreen>
); );
} else { } else {
final isPlaceholder = chat.isLastMsgDeleted; final isPlaceholder = chat.isLastMsgDeleted;
final isSavedWelcome =
chat.id == 0 && chat.lastMsgText == _savedWelcomeKey;
final sender = chat.lastMsgSenderId != null final sender = chat.lastMsgSenderId != null
? ContactCache.get(chat.lastMsgSenderId!) ? ContactCache.get(chat.lastMsgSenderId!)
: null; : null;
@@ -1906,6 +1910,10 @@ class _ChatListScreenState extends State<ChatListScreen>
: ""; : "";
final body = isPlaceholder final body = isPlaceholder
? 'зайдите в чат для подгрузки' ? 'зайдите в чат для подгрузки'
: isSavedWelcome
? AppLocalizations.of(
context,
)!.savedMessagesEmptyPreview
: (chat.lastMsgTextOneLine ?? ''); : (chat.lastMsgTextOneLine ?? '');
return _animateChatTile( return _animateChatTile(
@@ -1924,16 +1932,16 @@ class _ChatListScreenState extends State<ChatListScreen>
isVerified: chat.isOfficial, isVerified: chat.isOfficial,
isPinned: isPinned, isPinned: isPinned,
chatType: chat.type, chatType: chat.type,
messageItalic: isPlaceholder, messageItalic: isPlaceholder || isSavedWelcome,
draft: chat.id == 0 ? null : _draftFor(chat.id), draft: chat.id == 0 ? null : _draftFor(chat.id),
ownStatus: _ownStatusFor(chat, isPlaceholder), ownStatus: _ownStatusFor(chat, isPlaceholder),
ownRead: chat.lastMsgReadByOthers, ownRead: chat.lastMsgReadByOthers,
messageRanges: isPlaceholder messageRanges: isPlaceholder || isSavedWelcome
? const [] ? const []
: chat.lastMsgFormatRanges, : chat.lastMsgFormatRanges,
previewMessageId: isPlaceholder ? null : chat.lastMsgId, previewMessageId: isPlaceholder ? null : chat.lastMsgId,
previewPrefix: senderPrefix, previewPrefix: senderPrefix,
previewCipherText: isPlaceholder previewCipherText: isPlaceholder || isSavedWelcome
? null ? null
: chat.lastMsgText, : chat.lastMsgText,
previewMedia: isPlaceholder ? null : chat.lastMsgMedia, previewMedia: isPlaceholder ? null : chat.lastMsgMedia,
@@ -3247,6 +3247,7 @@ class _ChatScreenState extends State<ChatScreen>
showCall: showCall:
!_commentsMode && !_commentsMode &&
widget.chatType == 'DIALOG' && widget.chatType == 'DIALOG' &&
widget.chatId != 0 &&
!_peerIsBot, !_peerIsBot,
onClose: widget.onClose, onClose: widget.onClose,
onOpenInfo: _commentsMode ? () {} : _openChatInfo, onOpenInfo: _commentsMode ? () {} : _openChatInfo,
@@ -4137,6 +4138,7 @@ class _ChatScreenState extends State<ChatScreen>
int? _resolveOtherId() { int? _resolveOtherId() {
if (widget.chatType != 'DIALOG' || _myId == 0) return null; if (widget.chatType != 'DIALOG' || _myId == 0) return null;
if (widget.chatId == 0) return null;
final id = widget.chatId ^ _myId; final id = widget.chatId ^ _myId;
return id > 0 ? id : null; return id > 0 ? id : null;
} }
+54 -17
View File
@@ -145,6 +145,33 @@ class _SearchScreenState extends State<SearchScreen> {
return ContactCache.get(result.id) ?? result.name ?? 'User #${result.id}'; 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) { void _openChat(int chatId, String name, String? avatarUrl, String type) {
pushSwipeable( pushSwipeable(
context, context,
@@ -273,17 +300,7 @@ class _SearchScreenState extends State<SearchScreen> {
], ],
if (_chats.isNotEmpty) ...[ if (_chats.isNotEmpty) ...[
_sectionHeader(cs, 'Чаты'), _sectionHeader(cs, 'Чаты'),
for (final row in _chats) for (final row in _chats) _localChatTile(row),
_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',
),
),
], ],
if (_messages.isNotEmpty) ...[ if (_messages.isNotEmpty) ...[
_sectionHeader(cs, 'Сообщения'), _sectionHeader(cs, 'Сообщения'),
@@ -305,16 +322,36 @@ class _SearchScreenState extends State<SearchScreen> {
onTap: () => _openChat(hit.id, hit.title ?? '', hit.avatarUrl, hit.type), onTap: () => _openChat(hit.id, hit.title ?? '', hit.avatarUrl, hit.type),
); );
Widget _localChatTile(Map<String, dynamic> 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) { Widget _messageTile(MessageSearchHit hit) {
final meta = _msgChatMeta[hit.chatId]; final meta = _msgChatMeta[hit.chatId];
final title = (meta?['title'] as String?) ?? 'Чат'; final identity = _chatIdentity(
final icon = meta?['icon_url'] as String?; hit.chatId,
final type = (meta?['type'] as String?) ?? 'CHAT'; meta?['type'] as String?,
meta?['title'] as String?,
meta?['icon_url'] as String?,
);
final name = identity.name.isEmpty ? 'Чат' : identity.name;
return _ResultTile( return _ResultTile(
name: title, name: name,
imageUrl: icon, imageUrl: identity.avatar,
subtitle: hit.text?.trim(), subtitle: hit.text?.trim(),
onTap: () => _openChat(hit.chatId, title, icon, type), onTap: () => _openChat(hit.chatId, name, identity.avatar, identity.type),
); );
} }
@@ -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<BlockedContact>? initialContacts;
@override
State<BlacklistScreen> createState() => _BlacklistScreenState();
}
class _BlacklistScreenState extends State<BlacklistScreen>
with ReloadOnReconnect {
late List<BlockedContact> _contacts = widget.initialContacts ?? const [];
late bool _isLoading = widget.initialContacts == null;
final Set<int> _pending = {};
@override
void initState() {
super.initState();
_load();
}
@override
void reloadAfterReconnect() => _load();
Future<void> _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<void> _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,
),
),
],
),
),
),
),
);
}
}
@@ -14,6 +14,7 @@ import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/glossy_pill.dart'; import '../../widgets/glossy_pill.dart';
import '../../widgets/sheet_helpers.dart'; import '../../widgets/sheet_helpers.dart';
import '../../widgets/small_spinner.dart'; import '../../widgets/small_spinner.dart';
import 'blacklist_screen.dart';
import 'password_entry_screen.dart'; import 'password_entry_screen.dart';
import '../../../core/config/app_fonts.dart'; import '../../../core/config/app_fonts.dart';
import '../../../core/config/app_shape.dart'; import '../../../core/config/app_shape.dart';
@@ -780,6 +781,20 @@ class _SecurityScreenState extends State<SecurityScreen>
); );
} }
Future<void> _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) { Widget _buildBlacklistSection(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final count = _blockedContacts.length; final count = _blockedContacts.length;
@@ -790,10 +805,7 @@ class _SecurityScreenState extends State<SecurityScreen>
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
onTap: () => showCustomNotification( onTap: _openBlacklist,
context,
l10n.securityBlacklistNotification('$count'),
),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17),
+10 -3
View File
@@ -63,6 +63,7 @@ class _SettingsTabState extends State<SettingsTab> with SpectrumSurface {
double _headerDelta = 0; double _headerDelta = 0;
bool _headerEverExpanded = false; bool _headerEverExpanded = false;
bool _expandArmed = false; bool _expandArmed = false;
bool _headerDragging = false;
bool _zoneHapticFired = false; bool _zoneHapticFired = false;
bool _pastCommitPoint = false; bool _pastCommitPoint = false;
String? _appVersionLabel; String? _appVersionLabel;
@@ -116,6 +117,7 @@ class _SettingsTabState extends State<SettingsTab> with SpectrumSurface {
bool _handleScrollNotification(ScrollNotification n, double delta) { bool _handleScrollNotification(ScrollNotification n, double delta) {
if (n.depth != 0) return false; if (n.depth != 0) return false;
if (n is ScrollStartNotification) { if (n is ScrollStartNotification) {
_headerDragging = n.dragDetails != null;
if (n.dragDetails != null) { if (n.dragDetails != null) {
final px = n.metrics.pixels; final px = n.metrics.pixels;
_expandArmed = delta > 0 && px <= delta + 8; _expandArmed = delta > 0 && px <= delta + 8;
@@ -140,7 +142,10 @@ class _SettingsTabState extends State<SettingsTab> with SpectrumSurface {
} }
} }
} else if (n is ScrollEndNotification) { } else if (n is ScrollEndNotification) {
_snapHeader(delta); if (_headerDragging) {
_headerDragging = false;
_snapHeader(delta);
}
} }
return false; return false;
} }
@@ -148,9 +153,11 @@ class _SettingsTabState extends State<SettingsTab> with SpectrumSurface {
void _snapHeader(double delta) { void _snapHeader(double delta) {
final c = _scrollController; final c = _scrollController;
if (c == null || !c.hasClients || delta <= 0) return; if (c == null || !c.hasClients || delta <= 0) return;
final collapsed = math.min(delta, c.position.maxScrollExtent);
final offset = c.offset; final offset = c.offset;
if (offset <= 0 || offset >= delta) return; if (offset <= 0 || offset >= collapsed) return;
final target = offset < delta / 2 ? 0.0 : delta; final target = offset < collapsed / 2 ? 0.0 : collapsed;
if ((target - offset).abs() < 1) return;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !c.hasClients) return; if (!mounted || !c.hasClients) return;
c.animateTo( c.animateTo(
@@ -55,6 +55,8 @@ class PhotoBubble extends StatelessWidget {
); );
} else if (count == 2) { } else if (count == 2) {
photosWidget = _buildTwoPhotos(ctx, photos[0], photos[1]); photosWidget = _buildTwoPhotos(ctx, photos[0], photos[1]);
} else if (count == 3) {
photosWidget = _buildThreePhotos(ctx, photos);
} else { } else {
photosWidget = _buildPhotoGrid(ctx, photos); photosWidget = _buildPhotoGrid(ctx, photos);
} }
@@ -307,6 +309,39 @@ class PhotoBubble extends StatelessWidget {
); );
} }
Widget _buildThreePhotos(BubbleContext ctx, List<PhotoAttachment> 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<PhotoAttachment> photos) { Widget _buildPhotoGrid(BubbleContext ctx, List<PhotoAttachment> photos) {
final displayCount = photos.length > 4 ? 4 : photos.length; final displayCount = photos.length > 4 ? 4 : photos.length;
final remaining = photos.length - 4; final remaining = photos.length - 4;
@@ -361,30 +396,30 @@ class PhotoBubble extends StatelessWidget {
return _buildPhotoTile(ctx, photos[index], index); 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 = final cachePx =
(BubbleContext.photoMaxSize / (BubbleContext.photoMaxSize /
2 * 2 *
MediaQuery.of(ctx.context).devicePixelRatio) MediaQuery.of(ctx.context).devicePixelRatio)
.round(); .round();
return AspectRatio( return Stack(
aspectRatio: 1, children: [
child: Stack( _buildPhotoImage(
children: [ ctx,
_buildPhotoImage( photo,
ctx, double.infinity,
photo, double.infinity,
double.infinity, memWidth: cachePx,
double.infinity, memHeight: cachePx,
memWidth: cachePx, ),
memHeight: cachePx, if (ctx.uploadProgress != null)
), _buildUploadOverlay(ctx.uploadProgress!, index),
if (ctx.uploadProgress != null) if (ctx.uploadProgress == null)
_buildUploadOverlay(ctx.uploadProgress!, index), _buildTileTapTarget(ctx, index, cachePx),
if (ctx.uploadProgress == null) ],
_buildTileTapTarget(ctx, index, cachePx),
],
),
); );
} }
@@ -59,6 +59,8 @@ class VoiceMessageBubble extends StatefulWidget {
State<VoiceMessageBubble> createState() => _VoiceMessageBubbleState(); State<VoiceMessageBubble> createState() => _VoiceMessageBubbleState();
} }
const double _transcriptionMaxHeight = 132;
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> { class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
bool _transcriptionVisible = false; bool _transcriptionVisible = false;
String? _transcriptionText; String? _transcriptionText;
@@ -82,15 +84,39 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
fallbackDuration: Duration(seconds: widget.duration), fallbackDuration: Duration(seconds: widget.duration),
); );
_audio.failure.addListener(_onFailure); _audio.failure.addListener(_onFailure);
TranscriptionCache.listen(_sourceMessageId, _onTranscriptionPush);
_adoptCachedTranscription();
} }
@override @override
void dispose() { void dispose() {
TranscriptionCache.unlisten(_sourceMessageId, _onTranscriptionPush);
_audio.failure.removeListener(_onFailure); _audio.failure.removeListener(_onFailure);
MediaPlayback.instance.releaseVoice(_audio); MediaPlayback.instance.releaseVoice(_audio);
super.dispose(); 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'; String get _cacheName => '${widget.audioId ?? _sourceMessageId}.ogg';
int get _sourceChatId => widget.sourceChatId ?? widget.chatId; int get _sourceChatId => widget.sourceChatId ?? widget.chatId;
@@ -359,15 +385,21 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
curve: Curves.easeOut, curve: Curves.easeOut,
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
child: _transcriptionVisible child: _transcriptionVisible
? Text( ? ConstrainedBox(
_transcriptionText ?? '', constraints: const BoxConstraints(
style: TextStyle( maxHeight: _transcriptionMaxHeight,
color: widget.textColor.withValues(alpha: 0.8), ),
fontSize: 12, child: SingleChildScrollView(
height: 1.3, 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(), : const SizedBox.shrink(),
), ),
@@ -438,16 +470,16 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (_transcriptionVisible && _transcriptionText != null) { if (_transcriptionVisible && _transcriptionText != null) {
setState(() { setState(() {
_transcriptionVisible = false; _transcriptionVisible = false;
TranscriptionCache.setExpanded(_sourceMessageId, false);
}); });
return; return;
} }
if (TranscriptionCache.has(_sourceMessageId)) { if (TranscriptionCache.has(_sourceMessageId)) {
final cached = TranscriptionCache.get(_sourceMessageId)!; final cached = TranscriptionCache.get(_sourceMessageId)!;
setState(() { setState(
_transcriptionText = cached.text ?? 'не удалось распознать текст'; () => _showTranscription(cached.text ?? 'не удалось распознать текст'),
_transcriptionVisible = true; );
});
return; return;
} }
@@ -468,13 +500,13 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
setState(() { setState(() {
_transcriptionLoading = false; _transcriptionLoading = false;
if (result.status == 1) { if (result.status == 1) {
_transcriptionText = (result.text == null || result.text!.isEmpty) _showTranscription(
? 'не удалось распознать текст' (result.text == null || result.text!.isEmpty)
: result.text; ? 'не удалось распознать текст'
_transcriptionVisible = true; : result.text!,
);
} else if (result.status == 0) { } else if (result.status == 0) {
_transcriptionText = 'транскрибация...'; _showTranscription('транскрибация...');
_transcriptionVisible = true;
} }
}); });
} catch (e) { } catch (e) {
@@ -482,8 +514,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_transcriptionLoading = false; _transcriptionLoading = false;
_transcriptionText = 'ошибка транскрибации'; _showTranscription('ошибка транскрибации');
_transcriptionVisible = true;
}); });
} }
} }
@@ -64,9 +64,15 @@ class _AvatarHistoryScreenState extends State<AvatarHistoryScreen> {
final current = widget.currentAvatarUrl; final current = widget.currentAvatarUrl;
_current = (current != null && current.isNotEmpty) ? current : null; _current = (current != null && current.isNotEmpty) ? current : null;
_rebuildPages(); _rebuildPages();
_load(); if (_hasHistory) {
_load();
} else {
_loading = false;
}
} }
bool get _hasHistory => widget.contactId > 0;
@override @override
void dispose() { void dispose() {
_pageController.dispose(); _pageController.dispose();
@@ -100,7 +106,9 @@ class _AvatarHistoryScreenState extends State<AvatarHistoryScreen> {
} }
Future<void> _loadMore() async { Future<void> _loadMore() async {
if (_loadingMore || _history.length >= _historyTotal) return; if (!_hasHistory || _loadingMore || _history.length >= _historyTotal) {
return;
}
_loadingMore = true; _loadingMore = true;
final photos = await ContactsModule.fetchPhotos( final photos = await ContactsModule.fetchPhotos(
api, api,
@@ -286,9 +294,8 @@ class _AvatarHistoryScreenState extends State<AvatarHistoryScreen> {
imageUrl: _pages[i], imageUrl: _pages[i],
fit: BoxFit.contain, fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 120), fadeInDuration: const Duration(milliseconds: 120),
placeholder: (_, _) => const Center( placeholder: (_, _) =>
child: SmallSpinner(size: 36, color: Colors.white), const Center(child: SmallSpinner(size: 36, color: Colors.white)),
),
errorWidget: (_, _, _) => errorWidget: (_, _, _) =>
const Icon(Symbols.broken_image, color: Colors.white54, size: 64), const Icon(Symbols.broken_image, color: Colors.white54, size: 64),
), ),
@@ -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 @override
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
if (delta <= 0 || offset <= 0 || position.pixels <= 0) { if (delta <= 0 || offset <= 0 || position.pixels <= 0) {
@@ -107,8 +122,10 @@ class MorphHeaderDelegate extends SliverPersistentHeaderDelegate {
double get maxExtent => expandedExtent; double get maxExtent => expandedExtent;
@override @override
OverScrollHeaderStretchConfiguration get stretchConfiguration => OverScrollHeaderStretchConfiguration? get stretchConfiguration =>
OverScrollHeaderStretchConfiguration(); expandedExtent > collapsedExtent
? OverScrollHeaderStretchConfiguration()
: null;
@override @override
Widget build( Widget build(
+6 -3
View File
@@ -33,9 +33,12 @@ class ProfileHeroAvatar extends StatelessWidget {
final from = _AvatarHeroChild.of(fromHeroContext); final from = _AvatarHeroChild.of(fromHeroContext);
final to = _AvatarHeroChild.of(toHeroContext); final to = _AvatarHeroChild.of(toHeroContext);
final sharpest = from.size >= to.size ? from : to; final sharpest = from.size >= to.size ? from : to;
return FittedBox( return Material(
fit: BoxFit.fill, type: MaterialType.transparency,
child: SizedBox.square(dimension: sharpest.size, child: sharpest.child), child: FittedBox(
fit: BoxFit.fill,
child: SizedBox.square(dimension: sharpest.size, child: sharpest.child),
),
); );
} }
} }
+12 -1
View File
@@ -1150,5 +1150,16 @@
"type": "String" "type": "String"
} }
} }
} },
"savedMessagesEmptyPreview": "Save something here",
"proxyCurrentState": "Currently: {value}",
"@proxyCurrentState": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"blacklistEmpty": "Nobody is blocked",
"blacklistLoadError": "Failed to load the blacklist"
} }
+24
View File
@@ -5023,6 +5023,30 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'{value} MB/s'** /// **'{value} MB/s'**
String uploadSpeedMb(String value); 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 class _AppLocalizationsDelegate
+14
View File
@@ -2635,4 +2635,18 @@ class AppLocalizationsEn extends AppLocalizations {
String uploadSpeedMb(String value) { String uploadSpeedMb(String value) {
return '$value MB/s'; 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';
} }
+14
View File
@@ -2649,4 +2649,18 @@ class AppLocalizationsRu extends AppLocalizations {
String uploadSpeedMb(String value) { String uploadSpeedMb(String value) {
return '$value МБ/с'; return '$value МБ/с';
} }
@override
String get savedMessagesEmptyPreview => 'Сохраните что-нибудь';
@override
String proxyCurrentState(String value) {
return 'Сейчас: $value';
}
@override
String get blacklistEmpty => 'Никто не заблокирован';
@override
String get blacklistLoadError => 'Не удалось загрузить чёрный список';
} }
+12 -1
View File
@@ -894,5 +894,16 @@
"type": "String" "type": "String"
} }
} }
} },
"savedMessagesEmptyPreview": "Сохраните что-нибудь",
"proxyCurrentState": "Сейчас: {value}",
"@proxyCurrentState": {
"placeholders": {
"value": {
"type": "String"
}
}
},
"blacklistEmpty": "Никто не заблокирован",
"blacklistLoadError": "Не удалось загрузить чёрный список"
} }
+8 -3
View File
@@ -195,6 +195,7 @@ void main(List<String> args) async {
attachInfoCacheApi(api); attachInfoCacheApi(api);
chats.attachGlobalPushHandlers(api); chats.attachGlobalPushHandlers(api);
FoldersModule.attachGlobalPushHandlers(api); FoldersModule.attachGlobalPushHandlers(api);
TranscriptionPushHandler.attach(api);
commentsModule.attachPushHandlers(api); commentsModule.attachPushHandlers(api);
storiesModule.attach(); storiesModule.attach();
unawaited(storiesModule.loadCache()); unawaited(storiesModule.loadCache());
@@ -595,6 +596,9 @@ class KometAppState extends State<KometApp>
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
CallController.instance.appResumed = state == AppLifecycleState.resumed; CallController.instance.appResumed = state == AppLifecycleState.resumed;
if (state == AppLifecycleState.inactive && CallController.instance.isBusy) {
unawaited(CallBridge.instance.ensureOngoing());
}
if (state == AppLifecycleState.paused || if (state == AppLifecycleState.paused ||
state == AppLifecycleState.hidden || state == AppLifecycleState.hidden ||
state == AppLifecycleState.detached) { state == AppLifecycleState.detached) {
@@ -999,10 +1003,11 @@ class KometAppState extends State<KometApp>
child: child ?? const SizedBox.shrink(), child: child ?? const SizedBox.shrink(),
builder: (context, scale, appChild) { builder: (context, scale, appChild) {
Widget scaledChild = 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( scaledChild = MediaQuery.withClampedTextScaling(
minScaleFactor: scale, minScaleFactor: effective,
maxScaleFactor: scale, maxScaleFactor: effective,
child: scaledChild, child: scaledChild,
); );
} }