feat: багофикс по запросам волбачия
This commit is contained in:
@@ -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") ?: "Звонок"
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,13 +54,10 @@ class _SelectCountryScreenState extends State<SelectCountryScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _filterCountries(String query) {
|
List<CountryName> _applyFilter(String query) {
|
||||||
setState(() {
|
final q = query.trim().toLowerCase();
|
||||||
if (query.isEmpty) {
|
if (q.isEmpty) return _sortedCountries;
|
||||||
_filteredCountries = widget.countries;
|
final matches = _searchEntries
|
||||||
} else {
|
|
||||||
final q = query.toLowerCase();
|
|
||||||
_filteredCountries = _searchEntries
|
|
||||||
.where(
|
.where(
|
||||||
(e) =>
|
(e) =>
|
||||||
e.ruLower.contains(q) ||
|
e.ruLower.contains(q) ||
|
||||||
@@ -63,9 +66,27 @@ class _SelectCountryScreenState extends State<SelectCountryScreen> {
|
|||||||
)
|
)
|
||||||
.map((e) => e.country)
|
.map((e) => e.country)
|
||||||
.toList();
|
.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(() => _filteredCountries = _applyFilter(query));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,24 +649,26 @@ 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) {
|
||||||
|
if (_headerDragging) {
|
||||||
|
_headerDragging = false;
|
||||||
_snapHeader(delta);
|
_snapHeader(delta);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,17 +142,22 @@ class _SettingsTabState extends State<SettingsTab> with SpectrumSurface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (n is ScrollEndNotification) {
|
} else if (n is ScrollEndNotification) {
|
||||||
|
if (_headerDragging) {
|
||||||
|
_headerDragging = false;
|
||||||
_snapHeader(delta);
|
_snapHeader(delta);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
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,15 +396,16 @@ 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,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
children: [
|
||||||
_buildPhotoImage(
|
_buildPhotoImage(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -384,7 +420,6 @@ class PhotoBubble extends StatelessWidget {
|
|||||||
if (ctx.uploadProgress == null)
|
if (ctx.uploadProgress == null)
|
||||||
_buildTileTapTarget(ctx, index, cachePx),
|
_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(
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
maxHeight: _transcriptionMaxHeight,
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
|
child: Text(
|
||||||
_transcriptionText ?? '',
|
_transcriptionText ?? '',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: widget.textColor.withValues(alpha: 0.8),
|
color: widget.textColor.withValues(alpha: 0.8),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
height: 1.3,
|
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;
|
: result.text!,
|
||||||
_transcriptionVisible = true;
|
);
|
||||||
} 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,8 +64,14 @@ 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();
|
||||||
|
if (_hasHistory) {
|
||||||
_load();
|
_load();
|
||||||
|
} else {
|
||||||
|
_loading = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _hasHistory => widget.contactId > 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void 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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: FittedBox(
|
||||||
fit: BoxFit.fill,
|
fit: BoxFit.fill,
|
||||||
child: SizedBox.square(dimension: sharpest.size, child: sharpest.child),
|
child: SizedBox.square(dimension: sharpest.size, child: sharpest.child),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 => 'Не удалось загрузить чёрный список';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -894,5 +894,16 @@
|
|||||||
"type": "String"
|
"type": "String"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"savedMessagesEmptyPreview": "Сохраните что-нибудь",
|
||||||
|
"proxyCurrentState": "Сейчас: {value}",
|
||||||
|
"@proxyCurrentState": {
|
||||||
|
"placeholders": {
|
||||||
|
"value": {
|
||||||
|
"type": "String"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"blacklistEmpty": "Никто не заблокирован",
|
||||||
|
"blacklistLoadError": "Не удалось загрузить чёрный список"
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user