Merge pull request #34 from KometTeam/feature/FullStack
Крупный набор изменений: офлайн-вход, отправка медиа/вложений, рефактор UI чатов и профиля, фиксы спуфинга и iOS-сборки.
This commit is contained in:
@@ -54,9 +54,7 @@ jobs:
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>platform-application</key><true/>
|
||||
<key>get-task-allow</key><true/>
|
||||
<key>com.apple.private.security.no-container</key><true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
|
||||
<application
|
||||
android:label="Komet"
|
||||
android:name="${applicationName}"
|
||||
|
||||
+18
-3
@@ -179,6 +179,9 @@ class Api {
|
||||
String locale = 'ru';
|
||||
String deviceLocale = Platform.localeName.substring(0, 2);
|
||||
String deviceId = await DeviceIdentity.deviceId();
|
||||
String pushDeviceType = 'GCM';
|
||||
String instanceId = await DeviceIdentity.instanceId();
|
||||
int clientSessionId = DeviceIdentity.clientSessionId;
|
||||
|
||||
if (Platform.isLinux) {
|
||||
final linuxInfo = await deviceInfo.linuxInfo;
|
||||
@@ -223,6 +226,10 @@ class Api {
|
||||
locale = sLocale;
|
||||
deviceLocale = sLocale.split(RegExp(r'[-_]')).first;
|
||||
}
|
||||
final sDeviceLocale = spoofed['device_locale'] as String?;
|
||||
if (sDeviceLocale != null && sDeviceLocale.isNotEmpty) {
|
||||
deviceLocale = sDeviceLocale;
|
||||
}
|
||||
final sDeviceId = spoofed['device_id'] as String?;
|
||||
if (sDeviceId != null && sDeviceId.isNotEmpty) deviceId = sDeviceId;
|
||||
appVersion = (spoofed['app_version'] as String?) ?? appVersion;
|
||||
@@ -233,6 +240,14 @@ class Api {
|
||||
} else if (sBuild is String) {
|
||||
buildNumber = int.tryParse(sBuild) ?? buildNumber;
|
||||
}
|
||||
final sPushType = spoofed['push_device_type'] as String?;
|
||||
if (sPushType != null && sPushType.isNotEmpty) pushDeviceType = sPushType;
|
||||
final sInstanceId = spoofed['instance_id'] as String?;
|
||||
if (sInstanceId != null && sInstanceId.isNotEmpty) {
|
||||
instanceId = sInstanceId;
|
||||
}
|
||||
final sClientSession = spoofed['client_session_id'];
|
||||
if (sClientSession is int) clientSessionId = sClientSession;
|
||||
}
|
||||
|
||||
_userAgent = {
|
||||
@@ -241,7 +256,7 @@ class Api {
|
||||
'osVersion': osVersion,
|
||||
'timezone': timezone,
|
||||
'screen': screen,
|
||||
'pushDeviceType': 'GCM',
|
||||
'pushDeviceType': pushDeviceType,
|
||||
'arch': architecture,
|
||||
'locale': locale,
|
||||
'buildNumber': buildNumber,
|
||||
@@ -252,9 +267,9 @@ class Api {
|
||||
_deviceId = deviceId;
|
||||
|
||||
final payload = <dynamic, dynamic>{
|
||||
'mt_instanceid': await DeviceIdentity.instanceId(),
|
||||
'mt_instanceid': instanceId,
|
||||
'userAgent': _userAgent,
|
||||
'clientSessionId': DeviceIdentity.clientSessionId,
|
||||
'clientSessionId': clientSessionId,
|
||||
'deviceId': deviceId,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../../models/attachment.dart';
|
||||
import 'chats.dart' show ChatsModule;
|
||||
|
||||
@@ -24,7 +25,8 @@ class ContactCache {
|
||||
static String? get(int id) => _nameCache[id];
|
||||
static String? getAvatar(int id) => _avatarCache[id];
|
||||
static Set<String>? getOptions(int id) => _optionsCache[id];
|
||||
static bool isOfficial(int id) => _optionsCache[id]?.contains('OFFICIAL') ?? false;
|
||||
static bool isOfficial(int id) =>
|
||||
_optionsCache[id]?.contains('OFFICIAL') ?? false;
|
||||
|
||||
static void clear() {
|
||||
_nameCache.clear();
|
||||
@@ -108,8 +110,9 @@ class FileHistoryCache {
|
||||
static const _prefKey = 'file_history_v1';
|
||||
static const _maxEntries = 50;
|
||||
|
||||
static final ValueNotifier<List<FileHistoryEntry>> notifier =
|
||||
ValueNotifier(const []);
|
||||
static final ValueNotifier<List<FileHistoryEntry>> notifier = ValueNotifier(
|
||||
const [],
|
||||
);
|
||||
|
||||
static List<FileHistoryEntry> get history => notifier.value;
|
||||
static bool get isEmpty => notifier.value.isEmpty;
|
||||
@@ -135,7 +138,10 @@ class FileHistoryCache {
|
||||
}
|
||||
|
||||
static void add(FileHistoryEntry entry) {
|
||||
final next = [entry, ...notifier.value.where((e) => e.fileId != entry.fileId)];
|
||||
final next = [
|
||||
entry,
|
||||
...notifier.value.where((e) => e.fileId != entry.fileId),
|
||||
];
|
||||
if (next.length > _maxEntries) next.removeRange(_maxEntries, next.length);
|
||||
notifier.value = next;
|
||||
_persist();
|
||||
@@ -223,15 +229,24 @@ class CachedMessage {
|
||||
|
||||
return CachedMessage(
|
||||
id: row['id']?.toString() ?? '',
|
||||
accountId: row['account_id'] is int ? row['account_id'] as int : int.tryParse(row['account_id']?.toString() ?? '') ?? 0,
|
||||
chatId: row['chat_id'] is int ? row['chat_id'] as int : int.tryParse(row['chat_id']?.toString() ?? '') ?? 0,
|
||||
senderId: row['sender_id'] is int ? row['sender_id'] as int : int.tryParse(row['sender_id']?.toString() ?? '') ?? 0,
|
||||
accountId: row['account_id'] is int
|
||||
? row['account_id'] as int
|
||||
: int.tryParse(row['account_id']?.toString() ?? '') ?? 0,
|
||||
chatId: row['chat_id'] is int
|
||||
? row['chat_id'] as int
|
||||
: int.tryParse(row['chat_id']?.toString() ?? '') ?? 0,
|
||||
senderId: row['sender_id'] is int
|
||||
? row['sender_id'] as int
|
||||
: int.tryParse(row['sender_id']?.toString() ?? '') ?? 0,
|
||||
text: row['text']?.toString(),
|
||||
time: row['time'] is int ? row['time'] as int : int.tryParse(row['time']?.toString() ?? '') ?? 0,
|
||||
time: row['time'] is int
|
||||
? row['time'] as int
|
||||
: int.tryParse(row['time']?.toString() ?? '') ?? 0,
|
||||
status: row['status']?.toString(),
|
||||
payload: payload,
|
||||
attachments: attachments,
|
||||
isControl: attachments?.any((a) => a.type == AttachmentType.control) ?? false,
|
||||
isControl:
|
||||
attachments?.any((a) => a.type == AttachmentType.control) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -252,8 +267,7 @@ class CachedMessage {
|
||||
if (attaches is List && attaches.isNotEmpty) {
|
||||
attachments = attaches
|
||||
.whereType<Map>()
|
||||
.map((a) =>
|
||||
MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
|
||||
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
|
||||
.toList();
|
||||
}
|
||||
return CachedMessage(
|
||||
@@ -327,7 +341,7 @@ class MessagesModule {
|
||||
|
||||
if (rows.isNotEmpty) {
|
||||
AppDatabase.saveMessages(rows).catchError((e) {
|
||||
debugPrint('saveMessages error: $e');
|
||||
logger.e('saveMessages error: $e');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -424,7 +438,9 @@ class MessagesModule {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) {
|
||||
final msg = (response.payload is Map)
|
||||
? (response.payload['localizedMessage'] ?? response.payload['message'] ?? 'Ошибка отправки')
|
||||
? (response.payload['localizedMessage'] ??
|
||||
response.payload['message'] ??
|
||||
'Ошибка отправки')
|
||||
: 'Ошибка отправки';
|
||||
throw Exception(msg.toString());
|
||||
}
|
||||
@@ -460,7 +476,10 @@ class MessagesModule {
|
||||
if (transcriptionStatus == 1) {
|
||||
final text = data['transcription'] as String? ?? '';
|
||||
if (text.isEmpty) {
|
||||
return TranscriptionResult(status: 1, text: 'не удалось распознать текст');
|
||||
return TranscriptionResult(
|
||||
status: 1,
|
||||
text: 'не удалось распознать текст',
|
||||
);
|
||||
}
|
||||
return TranscriptionResult(status: 1, text: text);
|
||||
}
|
||||
@@ -509,7 +528,7 @@ class MessagesModule {
|
||||
if (token != null)
|
||||
{'_type': 'FILE', 'token': token}
|
||||
else
|
||||
{'_type': 'FILE', 'fileId': fileId}
|
||||
{'_type': 'FILE', 'fileId': fileId},
|
||||
],
|
||||
},
|
||||
'notify': notify,
|
||||
@@ -706,7 +725,10 @@ class MessagesModule {
|
||||
|
||||
final rawOpts = contact['options'];
|
||||
if (rawOpts is List) {
|
||||
ContactCache.putOptions(contactId, rawOpts.whereType<String>().toSet());
|
||||
ContactCache.putOptions(
|
||||
contactId,
|
||||
rawOpts.whereType<String>().toSet(),
|
||||
);
|
||||
}
|
||||
|
||||
ChatsModule.applyContactUpdate(contactId);
|
||||
@@ -716,7 +738,7 @@ class MessagesModule {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('searchContactById error: $e');
|
||||
logger.e('searchContactById error: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
enum GalleryPermission { granted, limited, denied }
|
||||
|
||||
abstract class GalleryItem {
|
||||
String get id;
|
||||
bool get isVideo;
|
||||
Duration? get duration;
|
||||
File? get localFile;
|
||||
Future<Uint8List?> thumbnail(int size);
|
||||
Future<File?> originFile();
|
||||
}
|
||||
|
||||
abstract class GallerySource {
|
||||
Future<GalleryPermission> ensurePermission();
|
||||
Future<List<GalleryItem>> load({int limit});
|
||||
Future<void> openSettings();
|
||||
Future<void> manageAccess();
|
||||
|
||||
factory GallerySource.create() {
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
return _PhotoManagerSource();
|
||||
}
|
||||
return _DesktopGallerySource();
|
||||
}
|
||||
}
|
||||
|
||||
class _PhotoManagerSource implements GallerySource {
|
||||
@override
|
||||
Future<GalleryPermission> ensurePermission() async {
|
||||
final state = await PhotoManager.requestPermissionExtend();
|
||||
if (state.isAuth) return GalleryPermission.granted;
|
||||
if (state.hasAccess) return GalleryPermission.limited;
|
||||
return GalleryPermission.denied;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<GalleryItem>> load({int limit = 120}) async {
|
||||
final paths = await PhotoManager.getAssetPathList(
|
||||
type: RequestType.common,
|
||||
onlyAll: true,
|
||||
filterOption: FilterOptionGroup(
|
||||
orders: const [
|
||||
OrderOption(type: OrderOptionType.createDate, asc: false),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (paths.isEmpty) return const [];
|
||||
final assets = await paths.first.getAssetListRange(start: 0, end: limit);
|
||||
return assets.map((a) => _AssetGalleryItem(a)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openSettings() => PhotoManager.openSetting();
|
||||
|
||||
@override
|
||||
Future<void> manageAccess() => PhotoManager.presentLimited();
|
||||
}
|
||||
|
||||
class _AssetGalleryItem implements GalleryItem {
|
||||
final AssetEntity asset;
|
||||
|
||||
_AssetGalleryItem(this.asset);
|
||||
|
||||
@override
|
||||
String get id => asset.id;
|
||||
|
||||
@override
|
||||
bool get isVideo => asset.type == AssetType.video;
|
||||
|
||||
@override
|
||||
Duration? get duration => isVideo ? Duration(seconds: asset.duration) : null;
|
||||
|
||||
@override
|
||||
File? get localFile => null;
|
||||
|
||||
@override
|
||||
Future<Uint8List?> thumbnail(int size) =>
|
||||
asset.thumbnailDataWithSize(ThumbnailSize.square(size));
|
||||
|
||||
@override
|
||||
Future<File?> originFile() => asset.file;
|
||||
}
|
||||
|
||||
class _DesktopGallerySource implements GallerySource {
|
||||
static const _imageExtensions = {
|
||||
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.heic', '.heif',
|
||||
};
|
||||
|
||||
@override
|
||||
Future<GalleryPermission> ensurePermission() async =>
|
||||
GalleryPermission.granted;
|
||||
|
||||
@override
|
||||
Future<List<GalleryItem>> load({int limit = 120}) async {
|
||||
final entries = <({File file, DateTime modified})>[];
|
||||
for (final dir in _candidateDirs()) {
|
||||
if (!dir.existsSync()) continue;
|
||||
try {
|
||||
for (final entity in dir.listSync(followLinks: false)) {
|
||||
if (entity is! File || !_isImage(entity.path)) continue;
|
||||
entries.add((file: entity, modified: entity.statSync().modified));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
entries.sort((a, b) => b.modified.compareTo(a.modified));
|
||||
return entries
|
||||
.take(limit)
|
||||
.map((e) => _FileGalleryItem(e.file))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openSettings() async {}
|
||||
|
||||
@override
|
||||
Future<void> manageAccess() async {}
|
||||
|
||||
List<Directory> _candidateDirs() {
|
||||
final home =
|
||||
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
||||
if (home == null || home.isEmpty) return const [];
|
||||
return [
|
||||
Directory('$home/Pictures'),
|
||||
Directory('$home/Изображения'),
|
||||
Directory('$home/Images'),
|
||||
];
|
||||
}
|
||||
|
||||
bool _isImage(String path) {
|
||||
final dot = path.lastIndexOf('.');
|
||||
if (dot < 0) return false;
|
||||
return _imageExtensions.contains(path.substring(dot).toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
class _FileGalleryItem implements GalleryItem {
|
||||
final File file;
|
||||
|
||||
_FileGalleryItem(this.file);
|
||||
|
||||
@override
|
||||
String get id => file.path;
|
||||
|
||||
@override
|
||||
bool get isVideo => false;
|
||||
|
||||
@override
|
||||
Duration? get duration => null;
|
||||
|
||||
@override
|
||||
File? get localFile => file;
|
||||
|
||||
@override
|
||||
Future<Uint8List?> thumbnail(int size) async => null;
|
||||
|
||||
@override
|
||||
Future<File?> originFile() async => file;
|
||||
}
|
||||
@@ -16,11 +16,15 @@ class SpoofingService {
|
||||
'screen': prefs.getString('spoof_screen'),
|
||||
'timezone': prefs.getString('spoof_timezone'),
|
||||
'locale': prefs.getString('spoof_locale'),
|
||||
'device_locale': prefs.getString('spoof_devicelocale'),
|
||||
'device_id': prefs.getString('spoof_deviceid'),
|
||||
'device_type': prefs.getString('spoof_devicetype'),
|
||||
'app_version': hardcodedAppVersion,
|
||||
'app_version': prefs.getString('spoof_appversion') ?? hardcodedAppVersion,
|
||||
'arch': prefs.getString('spoof_arch') ?? 'arm64-v8a',
|
||||
'build_number': hardcodedBuildNumber,
|
||||
'build_number': prefs.getInt('spoof_buildnumber') ?? hardcodedBuildNumber,
|
||||
'instance_id': prefs.getString('spoof_instanceid'),
|
||||
'client_session_id': prefs.getInt('spoof_clientsessionid'),
|
||||
'push_device_type': prefs.getString('spoof_pushdevicetype'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ enum SocketState { disconnected, connecting, connected }
|
||||
/// Обёртка над TCP + TLS сокетом.
|
||||
/// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver].
|
||||
class Connection {
|
||||
static const Duration _defaultConnectTimeout = Duration(seconds: 15);
|
||||
|
||||
SecureSocket? _socket;
|
||||
StreamSubscription<Uint8List>? _subscription;
|
||||
SocketState _state = SocketState.disconnected;
|
||||
@@ -86,28 +88,30 @@ class Connection {
|
||||
ProxySettings proxySettings, {
|
||||
Duration? timeout,
|
||||
}) async {
|
||||
final connectTimeout = timeout ?? _defaultConnectTimeout;
|
||||
Socket socket;
|
||||
if (proxySettings.isEnabled) {
|
||||
final connector = ProxyConnector(proxySettings);
|
||||
socket = await connector.connect(host, port);
|
||||
socket = await connector.connect(host, port).timeout(connectTimeout);
|
||||
logger.i('Подключено через прокси ${proxySettings.type.name}');
|
||||
} else {
|
||||
socket = timeout == null
|
||||
? await Socket.connect(host, port)
|
||||
: await Socket.connect(host, port, timeout: timeout);
|
||||
socket = await Socket.connect(host, port, timeout: connectTimeout);
|
||||
}
|
||||
final allowInsecure = await TlsConfig.isInsecureAllowed();
|
||||
if (allowInsecure) {
|
||||
logger.w(
|
||||
'TLS: проверка сертификата отключена (дебаг) — соединение уязвимо к MitM',
|
||||
);
|
||||
return SecureSocket.secure(
|
||||
socket,
|
||||
host: host,
|
||||
onBadCertificate: (_) => true,
|
||||
);
|
||||
}
|
||||
return SecureSocket.secure(socket, host: host);
|
||||
final secured = allowInsecure
|
||||
? SecureSocket.secure(socket, host: host, onBadCertificate: (_) => true)
|
||||
: SecureSocket.secure(socket, host: host);
|
||||
try {
|
||||
return await secured.timeout(connectTimeout);
|
||||
} on TimeoutException {
|
||||
socket.destroy();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void write(Uint8List data) {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/// Shared formatting helpers (dates, durations, sizes, phone, gender).
|
||||
library;
|
||||
|
||||
const List<String> kRuMonthsShort = [
|
||||
'янв',
|
||||
'фев',
|
||||
'мар',
|
||||
'апр',
|
||||
'мая',
|
||||
'июн',
|
||||
'июл',
|
||||
'авг',
|
||||
'сен',
|
||||
'окт',
|
||||
'ноя',
|
||||
'дек',
|
||||
];
|
||||
|
||||
String _two(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
/// "512 Б" / "1.5 КБ" / "3.2 МБ" / "1.1 ГБ" — Cyrillic units, 1 decimal.
|
||||
String formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes Б';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} ГБ';
|
||||
}
|
||||
|
||||
/// "m:ss" (e.g. "3:07"); with [padMinutes] the minutes are zero-padded ("03:07").
|
||||
String formatDurationMmSs(Duration d, {bool padMinutes = false}) {
|
||||
final m = d.inMinutes;
|
||||
return '${padMinutes ? _two(m) : m}:${_two(d.inSeconds % 60)}';
|
||||
}
|
||||
|
||||
/// "m:ss" from a raw seconds count.
|
||||
String formatSecondsMmSs(int seconds, {bool padMinutes = false}) =>
|
||||
formatDurationMmSs(Duration(seconds: seconds), padMinutes: padMinutes);
|
||||
|
||||
/// "HH:mm".
|
||||
String formatClock(DateTime dt) => '${_two(dt.hour)}:${_two(dt.minute)}';
|
||||
|
||||
/// "5 мая 2024".
|
||||
String formatDateWords(DateTime dt) =>
|
||||
'${dt.day} ${kRuMonthsShort[dt.month - 1]} ${dt.year}';
|
||||
|
||||
/// "05.04.2024".
|
||||
String formatDateNumeric(DateTime dt) =>
|
||||
'${_two(dt.day)}.${_two(dt.month)}.${dt.year}';
|
||||
|
||||
/// "05.04.2024 14:30".
|
||||
String formatDateTimeNumeric(DateTime dt) =>
|
||||
'${formatDateNumeric(dt)} ${formatClock(dt)}';
|
||||
|
||||
/// "5 мая 2024, 14:30".
|
||||
String formatDateTimeWords(DateTime dt) =>
|
||||
'${formatDateWords(dt)}, ${formatClock(dt)}';
|
||||
|
||||
/// "Был(-а) только что / N мин назад / N ч назад / N дн назад / 5 мая 2024".
|
||||
String formatLastSeen(int secondsSinceEpoch) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000);
|
||||
final diff = DateTime.now().difference(dt);
|
||||
if (diff.inMinutes < 2) return 'Был(-а) только что';
|
||||
if (diff.inMinutes < 60) return 'Был(-а) ${diff.inMinutes} мин назад';
|
||||
if (diff.inHours < 24) return 'Был(-а) ${diff.inHours} ч назад';
|
||||
if (diff.inDays < 7) return 'Был(-а) ${diff.inDays} дн назад';
|
||||
return 'Был(-а) ${formatDateWords(dt)}';
|
||||
}
|
||||
|
||||
/// "+7 (912) 345-67-89" for RU numbers, "+digits" otherwise.
|
||||
/// Accepts an int phone or a string; returns null if there is no usable number.
|
||||
String? formatPhone(dynamic raw) {
|
||||
String? digits;
|
||||
if (raw is int && raw > 0) {
|
||||
digits = raw.toString();
|
||||
} else if (raw is String && raw.isNotEmpty && raw != '***') {
|
||||
digits = raw.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
if (digits.isEmpty) return null;
|
||||
}
|
||||
if (digits == null) return null;
|
||||
if (digits.length == 11 && digits.startsWith('7')) {
|
||||
return '+${digits[0]} (${digits.substring(1, 4)}) '
|
||||
'${digits.substring(4, 7)}-${digits.substring(7, 9)}-${digits.substring(9)}';
|
||||
}
|
||||
return '+$digits';
|
||||
}
|
||||
|
||||
/// 1 → "Мужской", 2 → "Женский", anything else → null.
|
||||
String? formatGender(dynamic raw) {
|
||||
if (raw is! int) return null;
|
||||
if (raw == 1) return 'Мужской';
|
||||
if (raw == 2) return 'Женский';
|
||||
return null;
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import 'package:image/image.dart' as img;
|
||||
const int _avatarMaxDimension = 1024;
|
||||
const int _avatarTargetBytes = 900 * 1024;
|
||||
|
||||
/// Maximum accepted size for a user-picked avatar before compression.
|
||||
const int kMaxAvatarBytes = 8 * 1024 * 1024;
|
||||
|
||||
Future<Uint8List?> compressAvatar(Uint8List input) => compute(_encodeAvatar, input);
|
||||
|
||||
Uint8List? _encodeAvatar(Uint8List input) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../profile/spoof_screen.dart';
|
||||
import '../profile/debug_menu_screen.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/adaptive_shell.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import '../../../backend/api.dart';
|
||||
import '../../../main.dart';
|
||||
|
||||
@@ -152,9 +153,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
shape: kSheetShape,
|
||||
builder: (sheetContext) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
@@ -188,7 +187,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
KometApp.stateOf(appContext)?.applyLocale(const Locale('ru'));
|
||||
KometApp.stateOf(
|
||||
appContext,
|
||||
)?.applyLocale(const Locale('ru'));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
@@ -202,7 +203,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
KometApp.stateOf(appContext)?.applyLocale(const Locale('en'));
|
||||
KometApp.stateOf(
|
||||
appContext,
|
||||
)?.applyLocale(const Locale('en'));
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -220,9 +223,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
context: context,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
shape: kSheetShape,
|
||||
builder: (context) {
|
||||
double progress = _isTOSRead ? 1.0 : 0.0;
|
||||
return StatefulBuilder(
|
||||
@@ -503,13 +504,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
shape: kSheetShape,
|
||||
builder: (_) {
|
||||
return SafeArea(
|
||||
child: const ServerSettingsSheet(),
|
||||
);
|
||||
return SafeArea(child: const ServerSettingsSheet());
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -520,13 +517,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
shape: kSheetShape,
|
||||
builder: (_) {
|
||||
return SafeArea(
|
||||
child: const ProxySettingsSheet(),
|
||||
);
|
||||
return SafeArea(child: const ProxySettingsSheet());
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -537,9 +530,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
shape: kSheetShape,
|
||||
builder: (sheetContext) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
@@ -614,9 +605,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
shape: kSheetShape,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
@@ -725,7 +714,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => _showSecurityOptions(context),
|
||||
onPressed: () =>
|
||||
_showSecurityOptions(context),
|
||||
icon: Icon(
|
||||
Symbols.admin_panel_settings,
|
||||
color: cs.onSurfaceVariant,
|
||||
@@ -840,7 +830,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: _phoneMaskHint(_selectedCountry),
|
||||
hintText: _phoneMaskHint(
|
||||
_selectedCountry,
|
||||
),
|
||||
hintStyle: TextStyle(
|
||||
color: cs.outline,
|
||||
fontSize: 15,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:komet/l10n/app_localizations.dart';
|
||||
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
|
||||
class ProxySettingsSheet extends StatefulWidget {
|
||||
const ProxySettingsSheet({super.key});
|
||||
@@ -57,13 +58,15 @@ class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
|
||||
try {
|
||||
final username = _usernameController.text.trim();
|
||||
final password = _passwordController.text.trim();
|
||||
await ProxyConfig.save(ProxySettings(
|
||||
await ProxyConfig.save(
|
||||
ProxySettings(
|
||||
type: _selectedType,
|
||||
host: host,
|
||||
port: port,
|
||||
username: username.isNotEmpty ? username : null,
|
||||
password: password.isNotEmpty ? password : null,
|
||||
));
|
||||
),
|
||||
);
|
||||
await api.disconnect();
|
||||
await api.connect();
|
||||
if (!mounted) return;
|
||||
@@ -120,16 +123,8 @@ class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const Center(
|
||||
child: SheetGrabber(margin: EdgeInsets.only(bottom: 16)),
|
||||
),
|
||||
Text(
|
||||
l10n.proxySettingsTitle,
|
||||
@@ -197,9 +192,7 @@ class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : () => _apply(l10n),
|
||||
child: Text(
|
||||
isActive ? l10n.proxyApply : l10n.proxyDisable,
|
||||
),
|
||||
child: Text(isActive ? l10n.proxyApply : l10n.proxyDisable),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -278,10 +271,7 @@ class _ProxySettingsSheetState extends State<ProxySettingsSheet> {
|
||||
inputFormatters: inputFormatters,
|
||||
enabled: !_busy,
|
||||
obscureText: obscureText,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
),
|
||||
style: GoogleFonts.inter(color: cs.onSurface, fontSize: 15),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: GoogleFonts.inter(
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
|
||||
class ServerSettingsSheet extends StatefulWidget {
|
||||
const ServerSettingsSheet({super.key});
|
||||
@@ -57,10 +58,13 @@ class _ServerSettingsSheetState extends State<ServerSettingsSheet> {
|
||||
await api.disconnect();
|
||||
unawaited(api.connect());
|
||||
final online = await api.stateStream
|
||||
.firstWhere((s) =>
|
||||
s == SessionState.online || s == SessionState.disconnected)
|
||||
.timeout(const Duration(seconds: 15),
|
||||
onTimeout: () => SessionState.disconnected);
|
||||
.firstWhere(
|
||||
(s) => s == SessionState.online || s == SessionState.disconnected,
|
||||
)
|
||||
.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () => SessionState.disconnected,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (online == SessionState.online) {
|
||||
showCustomNotification(context, l10n.serverSettingsSaved);
|
||||
@@ -83,10 +87,13 @@ class _ServerSettingsSheetState extends State<ServerSettingsSheet> {
|
||||
await api.disconnect();
|
||||
api.connect();
|
||||
final online = await api.stateStream
|
||||
.firstWhere((s) =>
|
||||
s == SessionState.online || s == SessionState.disconnected)
|
||||
.timeout(const Duration(seconds: 15),
|
||||
onTimeout: () => SessionState.disconnected);
|
||||
.firstWhere(
|
||||
(s) => s == SessionState.online || s == SessionState.disconnected,
|
||||
)
|
||||
.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () => SessionState.disconnected,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (online == SessionState.online) {
|
||||
showCustomNotification(context, l10n.serverSettingsSaved);
|
||||
@@ -119,16 +126,8 @@ class _ServerSettingsSheetState extends State<ServerSettingsSheet> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const Center(
|
||||
child: SheetGrabber(margin: EdgeInsets.only(bottom: 16)),
|
||||
),
|
||||
Text(
|
||||
l10n.serverSettingsTitle,
|
||||
@@ -153,9 +152,7 @@ class _ServerSettingsSheetState extends State<ServerSettingsSheet> {
|
||||
hintText: '${ServerConfig.defaultPort}',
|
||||
cs: cs,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
@@ -199,10 +196,7 @@ class _ServerSettingsSheetState extends State<ServerSettingsSheet> {
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
enabled: !_busy,
|
||||
style: GoogleFonts.inter(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
),
|
||||
style: GoogleFonts.inter(color: cs.onSurface, fontSize: 15),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: GoogleFonts.inter(
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../core/utils/format.dart';
|
||||
|
||||
enum CallScreenState { incoming, outgoing, active }
|
||||
|
||||
class CallScreen extends StatefulWidget {
|
||||
@@ -68,12 +70,6 @@ class _CallScreenState extends State<CallScreen>
|
||||
});
|
||||
}
|
||||
|
||||
String get _timerText {
|
||||
final m = (_seconds ~/ 60).toString().padLeft(2, '0');
|
||||
final s = (_seconds % 60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
void _accept() {
|
||||
setState(() {
|
||||
_state = CallScreenState.active;
|
||||
@@ -127,13 +123,8 @@ class _CallScreenState extends State<CallScreen>
|
||||
return AnimatedBuilder(
|
||||
animation: _pulseAnimation,
|
||||
builder: (context, child) {
|
||||
final scale = (isRinging || isOutgoing)
|
||||
? _pulseAnimation.value
|
||||
: 1.0;
|
||||
return Transform.scale(
|
||||
scale: scale,
|
||||
child: child,
|
||||
);
|
||||
final scale = (isRinging || isOutgoing) ? _pulseAnimation.value : 1.0;
|
||||
return Transform.scale(scale: scale, child: child);
|
||||
},
|
||||
child: Container(
|
||||
width: size,
|
||||
@@ -204,7 +195,7 @@ class _CallScreenState extends State<CallScreen>
|
||||
case CallScreenState.outgoing:
|
||||
text = 'Вызов...';
|
||||
case CallScreenState.active:
|
||||
text = _timerText;
|
||||
text = formatSecondsMmSs(_seconds, padMinutes: true);
|
||||
}
|
||||
return Text(
|
||||
text,
|
||||
@@ -322,10 +313,7 @@ class _ActionButton extends StatelessWidget {
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: color,
|
||||
),
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(icon, color: Colors.white, size: 28, fill: 1),
|
||||
),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../main.dart' show api;
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
import '../../../backend/modules/calls.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
|
||||
class CallsTab extends StatefulWidget {
|
||||
const CallsTab({super.key});
|
||||
@@ -81,36 +82,7 @@ class _CallsTabState extends State<CallsTab> {
|
||||
String _formatDate(int timestamp) {
|
||||
if (timestamp == 0) return '';
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
final months = [
|
||||
'янв.',
|
||||
'фев.',
|
||||
'мар.',
|
||||
'апр.',
|
||||
'мая',
|
||||
'июн.',
|
||||
'июл.',
|
||||
'авг.',
|
||||
'сен.',
|
||||
'окт.',
|
||||
'ноя.',
|
||||
'дек.',
|
||||
];
|
||||
return '${dt.day} ${months[dt.month - 1]}';
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderAvatar(ColorScheme cs, String name) {
|
||||
return Container(
|
||||
color: cs.primaryContainer,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
return '${dt.day} ${kRuMonthsShort[dt.month - 1]}';
|
||||
}
|
||||
|
||||
Widget _buildCallItem(
|
||||
@@ -165,18 +137,10 @@ class _CallsTabState extends State<CallsTab> {
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: call.avatarUrl != null && call.avatarUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: call.avatarUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 144,
|
||||
memCacheHeight: 144,
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (context, url, error) =>
|
||||
_buildPlaceholderAvatar(cs, call.name),
|
||||
)
|
||||
: _buildPlaceholderAvatar(cs, call.name),
|
||||
child: KometAvatar(
|
||||
name: call.name,
|
||||
imageUrl: call.avatarUrl,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../backend/modules/messages.dart' show ContactCache;
|
||||
import '../../../core/cache/info_cache.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
|
||||
class _MemberInfo {
|
||||
final int id;
|
||||
@@ -223,7 +225,12 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
_buildAvatar(cs),
|
||||
KometAvatar(
|
||||
name: widget.name,
|
||||
imageUrl: widget.imageUrl,
|
||||
size: 96,
|
||||
fontSize: 36,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
widget.name,
|
||||
@@ -253,39 +260,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── AVATAR ──────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildAvatar(ColorScheme cs) {
|
||||
return Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle, color: cs.primaryContainer),
|
||||
child: widget.imageUrl.isNotEmpty
|
||||
? ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: widget.imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 360,
|
||||
memCacheHeight: 360,
|
||||
errorWidget: (context, error, stack) => _avatarLetters(cs),
|
||||
),
|
||||
)
|
||||
: _avatarLetters(cs),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _avatarLetters(ColorScheme cs) => Center(
|
||||
child: Text(
|
||||
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// ─── SUBTITLE ────────────────────────────────────────────────────────────
|
||||
|
||||
String _subtitle() {
|
||||
@@ -392,10 +366,13 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
}
|
||||
} else {
|
||||
final phone = _contactData?['phone'];
|
||||
final phoneInt =
|
||||
phone is int ? phone : int.tryParse(phone?.toString() ?? '');
|
||||
final phoneInt = phone is int
|
||||
? phone
|
||||
: int.tryParse(phone?.toString() ?? '');
|
||||
if (phoneInt != null && phoneInt > 0) {
|
||||
items.add(_simpleInfoCard(cs, 'Номер телефона', _formatPhone(phoneInt)));
|
||||
items.add(
|
||||
_simpleInfoCard(cs, 'Номер телефона', formatPhone(phoneInt)!),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (widget.chatType == 'CHANNEL') {
|
||||
@@ -417,8 +394,12 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _simpleInfoCard(ColorScheme cs, String label, String value,
|
||||
{bool isLink = false}) {
|
||||
Widget _simpleInfoCard(
|
||||
ColorScheme cs,
|
||||
String label,
|
||||
String value, {
|
||||
bool isLink = false,
|
||||
}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 14),
|
||||
@@ -429,8 +410,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
@@ -458,19 +441,27 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Ссылка-приглашение',
|
||||
style:
|
||||
TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
Text(
|
||||
'Ссылка-приглашение',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(link,
|
||||
Text(
|
||||
link,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF007AFF), fontSize: 15)),
|
||||
color: Color(0xFF007AFF),
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code_2,
|
||||
color: Color(0xFF007AFF), size: 22),
|
||||
icon: const Icon(
|
||||
Icons.qr_code_2,
|
||||
color: Color(0xFF007AFF),
|
||||
size: 22,
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
@@ -492,16 +483,16 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Описание',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
Text(
|
||||
'Описание',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
desc,
|
||||
style:
|
||||
TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4),
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4),
|
||||
maxLines: (_descExpanded || !isLong) ? null : collapsedLines,
|
||||
overflow:
|
||||
(_descExpanded || !isLong) ? null : TextOverflow.ellipsis,
|
||||
overflow: (_descExpanded || !isLong) ? null : TextOverflow.ellipsis,
|
||||
),
|
||||
if (isLong) ...[
|
||||
const SizedBox(height: 6),
|
||||
@@ -509,8 +500,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
onTap: () => setState(() => _descExpanded = !_descExpanded),
|
||||
child: Text(
|
||||
_descExpanded ? 'Свернуть' : 'Ещё',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF007AFF), fontSize: 13),
|
||||
style: const TextStyle(color: Color(0xFF007AFF), fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -598,10 +588,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
if (_selectedTab.isEmpty) return const SizedBox.shrink();
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey(_selectedTab),
|
||||
child: _tabBody(cs),
|
||||
),
|
||||
child: KeyedSubtree(key: ValueKey(_selectedTab), child: _tabBody(cs)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -632,11 +619,16 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon,
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.35), size: 48),
|
||||
Icon(
|
||||
icon,
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.35),
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15)),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -648,7 +640,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
final items = <Widget>[];
|
||||
|
||||
if (widget.chatType == 'DIALOG' && !_isBot) {
|
||||
final bio = (_contactData?['description'] as String?) ??
|
||||
final bio =
|
||||
(_contactData?['description'] as String?) ??
|
||||
(_contactData?['about'] as String?);
|
||||
if (bio != null && bio.isNotEmpty) {
|
||||
items
|
||||
@@ -696,14 +689,19 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(value,
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500)),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -727,7 +725,11 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
}
|
||||
|
||||
Widget _memberAction(
|
||||
ColorScheme cs, IconData icon, String label, VoidCallback onTap) {
|
||||
ColorScheme cs,
|
||||
IconData icon,
|
||||
String label,
|
||||
VoidCallback onTap,
|
||||
) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
@@ -737,8 +739,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
children: [
|
||||
Icon(icon, color: const Color(0xFF007AFF), size: 26),
|
||||
const SizedBox(width: 14),
|
||||
Text(label,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16)),
|
||||
Text(label, style: TextStyle(color: cs.onSurface, fontSize: 16)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -768,8 +769,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
sublabel = 'Был(-а) недавно';
|
||||
}
|
||||
|
||||
final String? roleLabel =
|
||||
member.isOwner ? 'владелец' : (member.isAdmin ? 'Адмін' : null);
|
||||
final String? roleLabel = member.isOwner
|
||||
? 'владелец'
|
||||
: (member.isAdmin ? 'Адмін' : null);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
@@ -778,7 +780,11 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
(avatar != null && avatar.isNotEmpty)
|
||||
? CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundImage: CachedNetworkImageProvider(avatar, maxWidth: 144, maxHeight: 144),
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
avatar,
|
||||
maxWidth: 144,
|
||||
maxHeight: 144,
|
||||
),
|
||||
backgroundColor: cs.primaryContainer,
|
||||
)
|
||||
: CircleAvatar(
|
||||
@@ -787,7 +793,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer, fontSize: 16),
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
@@ -795,21 +803,26 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(name,
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500)),
|
||||
Text(sublabel,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
sublabel,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (roleLabel != null)
|
||||
Text(roleLabel,
|
||||
style:
|
||||
TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
Text(
|
||||
roleLabel,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -821,8 +834,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
final rows = <({String label, String value})>[];
|
||||
final chat = _chatData;
|
||||
if (chat == null) {
|
||||
return Text('Нет данных',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13));
|
||||
return Text(
|
||||
'Нет данных',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
);
|
||||
}
|
||||
|
||||
void add(String label, dynamic val, {bool tsFormat = false}) {
|
||||
@@ -830,7 +845,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
if (val is bool && !val) return;
|
||||
String str;
|
||||
if (tsFormat && val is int && val > 1) {
|
||||
str = _formatTs(val);
|
||||
str = formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(val));
|
||||
} else if (val is bool) {
|
||||
str = 'да';
|
||||
} else {
|
||||
@@ -887,8 +902,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
}
|
||||
|
||||
if (rows.isEmpty) {
|
||||
return Text('Нет данных',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13));
|
||||
return Text(
|
||||
'Нет данных',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
);
|
||||
}
|
||||
|
||||
final extraRows = _buildExtraContactRows();
|
||||
@@ -908,10 +925,12 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
rows[i].value,
|
||||
trailing: _trailingFor(rows[i].label, cs),
|
||||
),
|
||||
if (i < rows.length - 1 || (_extraContactExpanded && extraRows.isNotEmpty))
|
||||
if (i < rows.length - 1 ||
|
||||
(_extraContactExpanded && extraRows.isNotEmpty))
|
||||
Divider(
|
||||
height: 10,
|
||||
color: cs.outlineVariant.withValues(alpha: 0.25)),
|
||||
color: cs.outlineVariant.withValues(alpha: 0.25),
|
||||
),
|
||||
],
|
||||
if (_extraContactExpanded)
|
||||
for (int i = 0; i < extraRows.length; i++) ...[
|
||||
@@ -919,7 +938,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
if (i < extraRows.length - 1)
|
||||
Divider(
|
||||
height: 10,
|
||||
color: cs.outlineVariant.withValues(alpha: 0.25)),
|
||||
color: cs.outlineVariant.withValues(alpha: 0.25),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -932,11 +952,17 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
final rows = <({String label, String value})>[];
|
||||
final reg = c['registrationTime'];
|
||||
if (reg is int && reg > 0) {
|
||||
rows.add((label: 'Регистрация', value: _formatTs(reg)));
|
||||
rows.add((
|
||||
label: 'Регистрация',
|
||||
value: formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(reg)),
|
||||
));
|
||||
}
|
||||
final upd = c['updateTime'];
|
||||
if (upd is int && upd > 0) {
|
||||
rows.add((label: 'Обновлён', value: _formatTs(upd)));
|
||||
rows.add((
|
||||
label: 'Обновлён',
|
||||
value: formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(upd)),
|
||||
));
|
||||
}
|
||||
final country = c['country'];
|
||||
if (country is String && country.isNotEmpty) {
|
||||
@@ -944,7 +970,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
}
|
||||
final gender = c['gender'];
|
||||
if (gender is int) {
|
||||
final g = gender == 1 ? 'Мужской' : (gender == 2 ? 'Женский' : null);
|
||||
final g = formatGender(gender);
|
||||
if (g != null) rows.add((label: 'Пол', value: g));
|
||||
}
|
||||
final phone = c['phone'];
|
||||
@@ -981,11 +1007,17 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
onPressed: () => setState(() => _extraContactExpanded = !_extraContactExpanded),
|
||||
onPressed: () =>
|
||||
setState(() => _extraContactExpanded = !_extraContactExpanded),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(ColorScheme cs, String label, String value, {Widget? trailing}) {
|
||||
Widget _infoRow(
|
||||
ColorScheme cs,
|
||||
String label,
|
||||
String value, {
|
||||
Widget? trailing,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
@@ -995,13 +1027,18 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10)),
|
||||
Text(value,
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500)),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1044,7 +1081,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
// ─── HELPERS ─────────────────────────────────────────────────────────────
|
||||
|
||||
String _formatLastSeen(int secondsSinceEpoch) {
|
||||
final diff = DateTime.now().millisecondsSinceEpoch - secondsSinceEpoch * 1000;
|
||||
final diff =
|
||||
DateTime.now().millisecondsSinceEpoch - secondsSinceEpoch * 1000;
|
||||
if (diff < 60000) return 'только что';
|
||||
if (diff < 3600000) return '${diff ~/ 60000} мин назад';
|
||||
if (diff < 86400000) return '${diff ~/ 3600000} ч назад';
|
||||
@@ -1052,21 +1090,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
return 'давно';
|
||||
}
|
||||
|
||||
String _formatPhone(int phone) {
|
||||
final s = phone.toString();
|
||||
if (s.length == 11 && s.startsWith('7')) {
|
||||
return '+7 ${s.substring(1, 4)} ${s.substring(4, 7)}-'
|
||||
'${s.substring(7, 9)}-${s.substring(9, 11)}';
|
||||
}
|
||||
return '+$s';
|
||||
}
|
||||
|
||||
String _formatTs(int ts) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(ts);
|
||||
return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year} '
|
||||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _pluralCount(int n, String one, String few, String many) {
|
||||
final mod100 = n % 100;
|
||||
final mod10 = n % 10;
|
||||
|
||||
@@ -10,7 +10,10 @@ import 'chat_screen.dart';
|
||||
import 'create_group_flow.dart';
|
||||
import '../../widgets/adaptive_shell.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import '../../widgets/swipe_route.dart';
|
||||
import '../../widgets/sliding_pill_nav.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
|
||||
import '../calls/calls_tab.dart';
|
||||
import '../contacts/contacts_tab.dart';
|
||||
@@ -82,6 +85,17 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
|
||||
int _currentNavIndex = 0;
|
||||
|
||||
static const List<PillNavItem> _chatsNavItems = [
|
||||
PillNavItem(icon: Symbols.chat_bubble, label: 'Чаты'),
|
||||
PillNavItem(icon: Symbols.call, label: 'Звонки'),
|
||||
PillNavItem(icon: Symbols.person_pin, label: 'Контакты'),
|
||||
PillNavItem(
|
||||
icon: Symbols.settings,
|
||||
label: 'Настройки',
|
||||
longPressable: true,
|
||||
),
|
||||
];
|
||||
|
||||
double _navPageAnimStart = 0;
|
||||
double _navPageAnimEnd = 0;
|
||||
final ValueNotifier<double> _navDragDx = ValueNotifier(0);
|
||||
@@ -255,14 +269,20 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
final myId = _profile?.id;
|
||||
if (myId == null) return;
|
||||
|
||||
await ChatsModule.refreshChats(api, selectedBefore.map((c) => c.id).toList());
|
||||
await ChatsModule.refreshChats(
|
||||
api,
|
||||
selectedBefore.map((c) => c.id).toList(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
final selectedAfter = _selectedChatObjects();
|
||||
if (selectedAfter.isEmpty) return;
|
||||
final cats = selectedAfter.map((c) => _categorizeChat(c, myId)).toSet();
|
||||
if (cats.contains(_DeleteKind.blocked) || cats.length > 1) {
|
||||
showCustomNotification(context, 'Статус чатов изменился, попробуйте ещё раз');
|
||||
showCustomNotification(
|
||||
context,
|
||||
'Статус чатов изменился, попробуйте ещё раз',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final kind = cats.single;
|
||||
@@ -324,9 +344,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
return showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
shape: kSheetShape,
|
||||
builder: (ctx) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
@@ -561,7 +579,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_profile = p;
|
||||
_chats = chats.where((c) => !CloudStorageModule.isCloudStorageGroup(c)).toList();
|
||||
_chats = chats
|
||||
.where((c) => !CloudStorageModule.isCloudStorageGroup(c))
|
||||
.toList();
|
||||
_folders = folders;
|
||||
_foldersListKnown = foldersKnown;
|
||||
if (_selectedFolderId != null &&
|
||||
@@ -674,8 +694,10 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
final Map<int, List<CachedChat>> _pageChatsCache = {};
|
||||
|
||||
List<CachedChat> _chatsForPageIndex(int pageIndex) {
|
||||
final baseKey =
|
||||
Object.hash(identityHashCode(_chats), identityHashCode(_folders));
|
||||
final baseKey = Object.hash(
|
||||
identityHashCode(_chats),
|
||||
identityHashCode(_folders),
|
||||
);
|
||||
if (_pageChatsBaseKey != baseKey) {
|
||||
_pageChatsBaseKey = baseKey;
|
||||
_pageChatsCache.clear();
|
||||
@@ -692,7 +714,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
final folder = _folders[pageIndex];
|
||||
base = FoldersModule.isAllChatsFolder(folder)
|
||||
? _chats
|
||||
: _chats.where((c) => FoldersModule.chatMatchesFolder(c, folder)).toList();
|
||||
: _chats
|
||||
.where((c) => FoldersModule.chatMatchesFolder(c, folder))
|
||||
.toList();
|
||||
}
|
||||
final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList()
|
||||
..sort((a, b) => a.favIndex!.compareTo(b.favIndex!));
|
||||
@@ -808,10 +832,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
|
||||
String _formatTime(int? timestamp) {
|
||||
if (timestamp == null || timestamp == 0) return '';
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
final h = dt.hour.toString().padLeft(2, '0');
|
||||
final m = dt.minute.toString().padLeft(2, '0');
|
||||
return '$h:$m';
|
||||
return formatClock(DateTime.fromMillisecondsSinceEpoch(timestamp));
|
||||
}
|
||||
|
||||
Widget _buildChatShimmer() {
|
||||
@@ -1104,7 +1125,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
child: Container(
|
||||
width: 50 * (1.0 - _pullRatio),
|
||||
height: 32,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
margin: const EdgeInsets.only(
|
||||
right: 8,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildFoldedStory(
|
||||
@@ -1210,7 +1233,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
@@ -1388,11 +1413,15 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
);
|
||||
}
|
||||
|
||||
final chatIndex = hasSeparator && index > pinnedCount ? index - 1 : index;
|
||||
final chatIndex = hasSeparator && index > pinnedCount
|
||||
? index - 1
|
||||
: index;
|
||||
final chat = chats[chatIndex];
|
||||
final isPinned = (chat.favIndex ?? 0) > 0;
|
||||
|
||||
if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) {
|
||||
if (chat.type.isNotEmpty &&
|
||||
chat.type == "DIALOG" &&
|
||||
chat.id != 0) {
|
||||
int secondId = _profile?.id ?? 0;
|
||||
for (final entry in chat.participants.entries) {
|
||||
if (entry.key != _profile?.id) {
|
||||
@@ -1400,11 +1429,13 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
break;
|
||||
}
|
||||
}
|
||||
final name = ContactCache.get(secondId);
|
||||
final avatar = ContactCache.getAvatar(secondId);
|
||||
final name = ContactCache.get(secondId) ?? chat.title;
|
||||
final avatar =
|
||||
ContactCache.getAvatar(secondId) ?? chat.iconUrl;
|
||||
// ContactCache.isOfficial covers contacts loaded via opcode 32;
|
||||
// chat.isOfficial covers contacts from the login payload.
|
||||
final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial;
|
||||
final isVerified =
|
||||
ContactCache.isOfficial(secondId) || chat.isOfficial;
|
||||
|
||||
final isPlaceholder =
|
||||
chat.lastMsgText == ChatsModule.lastMsgPlaceholder;
|
||||
@@ -1531,34 +1562,15 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
double navInnerW,
|
||||
double bottomInset,
|
||||
) {
|
||||
final totalWeight = 5.2;
|
||||
final unitWidth = navInnerW / totalWeight;
|
||||
final activeWidth = unitWidth * 2.2;
|
||||
final inactiveWidth = unitWidth * 1.0;
|
||||
final geometry = PillNavGeometry.fromInnerWidth(navInnerW, 4);
|
||||
final inactiveWidth = geometry.inactiveWidth;
|
||||
final bubbleW = geometry.activeWidth - 8;
|
||||
|
||||
double bubbleLeftForIndex(int index) {
|
||||
double lo = 0;
|
||||
for (int i = 0; i < index; i++) {
|
||||
lo += inactiveWidth;
|
||||
}
|
||||
return lo + 4;
|
||||
}
|
||||
double bubbleLeftForIndex(int index) => index * inactiveWidth + 4;
|
||||
|
||||
final leftOffset = bubbleLeftForIndex(_currentNavIndex);
|
||||
final bubbleW = activeWidth - 8;
|
||||
final minBubbleLeft = bubbleLeftForIndex(0);
|
||||
final maxBubbleLeft = bubbleLeftForIndex(3);
|
||||
|
||||
double navInterpolatedWidth(int tabIndex, double rowT) {
|
||||
final rt = rowT.clamp(0.0, 3.0);
|
||||
final i0 = rt.floor().clamp(0, 3);
|
||||
final i1 = rt.ceil().clamp(0, 3);
|
||||
final frac = i0 == i1 ? 0.0 : (rt - i0);
|
||||
double at(int sel, int tab) =>
|
||||
(tab == sel) ? (activeWidth - 0.5) : (inactiveWidth - 0.5);
|
||||
return at(i0, tabIndex) + (at(i1, tabIndex) - at(i0, tabIndex)) * frac;
|
||||
}
|
||||
|
||||
int indexForBubbleLeft(double left) {
|
||||
final cx = left + bubbleW / 2;
|
||||
var best = 0;
|
||||
@@ -1581,20 +1593,6 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
right: 8,
|
||||
bottom: _isSelectionMode ? -100 : bottomInset + 10.0,
|
||||
child: RepaintBoundary(
|
||||
child: Container(
|
||||
height: 68,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(34),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: (_) {
|
||||
@@ -1634,90 +1632,32 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: _navDragDx,
|
||||
builder: (context, navDragDx, _) {
|
||||
final bubbleLeft = _navDragging
|
||||
? (_navDragBaseLeft + navDragDx)
|
||||
.clamp(minBubbleLeft, maxBubbleLeft)
|
||||
: leftOffset;
|
||||
final navRowT =
|
||||
((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0);
|
||||
return Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: _navDragging
|
||||
final position = _navDragging
|
||||
? ((_navDragBaseLeft + navDragDx).clamp(
|
||||
minBubbleLeft,
|
||||
maxBubbleLeft,
|
||||
) -
|
||||
4) /
|
||||
inactiveWidth
|
||||
: _currentNavIndex.toDouble();
|
||||
return SlidingPillNav(
|
||||
items: _chatsNavItems,
|
||||
position: position,
|
||||
animationDuration: _navDragging
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: bubbleLeft,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
width: bubbleW,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: navInnerW,
|
||||
child: Row(
|
||||
children: List.generate(4, (index) {
|
||||
IconData icon;
|
||||
String label;
|
||||
switch (index) {
|
||||
case 0:
|
||||
icon = Symbols.chat_bubble;
|
||||
label = 'Чаты';
|
||||
break;
|
||||
case 1:
|
||||
icon = Symbols.call;
|
||||
label = 'Звонки';
|
||||
break;
|
||||
case 2:
|
||||
icon = Symbols.person_pin;
|
||||
label = 'Контакты';
|
||||
break;
|
||||
default:
|
||||
icon = Symbols.settings;
|
||||
label = 'Настройки';
|
||||
}
|
||||
|
||||
final isSelected = _currentNavIndex == index;
|
||||
final visualSel = navRowT.round().clamp(0, 3);
|
||||
return AnimatedContainer(
|
||||
duration: _navDragging
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: _navDragging
|
||||
? navInterpolatedWidth(index, navRowT)
|
||||
: (isSelected
|
||||
? (activeWidth - 0.5)
|
||||
: (inactiveWidth - 0.5)),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
child: _buildNavItem(
|
||||
index,
|
||||
icon,
|
||||
label,
|
||||
selectedOverride: _navDragging
|
||||
? (index == visualSel)
|
||||
: null,
|
||||
instant: _navDragging,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
geometry: geometry,
|
||||
iconSize: 20,
|
||||
labelGap: 4,
|
||||
onTap: _onNavTabSelected,
|
||||
onItemLongPress: (index, pos) {
|
||||
if (index == 3) _openAccountSwitcher(pos);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1759,8 +1699,10 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
width: pageW * 4,
|
||||
height: pageH,
|
||||
child: AnimatedBuilder(
|
||||
animation: Listenable.merge(
|
||||
[_navPageAnimController, _navDragDx]),
|
||||
animation: Listenable.merge([
|
||||
_navPageAnimController,
|
||||
_navDragDx,
|
||||
]),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -1902,15 +1844,23 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Builder(builder: (_) {
|
||||
child: Builder(
|
||||
builder: (_) {
|
||||
final selected = _selectedChatObjects();
|
||||
final deleteCategory = _selectionDeleteCategoryFor(selected);
|
||||
final deleteCategory = _selectionDeleteCategoryFor(
|
||||
selected,
|
||||
);
|
||||
final anyMuted = selected.any((c) => c.isMuted);
|
||||
final anyPinned = selected.any((c) => (c.favIndex ?? 0) > 0);
|
||||
final anyPinned = selected.any(
|
||||
(c) => (c.favIndex ?? 0) > 0,
|
||||
);
|
||||
return Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
|
||||
icon: Icon(
|
||||
Symbols.arrow_back,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
onPressed: _clearSelection,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -1941,14 +1891,17 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
anyMuted ? Symbols.volume_up : Symbols.volume_off,
|
||||
anyMuted
|
||||
? Symbols.volume_up
|
||||
: Symbols.volume_off,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
onPressed: selected.isEmpty ? null : _onMuteTap,
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1981,7 +1934,11 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
backgroundImage: CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144),
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
imageUrl,
|
||||
maxWidth: 144,
|
||||
maxHeight: 144,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@@ -2114,18 +2071,26 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
return;
|
||||
}
|
||||
if (imageUrl.isNotEmpty) {
|
||||
unawaited(precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144),
|
||||
unawaited(
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(
|
||||
imageUrl,
|
||||
maxWidth: 144,
|
||||
maxHeight: 144,
|
||||
),
|
||||
context,
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
if (widget.onChatSelected != null) {
|
||||
widget.onChatSelected!(DesktopChatSelection(
|
||||
widget.onChatSelected!(
|
||||
DesktopChatSelection(
|
||||
chatId: int.parse(id),
|
||||
name: name,
|
||||
imageUrl: imageUrl,
|
||||
chatType: chatType,
|
||||
));
|
||||
),
|
||||
);
|
||||
} else {
|
||||
pushSwipeable(
|
||||
context,
|
||||
@@ -2155,7 +2120,11 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
radius: 24,
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
backgroundImage: imageUrl.isNotEmpty
|
||||
? CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144)
|
||||
? CachedNetworkImageProvider(
|
||||
imageUrl,
|
||||
maxWidth: 144,
|
||||
maxHeight: 144,
|
||||
)
|
||||
: null,
|
||||
child: imageUrl.isEmpty
|
||||
? Text(
|
||||
@@ -2337,71 +2306,6 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavItem(
|
||||
int index,
|
||||
IconData icon,
|
||||
String label, {
|
||||
bool? selectedOverride,
|
||||
bool instant = false,
|
||||
}) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final bool isSelected = selectedOverride ?? (_currentNavIndex == index);
|
||||
final Duration animDur = instant
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 350);
|
||||
final Duration opacityDur = instant
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 200);
|
||||
final bool isSettings = index == 3;
|
||||
return GestureDetector(
|
||||
onTap: () => _onNavTabSelected(index),
|
||||
onLongPressStart: isSettings
|
||||
? (details) => _openAccountSwitcher(details.globalPosition)
|
||||
: null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isSelected ? cs.onPrimary : cs.onSurface,
|
||||
size: 20,
|
||||
fill: 1,
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: animDur,
|
||||
curve: Curves.easeOutCubic,
|
||||
width: isSelected ? null : 0,
|
||||
child: AnimatedOpacity(
|
||||
duration: opacityDur,
|
||||
opacity: isSelected ? 1.0 : 0.0,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: cs.onPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openAccountSwitcher(Offset point) {
|
||||
Haptics.medium();
|
||||
final controller = AccountSwitcherController()..attach(point);
|
||||
@@ -2537,7 +2441,11 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
child: CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundImage: CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144),
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
imageUrl,
|
||||
maxWidth: 144,
|
||||
maxHeight: 144,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,8 @@ import 'package:flutter/services.dart';
|
||||
import 'package:komet/backend/modules/chats.dart';
|
||||
import 'package:komet/backend/modules/file_uploader.dart';
|
||||
import 'package:komet/backend/modules/upload_notification_service.dart';
|
||||
import 'package:komet/core/utils/format.dart';
|
||||
import 'package:komet/core/utils/logger.dart';
|
||||
import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
|
||||
import 'package:komet/frontend/widgets/custom_notification.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -29,6 +31,7 @@ import '../../widgets/message_bubble.dart';
|
||||
import '../../widgets/theme_reveal.dart';
|
||||
import '../../widgets/message_actions_overlay.dart';
|
||||
import '../../widgets/attachment_panel.dart';
|
||||
import '../../widgets/attachment/attachment_sheet.dart';
|
||||
import '../../widgets/swipe_to_pop.dart';
|
||||
|
||||
class _UploadStatus {
|
||||
@@ -36,11 +39,7 @@ class _UploadStatus {
|
||||
final int sent;
|
||||
final int total;
|
||||
|
||||
const _UploadStatus({
|
||||
this.active = false,
|
||||
this.sent = 0,
|
||||
this.total = 0,
|
||||
});
|
||||
const _UploadStatus({this.active = false, this.sent = 0, this.total = 0});
|
||||
|
||||
bool get awaitingResponse => active && total > 0 && sent >= total;
|
||||
double? get progressValue =>
|
||||
@@ -81,19 +80,21 @@ class ChatScreen extends StatefulWidget {
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen>
|
||||
with TickerProviderStateMixin {
|
||||
class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final GlobalKey _listKey = GlobalKey();
|
||||
final ValueNotifier<bool> _hasText = ValueNotifier(false);
|
||||
bool _isLoading = true;
|
||||
final ValueNotifier<bool> _showAttachmentPanel = ValueNotifier(false);
|
||||
final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus());
|
||||
final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(
|
||||
const _UploadStatus(),
|
||||
);
|
||||
StreamSubscription<UploadEvent>? _uploadSub;
|
||||
StreamSubscription<Packet>? _pushSub;
|
||||
StreamSubscription<MessageEvent>? _messageEventSub;
|
||||
final Map<String, ValueNotifier<Map<String, dynamic>?>> _reactionNotifiers = {};
|
||||
final Map<String, ValueNotifier<Map<String, dynamic>?>> _reactionNotifiers =
|
||||
{};
|
||||
|
||||
ValueNotifier<Map<String, dynamic>?> _reactionNotifierFor(CachedMessage m) {
|
||||
final existing = _reactionNotifiers[m.id];
|
||||
@@ -108,11 +109,14 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
|
||||
void _pruneReactionNotifiers() {
|
||||
final liveIds = _messages.map((m) => m.id).toSet();
|
||||
final dead = _reactionNotifiers.keys.where((id) => !liveIds.contains(id)).toList();
|
||||
final dead = _reactionNotifiers.keys
|
||||
.where((id) => !liveIds.contains(id))
|
||||
.toList();
|
||||
for (final id in dead) {
|
||||
_reactionNotifiers.remove(id)?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final Set<int> _typingUserIds = {};
|
||||
final Map<int, Timer> _typingTimers = {};
|
||||
int _otherStatus = 0;
|
||||
@@ -131,7 +135,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
int _tempIdCounter = 0;
|
||||
late final AnimationController _attachAnim;
|
||||
|
||||
String _nextTempId() => 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}';
|
||||
String _nextTempId() =>
|
||||
'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}';
|
||||
late AnimationController _shimmerController;
|
||||
Timer? _shimmerStartTimer;
|
||||
bool _historyKickedOff = false;
|
||||
@@ -166,9 +171,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
);
|
||||
_showAttachmentPanel.addListener(_onAttachPanelToggle);
|
||||
_pushSub = api.pushStream
|
||||
.where((p) =>
|
||||
p.opcode == Opcode.notifMark ||
|
||||
p.opcode == Opcode.notifTyping)
|
||||
.where(
|
||||
(p) => p.opcode == Opcode.notifMark || p.opcode == Opcode.notifTyping,
|
||||
)
|
||||
.listen(_onIncomingPush);
|
||||
_messageEventSub = ChatsModule.messageEvents
|
||||
.where((e) => e.chatId == widget.chatId)
|
||||
@@ -205,7 +210,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (!mounted) return;
|
||||
_myId = p?.id ?? 0;
|
||||
|
||||
ChatsModule.getChat(_myId, widget.chatId).then((value) {
|
||||
ChatsModule.getChat(_myId, widget.chatId)
|
||||
.then((value) {
|
||||
if (mounted && value.isNotEmpty) {
|
||||
setState(() {
|
||||
chat = value.first;
|
||||
@@ -213,7 +219,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_recomputeHeaderStatus();
|
||||
_syncOtherReadTime();
|
||||
}
|
||||
}).catchError((_) {});
|
||||
})
|
||||
.catchError((_) {});
|
||||
|
||||
final firstRows = await AppDatabase.loadMessages(
|
||||
_myId,
|
||||
@@ -253,6 +260,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (!mounted) return;
|
||||
_kickoffHistory();
|
||||
}
|
||||
|
||||
anim.addStatusListener(onStatus);
|
||||
safety = Timer(const Duration(milliseconds: 400), () {
|
||||
anim.removeStatusListener(onStatus);
|
||||
@@ -321,10 +329,12 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (mounted) {
|
||||
_applyMergedMessages(updatedRows, markLoaded: true);
|
||||
}
|
||||
unawaited(ChatsModule.reconcileLastMessageIfPlaceholder(_myId, widget.chatId));
|
||||
unawaited(
|
||||
ChatsModule.reconcileLastMessageIfPlaceholder(_myId, widget.chatId),
|
||||
);
|
||||
_loadForwardedSenderNames();
|
||||
} catch (e) {
|
||||
debugPrint('Error fetching history: $e');
|
||||
logger.e('Error fetching history: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
@@ -338,9 +348,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
List<Map<String, dynamic>> rowsDesc, {
|
||||
bool markLoaded = false,
|
||||
}) {
|
||||
final byId = <String, CachedMessage>{
|
||||
for (final m in _messages) m.id: m,
|
||||
};
|
||||
final byId = <String, CachedMessage>{for (final m in _messages) m.id: m};
|
||||
final merged = <CachedMessage>[];
|
||||
for (final row in rowsDesc.reversed) {
|
||||
final fresh = CachedMessage.fromDbRow(row);
|
||||
@@ -634,20 +642,6 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
String _formatLastSeen(int secondsSinceEpoch) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000);
|
||||
final diff = DateTime.now().difference(dt);
|
||||
if (diff.inMinutes < 2) return 'Был(-а) только что';
|
||||
if (diff.inMinutes < 60) return 'Был(-а) ${diff.inMinutes} мин назад';
|
||||
if (diff.inHours < 24) return 'Был(-а) ${diff.inHours} ч назад';
|
||||
if (diff.inDays < 7) return 'Был(-а) ${diff.inDays} дн назад';
|
||||
const months = [
|
||||
'янв', 'фев', 'мар', 'апр', 'мая', 'июн',
|
||||
'июл', 'авг', 'сен', 'окт', 'ноя', 'дек',
|
||||
];
|
||||
return 'Был(-а) ${dt.day} ${months[dt.month - 1]} ${dt.year}';
|
||||
}
|
||||
|
||||
void _recomputeHeaderStatus() {
|
||||
_headerStatusNotifier.value = _headerStatus();
|
||||
}
|
||||
@@ -665,7 +659,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (_otherStatus == 1) return 'В сети';
|
||||
if (_otherStatus == 3) return 'Был(-а) недавно';
|
||||
final s = _otherSeenTime;
|
||||
if (s != null && s > 0) return _formatLastSeen(s);
|
||||
if (s != null && s > 0) return formatLastSeen(s);
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -719,7 +713,6 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
try {
|
||||
|
||||
final tempMessage = CachedMessage(
|
||||
id: tempId,
|
||||
accountId: _myId,
|
||||
@@ -746,7 +739,11 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_scrollToBottom();
|
||||
_checkPrankTrigger(tempMessage);
|
||||
|
||||
final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text);
|
||||
final actualId = await messagesModule.sendMessage(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
text,
|
||||
);
|
||||
|
||||
final index = _messages.indexWhere((m) => m.id == tempId);
|
||||
if (index != -1 && mounted) {
|
||||
@@ -901,26 +898,34 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
for (int i = 0; i < _messages.length; i++) {
|
||||
final msg = _messages[i];
|
||||
final msgDate = DateTime.fromMillisecondsSinceEpoch(msg.time);
|
||||
final dayMillis = DateTime(msgDate.year, msgDate.month, msgDate.day)
|
||||
.millisecondsSinceEpoch;
|
||||
final dayMillis = DateTime(
|
||||
msgDate.year,
|
||||
msgDate.month,
|
||||
msgDate.day,
|
||||
).millisecondsSinceEpoch;
|
||||
|
||||
bool needSeparator = i == 0;
|
||||
if (!needSeparator) {
|
||||
final prevDate =
|
||||
DateTime.fromMillisecondsSinceEpoch(_messages[i - 1].time);
|
||||
final prevDayMillis =
|
||||
DateTime(prevDate.year, prevDate.month, prevDate.day)
|
||||
.millisecondsSinceEpoch;
|
||||
final prevDate = DateTime.fromMillisecondsSinceEpoch(
|
||||
_messages[i - 1].time,
|
||||
);
|
||||
final prevDayMillis = DateTime(
|
||||
prevDate.year,
|
||||
prevDate.month,
|
||||
prevDate.day,
|
||||
).millisecondsSinceEpoch;
|
||||
needSeparator = dayMillis != prevDayMillis;
|
||||
}
|
||||
|
||||
if (needSeparator) {
|
||||
_separatorKeys.putIfAbsent(dayMillis, () => GlobalKey());
|
||||
usedDates.add(dayMillis);
|
||||
items.add(_DateSeparatorItem(
|
||||
items.add(
|
||||
_DateSeparatorItem(
|
||||
DateTime.fromMillisecondsSinceEpoch(dayMillis),
|
||||
_separatorKeys[dayMillis]!,
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
items.add(_MessageItem(msg, i));
|
||||
@@ -991,8 +996,18 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (d == yesterday) return 'Вчера';
|
||||
|
||||
const months = [
|
||||
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря',
|
||||
'января',
|
||||
'февраля',
|
||||
'марта',
|
||||
'апреля',
|
||||
'мая',
|
||||
'июня',
|
||||
'июля',
|
||||
'августа',
|
||||
'сентября',
|
||||
'октября',
|
||||
'ноября',
|
||||
'декабря',
|
||||
];
|
||||
if (date.year == now.year) {
|
||||
return '${date.day} ${months[date.month - 1]}';
|
||||
@@ -1000,8 +1015,12 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
return '${date.day} ${months[date.month - 1]} ${date.year}';
|
||||
}
|
||||
|
||||
Widget _buildDateSeparatorWidget(BuildContext context, DateTime date,
|
||||
{Key? key, bool floating = false}) {
|
||||
Widget _buildDateSeparatorWidget(
|
||||
BuildContext context,
|
||||
DateTime date, {
|
||||
Key? key,
|
||||
bool floating = false,
|
||||
}) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
key: key,
|
||||
@@ -1028,8 +1047,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme =
|
||||
_prankActive ? _prankPinkTheme(Theme.of(context)) : Theme.of(context);
|
||||
final theme = _prankActive
|
||||
? _prankPinkTheme(Theme.of(context))
|
||||
: Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
|
||||
// TODO: Локализация
|
||||
@@ -1052,12 +1072,14 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => ChatInfoScreen(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChatInfoScreen(
|
||||
chatId: widget.chatId,
|
||||
name: widget.name,
|
||||
imageUrl: widget.imageUrl,
|
||||
chatType: widget.chatType)
|
||||
)
|
||||
chatType: widget.chatType,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: AppBar(
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
@@ -1084,15 +1106,24 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (widget.imageUrl.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundImage: CachedNetworkImageProvider(widget.imageUrl, maxWidth: 144, maxHeight: 144),
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
widget.imageUrl,
|
||||
maxWidth: 144,
|
||||
maxHeight: 144,
|
||||
),
|
||||
)
|
||||
else
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: cs.primaryContainer,
|
||||
child: Text(
|
||||
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?',
|
||||
style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12),
|
||||
widget.name.isNotEmpty
|
||||
? widget.name[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -1155,7 +1186,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -1230,16 +1262,20 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final item = items[items.length - 1 - index];
|
||||
|
||||
if (item is _DateSeparatorItem) {
|
||||
return _buildDateSeparatorWidget(context, item.date,
|
||||
key: item.key);
|
||||
return _buildDateSeparatorWidget(
|
||||
context,
|
||||
item.date,
|
||||
key: item.key,
|
||||
);
|
||||
}
|
||||
|
||||
final msgItem = item as _MessageItem;
|
||||
final message = msgItem.message;
|
||||
final msgIndex = msgItem.index;
|
||||
final isMe = message.senderId == _myId;
|
||||
final prevMessage =
|
||||
msgIndex > 0 ? _messages[msgIndex - 1] : null;
|
||||
final prevMessage = msgIndex > 0
|
||||
? _messages[msgIndex - 1]
|
||||
: null;
|
||||
final nextMessage = msgIndex < _messages.length - 1
|
||||
? _messages[msgIndex + 1]
|
||||
: null;
|
||||
@@ -1303,7 +1339,11 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
),
|
||||
);
|
||||
},
|
||||
child: _buildDateSeparatorWidget(context, date, floating: true),
|
||||
child: _buildDateSeparatorWidget(
|
||||
context,
|
||||
date,
|
||||
floating: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -1482,7 +1522,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final t = _attachAnim.value;
|
||||
return IgnorePointer(
|
||||
ignoring: t > 0.5,
|
||||
child: Opacity(opacity: (1 - t).clamp(0.0, 1.0), child: child),
|
||||
child: Opacity(
|
||||
opacity: (1 - t).clamp(0.0, 1.0),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
@@ -1490,14 +1533,22 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400),
|
||||
Icon(
|
||||
Symbols.face,
|
||||
color: mutedIcon,
|
||||
size: 24,
|
||||
weight: 400,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Focus(
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.enter &&
|
||||
!HardwareKeyboard.instance.isShiftPressed) {
|
||||
event.logicalKey ==
|
||||
LogicalKeyboardKey.enter &&
|
||||
!HardwareKeyboard
|
||||
.instance
|
||||
.isShiftPressed) {
|
||||
if (_hasText.value) _sendMessage();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@@ -1505,7 +1556,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
},
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
@@ -1526,7 +1580,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
),
|
||||
_AttachButton(
|
||||
hasText: _hasText,
|
||||
panelOpen: _showAttachmentPanel,
|
||||
onOpen: _openAttachmentSheet,
|
||||
uploadStatus: _uploadStatus,
|
||||
mutedIcon: mutedIcon,
|
||||
cs: cs,
|
||||
@@ -1547,7 +1601,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final t = _attachAnim.value;
|
||||
return IgnorePointer(
|
||||
ignoring: t < 0.5,
|
||||
child: Opacity(opacity: t.clamp(0.0, 1.0), child: child),
|
||||
child: Opacity(
|
||||
opacity: t.clamp(0.0, 1.0),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: _HistoryStrip(
|
||||
@@ -1597,7 +1654,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
height: 54,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: hasText ? cs.primary : cs.surfaceContainerHighest,
|
||||
color: hasText
|
||||
? cs.primary
|
||||
: cs.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: GestureDetector(
|
||||
@@ -1669,12 +1728,14 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
Future<void> _sendHistoryFile(FileHistoryEntry entry) async {
|
||||
final tempId = _addOptimisticFileMessage(FileAttachment(
|
||||
final tempId = _addOptimisticFileMessage(
|
||||
FileAttachment(
|
||||
fileId: entry.fileId,
|
||||
fileToken: entry.token,
|
||||
name: entry.filename,
|
||||
size: entry.size,
|
||||
));
|
||||
),
|
||||
);
|
||||
_showAttachmentPanel.value = false;
|
||||
try {
|
||||
final ok = await messagesModule.sendFileMessage(
|
||||
@@ -1694,10 +1755,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final ok = await messagesModule.sendFileMessage(widget.chatId, fileId);
|
||||
if (!mounted) return ok;
|
||||
if (ok) {
|
||||
FileHistoryCache.add(FileHistoryEntry(
|
||||
fileId: fileId,
|
||||
sentAt: DateTime.now(),
|
||||
));
|
||||
FileHistoryCache.add(
|
||||
FileHistoryEntry(fileId: fileId, sentAt: DateTime.now()),
|
||||
);
|
||||
_updateFileMessageStatus(tempId, 'sent');
|
||||
_showAttachmentPanel.value = false;
|
||||
} else {
|
||||
@@ -1712,6 +1772,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
}
|
||||
|
||||
void _openAttachmentSheet() {
|
||||
showAttachmentSheet(context);
|
||||
}
|
||||
|
||||
Future<void> _pickAndUploadFile() async {
|
||||
final result = await FilePicker.platform.pickFiles();
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
@@ -1721,10 +1785,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_showAttachmentPanel.value = false;
|
||||
_uploadStatus.value = _UploadStatus(active: true, total: file.size);
|
||||
|
||||
final tempId = _addOptimisticFileMessage(FileAttachment(
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
));
|
||||
final tempId = _addOptimisticFileMessage(
|
||||
FileAttachment(name: file.name, size: file.size),
|
||||
);
|
||||
|
||||
UploadNotificationService.start(file.name);
|
||||
|
||||
@@ -1748,11 +1811,16 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (!mounted) return;
|
||||
switch (event) {
|
||||
case UploadProgress(:final sent, :final total):
|
||||
_uploadStatus.value = _UploadStatus(active: true, sent: sent, total: total);
|
||||
_uploadStatus.value = _UploadStatus(
|
||||
active: true,
|
||||
sent: sent,
|
||||
total: total,
|
||||
);
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
final elapsed = nowMs - notifLastMs;
|
||||
if (elapsed >= 500) {
|
||||
notifSpeedBps = ((sent - notifLastSent) * 1000 / elapsed).round();
|
||||
notifSpeedBps = ((sent - notifLastSent) * 1000 / elapsed)
|
||||
.round();
|
||||
notifLastSent = sent;
|
||||
notifLastMs = nowMs;
|
||||
}
|
||||
@@ -1767,14 +1835,16 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
case UploadDone(:final fileId, :final token, :final url):
|
||||
stopNotif();
|
||||
FileHistoryCache.add(FileHistoryEntry(
|
||||
FileHistoryCache.add(
|
||||
FileHistoryEntry(
|
||||
fileId: fileId,
|
||||
url: url,
|
||||
token: token,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
sentAt: DateTime.now(),
|
||||
));
|
||||
),
|
||||
);
|
||||
_updateFileMessageStatus(
|
||||
tempId,
|
||||
'sent',
|
||||
@@ -1797,7 +1867,11 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final inFlight = _messages.firstWhere(
|
||||
(m) => m.id == tempId,
|
||||
orElse: () => CachedMessage(
|
||||
id: '', accountId: 0, chatId: 0, senderId: 0, time: 0,
|
||||
id: '',
|
||||
accountId: 0,
|
||||
chatId: 0,
|
||||
senderId: 0,
|
||||
time: 0,
|
||||
),
|
||||
);
|
||||
if (inFlight.id == tempId && inFlight.status == 'sending') {
|
||||
@@ -1820,14 +1894,14 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
|
||||
class _AttachButton extends StatelessWidget {
|
||||
final ValueNotifier<bool> hasText;
|
||||
final ValueNotifier<bool> panelOpen;
|
||||
final VoidCallback onOpen;
|
||||
final ValueNotifier<_UploadStatus> uploadStatus;
|
||||
final Color mutedIcon;
|
||||
final ColorScheme cs;
|
||||
|
||||
const _AttachButton({
|
||||
required this.hasText,
|
||||
required this.panelOpen,
|
||||
required this.onOpen,
|
||||
required this.uploadStatus,
|
||||
required this.mutedIcon,
|
||||
required this.cs,
|
||||
@@ -1836,19 +1910,16 @@ class _AttachButton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([hasText, panelOpen, uploadStatus]),
|
||||
listenable: Listenable.merge([hasText, uploadStatus]),
|
||||
builder: (context, _) {
|
||||
final isText = hasText.value;
|
||||
final open = panelOpen.value;
|
||||
final status = uploadStatus.value;
|
||||
final iconColor = status.awaitingResponse
|
||||
? cs.primary
|
||||
: (status.active || open
|
||||
: (status.active
|
||||
? cs.onSurfaceVariant.withValues(alpha: 0.5)
|
||||
: mutedIcon);
|
||||
final onTap = (isText || status.active || open)
|
||||
? null
|
||||
: () => panelOpen.value = true;
|
||||
final onTap = (isText || status.active) ? null : onOpen;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: isText ? 0 : 36,
|
||||
@@ -1935,7 +2006,10 @@ class _HistoryStrip extends StatelessWidget {
|
||||
return AnimatedBuilder(
|
||||
animation: anim,
|
||||
builder: (context, child) {
|
||||
final raw = ((anim.value - startInterval) / 0.45).clamp(0.0, 1.0);
|
||||
final raw = ((anim.value - startInterval) / 0.45).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
);
|
||||
final v = Curves.easeOutCubic.transform(raw);
|
||||
return Opacity(
|
||||
opacity: v,
|
||||
@@ -1951,9 +2025,12 @@ class _HistoryStrip extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
|
||||
border: Border.all(
|
||||
color: cs.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
child: Stack(children: [
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
@@ -1969,10 +2046,15 @@ class _HistoryStrip extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 3,
|
||||
),
|
||||
child: Text(
|
||||
_labelForEntry(e),
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 9),
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 9,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
@@ -2008,7 +2090,8 @@ class _HistoryStrip extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -2213,10 +2296,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> {
|
||||
_controller?.updatePointer(d.globalPosition),
|
||||
onLongPressEnd: (_) => _controller?.commit(),
|
||||
onSecondaryTapDown: _onSecondaryTapDown,
|
||||
child: RepaintBoundary(
|
||||
key: _boundaryKey,
|
||||
child: widget.child,
|
||||
),
|
||||
child: RepaintBoundary(key: _boundaryKey, child: widget.child),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2250,9 +2330,10 @@ class _SentMessageAnimationState extends State<_SentMessageAnimation>
|
||||
duration: const Duration(milliseconds: 220),
|
||||
);
|
||||
_opacity = CurvedAnimation(parent: _ctrl, curve: Curves.easeOut);
|
||||
_slide = Tween<double>(begin: 16, end: 0).animate(
|
||||
CurvedAnimation(parent: _ctrl, curve: Curves.easeOut),
|
||||
);
|
||||
_slide = Tween<double>(
|
||||
begin: 16,
|
||||
end: 0,
|
||||
).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
||||
_ctrl.forward().whenComplete(widget.onComplete);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,20 +11,17 @@ import '../../../core/storage/token_storage.dart';
|
||||
import '../../../core/utils/image_utils.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import '../../widgets/swipe_route.dart';
|
||||
import 'chat_screen.dart';
|
||||
|
||||
const int _maxAvatarBytes = 8 * 1024 * 1024;
|
||||
|
||||
Future<void> showCreateGroupFlow(BuildContext context) async {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
shape: kSheetShape,
|
||||
builder: (_) => const _CreateGroupFlow(),
|
||||
);
|
||||
}
|
||||
@@ -70,7 +67,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
}
|
||||
final list = await ContactsModule.getContacts(myId);
|
||||
list.removeWhere((c) => c.id == myId);
|
||||
list.sort((a, b) => _displayName(a).toLowerCase().compareTo(_displayName(b).toLowerCase()));
|
||||
list.sort(
|
||||
(a, b) => _displayName(
|
||||
a,
|
||||
).toLowerCase().compareTo(_displayName(b).toLowerCase()),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_all = list;
|
||||
@@ -102,7 +103,7 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
if (path == null) return;
|
||||
final file = File(path);
|
||||
final size = await file.length();
|
||||
if (size > _maxAvatarBytes) {
|
||||
if (size > kMaxAvatarBytes) {
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)');
|
||||
return;
|
||||
@@ -134,7 +135,9 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
if (url != null) {
|
||||
final bytes = await compressAvatar(await _avatar!.readAsBytes());
|
||||
if (bytes == null) {
|
||||
if (mounted) showCustomNotification(context, 'Не удалось обработать аватарку');
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Не удалось обработать аватарку');
|
||||
}
|
||||
} else {
|
||||
final token = await fileUploader.uploadImage(
|
||||
Uri.parse(url),
|
||||
@@ -142,7 +145,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
filename: 'avatar.jpg',
|
||||
);
|
||||
if (token != null) {
|
||||
await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token);
|
||||
await ChatsModule.setChatPhoto(
|
||||
api,
|
||||
chatId: chat.id,
|
||||
photoToken: token,
|
||||
);
|
||||
} else if (mounted) {
|
||||
showCustomNotification(context, 'Не удалось загрузить аватарку');
|
||||
}
|
||||
@@ -183,7 +190,9 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
padding: EdgeInsets.only(bottom: viewInsets.bottom),
|
||||
child: SafeArea(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.85),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.85,
|
||||
),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.easeOut,
|
||||
@@ -193,7 +202,10 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
? Offset(-0.05, 0)
|
||||
: Offset(0.05, 0);
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(begin: offset, end: Offset.zero).animate(anim),
|
||||
position: Tween<Offset>(
|
||||
begin: offset,
|
||||
end: Offset.zero,
|
||||
).animate(anim),
|
||||
child: FadeTransition(opacity: anim, child: child),
|
||||
);
|
||||
},
|
||||
@@ -217,7 +229,9 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
final query = _search.text.trim().toLowerCase();
|
||||
final filtered = query.isEmpty
|
||||
? _all
|
||||
: _all.where((c) => _displayName(c).toLowerCase().contains(query)).toList();
|
||||
: _all
|
||||
.where((c) => _displayName(c).toLowerCase().contains(query))
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -269,7 +283,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Найти по имени',
|
||||
hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
prefixIcon: Icon(Symbols.search, color: cs.onSurfaceVariant, size: 20),
|
||||
prefixIcon: Icon(
|
||||
Symbols.search,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
@@ -291,7 +309,10 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
return InkWell(
|
||||
onTap: () => _toggle(c),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_Avatar(contact: c, size: 40, cs: cs),
|
||||
@@ -316,7 +337,9 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
Text(
|
||||
_statusText(c),
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.8),
|
||||
color: cs.onSurfaceVariant.withValues(
|
||||
alpha: 0.8,
|
||||
),
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -333,7 +356,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
color: cs.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Symbols.check, color: cs.onPrimary, size: 16),
|
||||
child: Icon(
|
||||
Symbols.check,
|
||||
color: cs.onPrimary,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -419,7 +446,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _avatar != null
|
||||
? Image.file(_avatar!, fit: BoxFit.cover)
|
||||
: Icon(Symbols.add_a_photo, color: cs.onSurfaceVariant, size: 20),
|
||||
: Icon(
|
||||
Symbols.add_a_photo,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -431,7 +462,10 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Название группы',
|
||||
hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16),
|
||||
hintStyle: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 16,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
),
|
||||
@@ -500,7 +534,10 @@ class _Avatar extends StatelessWidget {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(color: cs.primaryContainer, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initial,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../core/cache/info_cache.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
import '../../widgets/swipe_route.dart';
|
||||
import '../chats/chat_screen.dart';
|
||||
|
||||
@@ -96,64 +97,10 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
||||
if (_isBot) return 'Бот';
|
||||
if (_presenceStatus == 1) return 'В сети';
|
||||
if (_presenceStatus == 3) return 'Был(-а) недавно';
|
||||
if (_seenTime != null && _seenTime! > 0) return _formatLastSeen(_seenTime!);
|
||||
if (_seenTime != null && _seenTime! > 0) return formatLastSeen(_seenTime!);
|
||||
return '';
|
||||
}
|
||||
|
||||
String _formatLastSeen(int secondsSinceEpoch) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000);
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(dt);
|
||||
if (diff.inMinutes < 2) return 'Был(-а) только что';
|
||||
if (diff.inMinutes < 60) return 'Был(-а) ${diff.inMinutes} мин назад';
|
||||
if (diff.inHours < 24) return 'Был(-а) ${diff.inHours} ч назад';
|
||||
if (diff.inDays < 7) return 'Был(-а) ${diff.inDays} дн назад';
|
||||
return 'Был(-а) ${_formatDate(dt)}';
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt) {
|
||||
const months = [
|
||||
'янв', 'фев', 'мар', 'апр', 'мая', 'июн',
|
||||
'июл', 'авг', 'сен', 'окт', 'ноя', 'дек',
|
||||
];
|
||||
return '${dt.day} ${months[dt.month - 1]} ${dt.year}';
|
||||
}
|
||||
|
||||
String _formatDateTime(int msSinceEpoch) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(msSinceEpoch);
|
||||
final hh = dt.hour.toString().padLeft(2, '0');
|
||||
final mm = dt.minute.toString().padLeft(2, '0');
|
||||
return '${_formatDate(dt)}, $hh:$mm';
|
||||
}
|
||||
|
||||
String? _formatPhone(dynamic raw) {
|
||||
String? digits;
|
||||
if (raw is int && raw > 0) {
|
||||
digits = raw.toString();
|
||||
} else if (raw is String && raw.isNotEmpty && raw != '***') {
|
||||
digits = raw.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
if (digits.isEmpty) return null;
|
||||
}
|
||||
if (digits == null) return null;
|
||||
if (digits.length == 11 && digits.startsWith('7')) {
|
||||
final p = digits;
|
||||
return '+${p[0]} (${p.substring(1, 4)}) ${p.substring(4, 7)}-${p.substring(7, 9)}-${p.substring(9)}';
|
||||
}
|
||||
return '+$digits';
|
||||
}
|
||||
|
||||
String? _formatGender(dynamic raw) {
|
||||
if (raw is! int) return null;
|
||||
switch (raw) {
|
||||
case 1:
|
||||
return 'Мужской';
|
||||
case 2:
|
||||
return 'Женский';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openChat() async {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return;
|
||||
@@ -204,7 +151,12 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildAvatar(cs),
|
||||
KometAvatar(
|
||||
name: _displayName(),
|
||||
imageUrl: _avatarUrl(),
|
||||
size: 96,
|
||||
fontSize: 36,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildNameRow(cs),
|
||||
const SizedBox(height: 4),
|
||||
@@ -225,41 +177,6 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(ColorScheme cs) {
|
||||
final url = _avatarUrl();
|
||||
return Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: cs.primaryContainer,
|
||||
),
|
||||
child: (url != null && url.isNotEmpty)
|
||||
? ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, _, _) => _avatarLetters(cs),
|
||||
),
|
||||
)
|
||||
: _avatarLetters(cs),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _avatarLetters(ColorScheme cs) {
|
||||
final name = _displayName();
|
||||
return Center(
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNameRow(ColorScheme cs) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -280,12 +197,7 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
||||
),
|
||||
if (_isVerified) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
Symbols.verified,
|
||||
color: cs.primary,
|
||||
size: 20,
|
||||
fill: 1,
|
||||
),
|
||||
Icon(Symbols.verified, color: cs.primary, size: 20, fill: 1),
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -295,8 +207,7 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
||||
final actions = <({IconData icon, String label, VoidCallback? onTap})>[
|
||||
(icon: Symbols.chat_bubble, label: 'Чат', onTap: _openChat),
|
||||
(icon: Symbols.notifications, label: 'Звук', onTap: null),
|
||||
if (!_isBot)
|
||||
(icon: Symbols.call, label: 'Звонок', onTap: null),
|
||||
if (!_isBot) (icon: Symbols.call, label: 'Звонок', onTap: null),
|
||||
];
|
||||
return Row(
|
||||
children: [
|
||||
@@ -336,7 +247,7 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
||||
|
||||
final rows = <Widget>[];
|
||||
|
||||
final phoneStr = _formatPhone(c['phone']);
|
||||
final phoneStr = formatPhone(c['phone']);
|
||||
if (phoneStr != null) {
|
||||
rows.add(_infoRow(cs, Symbols.phone, 'Телефон', phoneStr));
|
||||
}
|
||||
@@ -346,24 +257,45 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
||||
rows.add(_infoRow(cs, Symbols.public, 'Страна', country));
|
||||
}
|
||||
|
||||
final genderStr = _formatGender(c['gender']);
|
||||
final genderStr = formatGender(c['gender']);
|
||||
if (genderStr != null) {
|
||||
rows.add(_infoRow(cs, Symbols.wc, 'Пол', genderStr));
|
||||
}
|
||||
|
||||
final regTime = c['registrationTime'] as int?;
|
||||
if (regTime != null && regTime > 0) {
|
||||
rows.add(_infoRow(cs, Symbols.event, 'Регистрация', _formatDateTime(regTime)));
|
||||
rows.add(
|
||||
_infoRow(
|
||||
cs,
|
||||
Symbols.event,
|
||||
'Регистрация',
|
||||
formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(regTime)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final updateTime = c['updateTime'] as int?;
|
||||
if (updateTime != null && updateTime > 0) {
|
||||
rows.add(_infoRow(cs, Symbols.update, 'Обновлён', _formatDateTime(updateTime)));
|
||||
rows.add(
|
||||
_infoRow(
|
||||
cs,
|
||||
Symbols.update,
|
||||
'Обновлён',
|
||||
formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(updateTime)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final accountStatus = c['accountStatus'];
|
||||
if (accountStatus is int && accountStatus != 0) {
|
||||
rows.add(_infoRow(cs, Symbols.account_circle, 'Статус аккаунта', accountStatus.toString()));
|
||||
rows.add(
|
||||
_infoRow(
|
||||
cs,
|
||||
Symbols.account_circle,
|
||||
'Статус аккаунта',
|
||||
accountStatus.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final desc = (c['description'] as String?)?.trim();
|
||||
@@ -383,7 +315,9 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
||||
|
||||
final opts = _options();
|
||||
if (opts.isNotEmpty) {
|
||||
rows.add(_infoRow(cs, Symbols.label, 'Флаги', opts.join(', '), multiline: true));
|
||||
rows.add(
|
||||
_infoRow(cs, Symbols.label, 'Флаги', opts.join(', '), multiline: true),
|
||||
);
|
||||
}
|
||||
|
||||
rows.add(_infoRow(cs, Symbols.tag, 'ID', widget.contactId.toString()));
|
||||
@@ -401,7 +335,10 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
||||
children: [
|
||||
for (var i = 0; i < rows.length; i++) ...[
|
||||
if (i > 0)
|
||||
Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: cs.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
rows[i],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
@@ -6,6 +5,8 @@ import '../../../core/protocol/packet.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../backend/modules/contacts.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import 'contact_profile_screen.dart';
|
||||
|
||||
class ContactsTab extends StatefulWidget {
|
||||
@@ -31,9 +32,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
shape: kSheetShape,
|
||||
builder: (_) => const _SearchContactSheet(),
|
||||
);
|
||||
}
|
||||
@@ -55,21 +54,6 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderAvatar(ColorScheme cs, String name) {
|
||||
return Container(
|
||||
color: cs.primaryContainer,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContactItem(
|
||||
BuildContext context,
|
||||
ColorScheme cs,
|
||||
@@ -109,18 +93,10 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: contact.baseUrl != null && contact.baseUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: contact.baseUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 144,
|
||||
memCacheHeight: 144,
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (context, url, error) =>
|
||||
_buildPlaceholderAvatar(cs, nameToDisplay),
|
||||
)
|
||||
: _buildPlaceholderAvatar(cs, nameToDisplay),
|
||||
child: KometAvatar(
|
||||
name: nameToDisplay,
|
||||
imageUrl: contact.baseUrl,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -366,8 +342,15 @@ class _SearchContactSheetState extends State<_SearchContactSheet> {
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Введите ID контакта',
|
||||
hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16),
|
||||
prefixIcon: Icon(Symbols.tag, color: cs.onSurfaceVariant, size: 20),
|
||||
hintStyle: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 16,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Symbols.tag,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
@@ -380,19 +363,29 @@ class _SearchContactSheetState extends State<_SearchContactSheet> {
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.errorContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Symbols.error_outline, size: 18, color: cs.onErrorContainer),
|
||||
Icon(
|
||||
Symbols.error_outline,
|
||||
size: 18,
|
||||
color: cs.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(color: cs.onErrorContainer, fontSize: 13),
|
||||
style: TextStyle(
|
||||
color: cs.onErrorContainer,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -403,7 +396,9 @@ class _SearchContactSheetState extends State<_SearchContactSheet> {
|
||||
FilledButton(
|
||||
onPressed: _loading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: _loading
|
||||
|
||||
@@ -11,8 +11,10 @@ import '../../../backend/modules/chats.dart';
|
||||
import '../../../backend/modules/cloud_storage.dart';
|
||||
import '../../../backend/modules/upload_manager.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
|
||||
enum _EnvState { loading, notConfigured, ready }
|
||||
|
||||
@@ -108,7 +110,8 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
final cachedId = await CloudStorageModule.getCachedEnvGroupId(profile.id);
|
||||
if (cachedId != null) {
|
||||
final rows = await ChatsModule.getChat(profile.id, cachedId);
|
||||
if (rows.isNotEmpty && CloudStorageModule.isCloudStorageGroup(rows.first)) {
|
||||
if (rows.isNotEmpty &&
|
||||
CloudStorageModule.isCloudStorageGroup(rows.first)) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_envState = _EnvState.ready;
|
||||
@@ -127,7 +130,10 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
final orphans = CloudStorageModule.findOrphanGroups(chats);
|
||||
|
||||
if (envGroup == null && orphans.isNotEmpty) {
|
||||
final repaired = await CloudStorageModule.repairOrphan(api, orphans.first);
|
||||
final repaired = await CloudStorageModule.repairOrphan(
|
||||
api,
|
||||
orphans.first,
|
||||
);
|
||||
if (repaired != null) {
|
||||
envGroup = repaired;
|
||||
await CloudStorageModule.cacheEnvGroupId(profile.id, repaired.id);
|
||||
@@ -161,14 +167,23 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
void _deleteOrLeave(int accountId, CachedChat chat) async {
|
||||
final isAdmin = chat.owner == accountId || chat.admins.contains(accountId);
|
||||
if (isAdmin) {
|
||||
await ChatsModule.deleteChat(api, chatId: chat.id, lastEventTime: chat.lastEventTime, forAll: true);
|
||||
await ChatsModule.deleteChat(
|
||||
api,
|
||||
chatId: chat.id,
|
||||
lastEventTime: chat.lastEventTime,
|
||||
forAll: true,
|
||||
);
|
||||
} else {
|
||||
await ChatsModule.leaveChat(api, chatId: chat.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadFiles(int accountId, int chatId) async {
|
||||
final files = await CloudStorageModule.fetchFiles(messagesModule, accountId, chatId);
|
||||
final files = await CloudStorageModule.fetchFiles(
|
||||
messagesModule,
|
||||
accountId,
|
||||
chatId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _files = files.reversed.toList());
|
||||
}
|
||||
@@ -179,8 +194,11 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
_animateNewCard = true;
|
||||
});
|
||||
if (_pageController.hasClients) {
|
||||
_pageController.animateToPage(0,
|
||||
duration: const Duration(milliseconds: 350), curve: Curves.easeOut);
|
||||
_pageController.animateToPage(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
if (mounted) setState(() => _animateNewCard = false);
|
||||
@@ -248,7 +266,10 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
final ok = await messagesModule.sendFileMessage(chatId, id);
|
||||
if (!ok) return false;
|
||||
final newest = await CloudStorageModule.fetchLatestFile(
|
||||
messagesModule, accountId, chatId, expectedFileId: id,
|
||||
messagesModule,
|
||||
accountId,
|
||||
chatId,
|
||||
expectedFileId: id,
|
||||
);
|
||||
if (mounted) {
|
||||
if (newest != null) {
|
||||
@@ -316,23 +337,45 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
Text(
|
||||
'Среда для облачного хранилища не настроена',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 17, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text('Начнем? Это быстро.', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14)),
|
||||
Text(
|
||||
'Начнем? Это быстро.',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _isCreatingEnv ? null : _setupEnv,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 14,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
child: _isCreatingEnv
|
||||
? SizedBox(
|
||||
width: 18, height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: cs.onPrimary),
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: cs.onPrimary,
|
||||
),
|
||||
)
|
||||
: const Text('Начать', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
: const Text(
|
||||
'Начать',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -382,7 +425,11 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUploadingCenterHint(ColorScheme cs, double t, double availableWidth) {
|
||||
Widget _buildUploadingCenterHint(
|
||||
ColorScheme cs,
|
||||
double t,
|
||||
double availableWidth,
|
||||
) {
|
||||
final cardSide = availableWidth * _cardViewportFraction;
|
||||
return Center(
|
||||
child: Opacity(
|
||||
@@ -412,7 +459,9 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
);
|
||||
if (i == 0 && _animateNewCard) {
|
||||
return _FadeScaleEntry(
|
||||
key: ValueKey('${_files[0].messageId}_${_files[0].time}'),
|
||||
key: ValueKey(
|
||||
'${_files[0].messageId}_${_files[0].time}',
|
||||
),
|
||||
child: padded,
|
||||
);
|
||||
}
|
||||
@@ -448,8 +497,10 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Загрузка ${(progress * 100).toStringAsFixed(0)}%',
|
||||
style:
|
||||
TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -706,10 +757,7 @@ class _DragDownHintState extends State<_DragDownHint>
|
||||
if (phase > _activeFraction) return (dy: 0, opacity: 0);
|
||||
final local = phase / _activeFraction;
|
||||
final eased = Curves.easeOutCubic.transform(local);
|
||||
return (
|
||||
dy: _startY + eased * _travel,
|
||||
opacity: (1 - local) * _peakOpacity,
|
||||
);
|
||||
return (dy: _startY + eased * _travel, opacity: (1 - local) * _peakOpacity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,9 +786,15 @@ class _FadeScaleEntryState extends State<_FadeScaleEntry>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_c = AnimationController(vsync: this, duration: const Duration(milliseconds: 550));
|
||||
_c = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 550),
|
||||
);
|
||||
_scale = CurvedAnimation(parent: _c, curve: Curves.elasticOut);
|
||||
_opacity = CurvedAnimation(parent: _c, curve: const Interval(0, 0.4, curve: Curves.easeIn));
|
||||
_opacity = CurvedAnimation(
|
||||
parent: _c,
|
||||
curve: const Interval(0, 0.4, curve: Curves.easeIn),
|
||||
);
|
||||
_c.forward();
|
||||
}
|
||||
|
||||
@@ -789,7 +843,7 @@ class _CloudFileCard extends StatelessWidget {
|
||||
final d = DateTime.fromMillisecondsSinceEpoch(millis);
|
||||
final now = DateTime.now();
|
||||
if (d.year == now.year && d.month == now.month && d.day == now.day) {
|
||||
return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
||||
return formatClock(d);
|
||||
}
|
||||
return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}';
|
||||
}
|
||||
@@ -805,7 +859,10 @@ class _CloudFileCard extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5), width: 0.5),
|
||||
border: Border.all(
|
||||
color: cs.outlineVariant.withValues(alpha: 0.5),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -834,7 +891,10 @@ class _CloudFileCard extends StatelessWidget {
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatTime(file.time),
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10),
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -889,18 +949,23 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> {
|
||||
chatId: f.chatId,
|
||||
messageId: f.messageId,
|
||||
);
|
||||
if (mounted) setState(() { _link = result; _loading = false; });
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_link = result;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static String _formatSize(int? bytes) {
|
||||
if (bytes == null) return '—';
|
||||
if (bytes < 1024) return '$bytes Б';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ';
|
||||
return formatBytes(bytes);
|
||||
}
|
||||
|
||||
static String _formatExpiry(int expiresMs) {
|
||||
final remaining = DateTime.fromMillisecondsSinceEpoch(expiresMs).difference(DateTime.now());
|
||||
final remaining = DateTime.fromMillisecondsSinceEpoch(
|
||||
expiresMs,
|
||||
).difference(DateTime.now());
|
||||
if (remaining.isNegative) return 'истекла';
|
||||
final h = remaining.inHours;
|
||||
final m = remaining.inMinutes % 60;
|
||||
@@ -913,7 +978,8 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> {
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final f = widget.file;
|
||||
final isExpired = _link == null ||
|
||||
final isExpired =
|
||||
_link == null ||
|
||||
_link!.expires <= DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
return Container(
|
||||
@@ -922,25 +988,25 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> {
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24, 16, 24,
|
||||
24,
|
||||
16,
|
||||
24,
|
||||
MediaQuery.of(context).viewInsets.bottom + 32,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36, height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Center(child: SheetGrabber(margin: EdgeInsets.zero)),
|
||||
const SizedBox(height: 20),
|
||||
Text(f.name,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700)),
|
||||
Text(
|
||||
f.name,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_InfoRow(label: 'ID файла', value: f.fileId?.toString() ?? '—'),
|
||||
const SizedBox(height: 6),
|
||||
@@ -952,16 +1018,27 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: isExpired
|
||||
? Text('Ссылки пока нет. Создайте.',
|
||||
style: TextStyle(color: cs.error, fontSize: 13))
|
||||
: Text('Ссылка истечет ${_formatExpiry(_link!.expires)}',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
? Text(
|
||||
'Ссылки пока нет. Создайте.',
|
||||
style: TextStyle(color: cs.error, fontSize: 13),
|
||||
)
|
||||
: Text(
|
||||
'Ссылка истечет ${_formatExpiry(_link!.expires)}',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_loading
|
||||
? SizedBox(
|
||||
width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: cs.primary),
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: cs.primary,
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
icon: Icon(
|
||||
@@ -974,8 +1051,13 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> {
|
||||
onPressed: isExpired
|
||||
? _generateLink
|
||||
: () {
|
||||
Clipboard.setData(ClipboardData(text: _link!.url));
|
||||
showCustomNotification(context, 'Ссылка скопирована');
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: _link!.url),
|
||||
);
|
||||
showCustomNotification(
|
||||
context,
|
||||
'Ссылка скопирована',
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -996,11 +1078,18 @@ class _InfoRow extends StatelessWidget {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Row(
|
||||
children: [
|
||||
Text('$label: ', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
Text(
|
||||
'$label: ',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
@@ -1053,24 +1142,25 @@ class _SendByIdSheetState extends State<_SendByIdSheet> {
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24, 16, 24,
|
||||
24,
|
||||
16,
|
||||
24,
|
||||
MediaQuery.of(context).viewInsets.bottom + 32,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36, height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.outlineVariant, borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Center(child: SheetGrabber(margin: EdgeInsets.zero)),
|
||||
const SizedBox(height: 20),
|
||||
Text('Отправить по ID',
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
Text(
|
||||
'Отправить по ID',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
@@ -1087,7 +1177,10 @@ class _SendByIdSheetState extends State<_SendByIdSheet> {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -1095,14 +1188,23 @@ class _SendByIdSheetState extends State<_SendByIdSheet> {
|
||||
onPressed: _sending ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _sending
|
||||
? SizedBox(
|
||||
width: 18, height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: cs.onPrimary),
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: cs.onPrimary,
|
||||
),
|
||||
)
|
||||
: const Text('Отправить', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
: const Text(
|
||||
'Отправить',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -10,10 +10,12 @@ import '../../../core/config/app_media_cache.dart';
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/protocol/packet.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
import '../../../core/utils/logger.dart';
|
||||
import '../../../core/utils/media_cache.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import '../../widgets/login_success_screen.dart';
|
||||
import '../calls/call_screen.dart';
|
||||
|
||||
@@ -53,7 +55,7 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
_clearingCache = false;
|
||||
_cacheSize = 0;
|
||||
});
|
||||
showCustomNotification(context, 'Кэш очищен (${_formatBytes(freed)})');
|
||||
showCustomNotification(context, 'Кэш очищен (${formatBytes(freed)})');
|
||||
}
|
||||
|
||||
void _pickCacheLimit() {
|
||||
@@ -61,9 +63,7 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
shape: kSheetShape,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -106,16 +106,7 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
}
|
||||
|
||||
String _limitLabel(int bytes) =>
|
||||
bytes <= 0 ? 'Без лимита' : _formatBytes(bytes);
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes Б';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} ГБ';
|
||||
}
|
||||
bytes <= 0 ? 'Без лимита' : formatBytes(bytes);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -133,7 +124,10 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
_errors.clear();
|
||||
});
|
||||
|
||||
Future<void> tryProbe(String label, Future<dynamic> Function() probe) async {
|
||||
Future<void> tryProbe(
|
||||
String label,
|
||||
Future<dynamic> Function() probe,
|
||||
) async {
|
||||
try {
|
||||
final res = await probe();
|
||||
logger.i('debug-search $label($id): $res');
|
||||
@@ -147,11 +141,15 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
|
||||
await Future.wait([
|
||||
tryProbe('contactInfo', () async {
|
||||
final p = await api.sendRequest(Opcode.contactInfo, {'contactIds': [id]});
|
||||
final p = await api.sendRequest(Opcode.contactInfo, {
|
||||
'contactIds': [id],
|
||||
});
|
||||
return p.payload;
|
||||
}),
|
||||
tryProbe('chatInfo', () async {
|
||||
final p = await api.sendRequest(Opcode.chatInfo, {'chatIds': [id]});
|
||||
final p = await api.sendRequest(Opcode.chatInfo, {
|
||||
'chatIds': [id],
|
||||
});
|
||||
return p.payload;
|
||||
}),
|
||||
tryProbe('publicSearch', () => ChatsModule.searchById(api, id)),
|
||||
@@ -704,7 +702,7 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
Text(
|
||||
_clearingCache
|
||||
? 'Очистка…'
|
||||
: 'Занято: ${_formatBytes(_cacheSize)}',
|
||||
: 'Занято: ${formatBytes(_cacheSize)}',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
@@ -958,7 +956,10 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'Ничего не найдено',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final hit in _hits) ...[
|
||||
@@ -1152,7 +1153,11 @@ class _SearchResultCard extends StatelessWidget {
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Скопировать id',
|
||||
icon: Icon(Symbols.content_copy, size: 18, color: cs.onSurfaceVariant),
|
||||
icon: Icon(
|
||||
Symbols.content_copy,
|
||||
size: 18,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: hit.id.toString()));
|
||||
if (context.mounted) {
|
||||
@@ -1292,10 +1297,7 @@ class _ErrorChip extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$label: $message',
|
||||
style: TextStyle(
|
||||
color: cs.onErrorContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
style: TextStyle(color: cs.onErrorContainer, fontSize: 12),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
@@ -6,9 +6,11 @@ import 'package:flutter/foundation.dart'
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
import '../../../main.dart' show accountModule;
|
||||
import '../../../backend/modules/account.dart' show SessionInfo;
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import 'web_qr_scan_screen.dart';
|
||||
|
||||
class DevicesScreen extends StatefulWidget {
|
||||
@@ -125,16 +127,7 @@ class _DevicesScreenState extends State<DevicesScreen>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Center(child: SheetGrabber(margin: EdgeInsets.zero)),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Вход по QR',
|
||||
@@ -158,8 +151,7 @@ class _DevicesScreenState extends State<DevicesScreen>
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(sheetContext).pop(false),
|
||||
onPressed: () => Navigator.of(sheetContext).pop(false),
|
||||
child: Text(
|
||||
'Отмена',
|
||||
style: TextStyle(color: cs.onSurface),
|
||||
@@ -169,8 +161,7 @@ class _DevicesScreenState extends State<DevicesScreen>
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(sheetContext).pop(true),
|
||||
onPressed: () => Navigator.of(sheetContext).pop(true),
|
||||
child: const Text('Войти'),
|
||||
),
|
||||
),
|
||||
@@ -186,7 +177,8 @@ class _DevicesScreenState extends State<DevicesScreen>
|
||||
}
|
||||
|
||||
Future<void> _startWebQrAuth() async {
|
||||
final canScan = !kIsWeb &&
|
||||
final canScan =
|
||||
!kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS);
|
||||
|
||||
@@ -310,29 +302,14 @@ class _DevicesScreenState extends State<DevicesScreen>
|
||||
if (now.year == date.year &&
|
||||
now.month == date.month &&
|
||||
now.day == date.day) {
|
||||
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
return formatClock(date);
|
||||
}
|
||||
|
||||
final months = [
|
||||
'янв.',
|
||||
'февр.',
|
||||
'мар.',
|
||||
'апр.',
|
||||
'мая',
|
||||
'июня',
|
||||
'июля',
|
||||
'авг.',
|
||||
'сент.',
|
||||
'окт.',
|
||||
'нояб.',
|
||||
'дек.',
|
||||
];
|
||||
|
||||
if (now.year == date.year) {
|
||||
return '${date.day} ${months[date.month - 1]}';
|
||||
return '${date.day} ${kRuMonthsShort[date.month - 1]}';
|
||||
}
|
||||
|
||||
return '${date.day}.${date.month.toString().padLeft(2, '0')}.${date.year}';
|
||||
return formatDateNumeric(date);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -7,8 +7,6 @@ import '../../../l10n/app_localizations.dart';
|
||||
import '../../../main.dart' show accountModule, fileUploader, KometApp;
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
const int _maxAvatarBytes = 8 * 1024 * 1024;
|
||||
|
||||
class EditProfileScreen extends StatefulWidget {
|
||||
const EditProfileScreen({super.key});
|
||||
|
||||
@@ -62,7 +60,9 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||
try {
|
||||
final newProfile = await accountModule.updateProfileName(
|
||||
firstName,
|
||||
_lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(),
|
||||
_lastNameController.text.trim().isEmpty
|
||||
? null
|
||||
: _lastNameController.text.trim(),
|
||||
);
|
||||
_avatarUrl = newProfile.baseUrl;
|
||||
_photoId = newProfile.photoId;
|
||||
@@ -92,8 +92,10 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||
if (mounted) showCustomNotification(context, 'Не удалось прочитать файл');
|
||||
return;
|
||||
}
|
||||
if (bytes.length > _maxAvatarBytes) {
|
||||
if (mounted) showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)');
|
||||
if (bytes.length > kMaxAvatarBytes) {
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
@@ -183,7 +185,10 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||
)
|
||||
: Text(
|
||||
l10n?.editProfileSave ?? 'Save',
|
||||
style: TextStyle(color: cs.primary, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(
|
||||
color: cs.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -214,7 +219,8 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_firstNameController.text.isNotEmpty
|
||||
? _firstNameController.text[0].toUpperCase()
|
||||
? _firstNameController.text[0]
|
||||
.toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
@@ -234,7 +240,11 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(Symbols.camera_alt, color: cs.onPrimary, size: 20),
|
||||
icon: Icon(
|
||||
Symbols.camera_alt,
|
||||
color: cs.onPrimary,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: _changeAvatar,
|
||||
),
|
||||
),
|
||||
@@ -274,13 +284,21 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField(String label, TextEditingController controller, ColorScheme cs, {bool enabled = true}) {
|
||||
Widget _buildTextField(
|
||||
String label,
|
||||
TextEditingController controller,
|
||||
ColorScheme cs, {
|
||||
bool enabled = true,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 6),
|
||||
child: Text(label, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: controller,
|
||||
@@ -292,7 +310,10 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/section_header.dart';
|
||||
|
||||
class InfoScreen extends StatefulWidget {
|
||||
const InfoScreen({super.key});
|
||||
@@ -114,29 +115,49 @@ class _InfoScreenState extends State<InfoScreen> {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildSectionTitle(l10n.infoAccountSection, cs),
|
||||
...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key], e.key), cs)),
|
||||
SectionHeader(l10n.infoAccountSection),
|
||||
...accountKeys.entries.map(
|
||||
(e) =>
|
||||
_buildRow(e.key, e.value, _formatValue(info[e.key], e.key), cs),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
_buildSectionTitle(l10n.infoServerSection, cs),
|
||||
...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key], e.key), cs)),
|
||||
SectionHeader(l10n.infoServerSection),
|
||||
...serverKeys.entries.map(
|
||||
(e) => _buildRow(
|
||||
e.key,
|
||||
e.value,
|
||||
_formatValue(server?[e.key], e.key),
|
||||
cs,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
_buildSectionTitle(l10n.infoYMapSection, cs),
|
||||
SectionHeader(l10n.infoYMapSection),
|
||||
_buildRow('tile', l10n.infoTile, yMap?['tile']?.toString() ?? '-', cs),
|
||||
_buildRow('geocoder', l10n.infoGeocoder, yMap?['geocoder']?.toString() ?? '-', cs),
|
||||
_buildRow('static', l10n.infoStatic, yMap?['static']?.toString() ?? '-', cs),
|
||||
_buildRow(
|
||||
'geocoder',
|
||||
l10n.infoGeocoder,
|
||||
yMap?['geocoder']?.toString() ?? '-',
|
||||
cs,
|
||||
),
|
||||
_buildRow(
|
||||
'static',
|
||||
l10n.infoStatic,
|
||||
yMap?['static']?.toString() ?? '-',
|
||||
cs,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
_buildSectionTitle(l10n.infoFileUploadTypes, cs),
|
||||
SectionHeader(l10n.infoFileUploadTypes),
|
||||
_buildListRow(server?['file-upload-unsupported-types'] as List?, cs),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
_buildSectionTitle(l10n.infoWhiteListLinks, cs),
|
||||
SectionHeader(l10n.infoWhiteListLinks),
|
||||
_buildListRow(server?['white-list-links'] as List?, cs),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
_buildSectionTitle(l10n.infoUserSection, cs),
|
||||
SectionHeader(l10n.infoUserSection),
|
||||
if (user != null)
|
||||
...user.entries
|
||||
.where((e) => e.value != null)
|
||||
@@ -147,21 +168,6 @@ class _InfoScreenState extends State<InfoScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title, ColorScheme cs) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 8, left: 4, right: 4),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: cs.primary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRow(String key, String label, String value, ColorScheme cs) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 1),
|
||||
@@ -224,7 +230,10 @@ class _InfoScreenState extends State<InfoScreen> {
|
||||
children: items
|
||||
.map(
|
||||
(item) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -250,7 +259,10 @@ class _InfoScreenState extends State<InfoScreen> {
|
||||
if (key == 'edit-timeout' && value is int && value > 0) {
|
||||
final weeks = value ~/ 604800;
|
||||
final days = (value % 604800) ~/ 86400;
|
||||
if (weeks > 0) return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim();
|
||||
if (weeks > 0) {
|
||||
return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'
|
||||
.trim();
|
||||
}
|
||||
final h = value ~/ 3600;
|
||||
final m = (value % 3600) ~/ 60;
|
||||
if (h > 0) return '${h}h ${m}m';
|
||||
|
||||
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:m3e_collection/m3e_collection.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../widgets/section_header.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
|
||||
class NotificationsScreen extends StatefulWidget {
|
||||
const NotificationsScreen({super.key});
|
||||
|
||||
@@ -29,9 +32,7 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
|
||||
final picked = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
shape: kSheetShape,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
@@ -67,10 +68,7 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
|
||||
),
|
||||
title: Text(
|
||||
s,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
),
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -90,17 +88,18 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
appBar: AppBarM3E(
|
||||
titleText: 'Уведомления',
|
||||
backgroundColor: cs.surface,
|
||||
),
|
||||
appBar: AppBarM3E(titleText: 'Уведомления', backgroundColor: cs.surface),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: ListView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 120),
|
||||
children: [
|
||||
_sectionHeader(cs, 'FKM'),
|
||||
const SectionHeader(
|
||||
'FKM',
|
||||
padding: EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
fontSize: 14,
|
||||
),
|
||||
_card(cs, [
|
||||
_toggleRow(
|
||||
cs,
|
||||
@@ -113,7 +112,11 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
_sectionHeader(cs, 'Настройки уведомлений'),
|
||||
const SectionHeader(
|
||||
'Настройки уведомлений',
|
||||
padding: EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
fontSize: 14,
|
||||
),
|
||||
_card(cs, [
|
||||
_toggleRow(
|
||||
cs,
|
||||
@@ -140,7 +143,11 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
_sectionHeader(cs, 'Звук'),
|
||||
const SectionHeader(
|
||||
'Звук',
|
||||
padding: EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
fontSize: 14,
|
||||
),
|
||||
_card(cs, [
|
||||
_tappableRow(
|
||||
cs,
|
||||
@@ -156,21 +163,6 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionHeader(ColorScheme cs, String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: cs.primary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card(ColorScheme cs, List<Widget> children) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -275,10 +267,7 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
|
||||
),
|
||||
Text(
|
||||
trailingText,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Icon(Symbols.chevron_right, color: cs.outline, size: 20),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../main.dart' show accountModule;
|
||||
import '../../../backend/modules/account.dart' show TwoFactorDetails;
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../widgets/confirm_dialog.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
class PasswordEntryScreen extends StatefulWidget {
|
||||
@@ -270,36 +271,22 @@ class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showRemoveConfirmation(BuildContext context, ColorScheme cs) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: Text('Удалить пароль?', style: TextStyle(color: cs.onSurface)),
|
||||
content: Text(
|
||||
Future<void> _showRemoveConfirmation(
|
||||
BuildContext context,
|
||||
ColorScheme cs,
|
||||
) async {
|
||||
final confirmed = await showConfirmDialog(
|
||||
context,
|
||||
title: 'Удалить пароль?',
|
||||
message:
|
||||
'Вы уверены, что хотите удалить пароль для входа? Это ослабит защиту вашего аккаунта.',
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Отмена', style: TextStyle(color: cs.primary)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
confirmLabel: 'Удалить',
|
||||
destructive: true,
|
||||
);
|
||||
if (!confirmed || !context.mounted) return;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const TwoFactorRemoveScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text('Удалить', style: TextStyle(color: cs.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
MaterialPageRoute(builder: (context) => const TwoFactorRemoveScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -813,10 +800,7 @@ class _TwoFactorManageScreenState extends State<TwoFactorManageScreen> {
|
||||
style: TextStyle(color: cs.onErrorContainer),
|
||||
),
|
||||
),
|
||||
_PasswordField(
|
||||
controller: _passwordController,
|
||||
hintText: 'Пароль',
|
||||
),
|
||||
_PasswordField(controller: _passwordController, hintText: 'Пароль'),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -1414,10 +1398,7 @@ class _TwoFactorRemoveScreenState extends State<TwoFactorRemoveScreen> {
|
||||
style: TextStyle(color: cs.onErrorContainer),
|
||||
),
|
||||
),
|
||||
_PasswordField(
|
||||
controller: _passwordController,
|
||||
hintText: 'Пароль',
|
||||
),
|
||||
_PasswordField(controller: _passwordController, hintText: 'Пароль'),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -1457,10 +1438,7 @@ class _PasswordField extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String hintText;
|
||||
|
||||
const _PasswordField({
|
||||
required this.controller,
|
||||
required this.hintText,
|
||||
});
|
||||
const _PasswordField({required this.controller, required this.hintText});
|
||||
|
||||
@override
|
||||
State<_PasswordField> createState() => _PasswordFieldState();
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:m3e_collection/m3e_collection.dart';
|
||||
|
||||
import '../../../core/config/app_cache_extent.dart';
|
||||
import '../../../core/utils/haptics.dart';
|
||||
import '../../widgets/confirm_dialog.dart';
|
||||
|
||||
class PerformanceScreen extends StatefulWidget {
|
||||
const PerformanceScreen({super.key});
|
||||
@@ -25,7 +26,8 @@ class _PerformanceScreenState extends State<PerformanceScreen> {
|
||||
}
|
||||
|
||||
bool _isInSafeZone(double v) =>
|
||||
v >= AppCacheExtent.lowWarnThreshold && v < AppCacheExtent.highWarnThreshold;
|
||||
v >= AppCacheExtent.lowWarnThreshold &&
|
||||
v < AppCacheExtent.highWarnThreshold;
|
||||
|
||||
void _onChanged(double v) {
|
||||
setState(() {
|
||||
@@ -41,8 +43,7 @@ class _PerformanceScreenState extends State<PerformanceScreen> {
|
||||
|
||||
if (inLow && !_lowWarnDismissed) {
|
||||
final ok = await _showWarning(
|
||||
text:
|
||||
'Производительность приложения может снизиться, вы уверены?',
|
||||
text: 'Производительность приложения может снизиться, вы уверены?',
|
||||
);
|
||||
if (ok) {
|
||||
_lowWarnDismissed = true;
|
||||
@@ -73,37 +74,13 @@ class _PerformanceScreenState extends State<PerformanceScreen> {
|
||||
await AppCacheExtent.save(v);
|
||||
}
|
||||
|
||||
Future<bool> _showWarning({required String text}) async {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final res = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
content: Text(
|
||||
text,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.35),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(
|
||||
'Нет',
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Да'),
|
||||
),
|
||||
],
|
||||
Future<bool> _showWarning({required String text}) {
|
||||
return showConfirmDialog(
|
||||
context,
|
||||
message: text,
|
||||
confirmLabel: 'Да',
|
||||
cancelLabel: 'Нет',
|
||||
);
|
||||
},
|
||||
);
|
||||
return res ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -5,7 +5,9 @@ import '../../../main.dart' show accountModule;
|
||||
import '../../../backend/modules/account.dart'
|
||||
show PrivacyConfig, BlockedContact;
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../widgets/confirm_dialog.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import 'password_entry_screen.dart';
|
||||
|
||||
class SecurityScreen extends StatefulWidget {
|
||||
@@ -539,14 +541,7 @@ class _SecurityScreenState extends State<SecurityScreen>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SheetGrabber(margin: EdgeInsets.zero),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
@@ -598,37 +593,20 @@ class _SecurityScreenState extends State<SecurityScreen>
|
||||
);
|
||||
}
|
||||
|
||||
void _showHiddenStatusSheet(BuildContext context, ColorScheme cs) {
|
||||
Future<void> _showHiddenStatusSheet(
|
||||
BuildContext context,
|
||||
ColorScheme cs,
|
||||
) async {
|
||||
final currentValue = _privacyConfig?.hidden == true ? 'NONE' : 'CONTACTS';
|
||||
|
||||
if (currentValue == 'NONE') {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
title: Text('Вы уверены?', style: TextStyle(color: cs.onSurface)),
|
||||
content: Text(
|
||||
'Вы не сможете видеть статусы посещения других пользователей.',
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Отмена', style: TextStyle(color: cs.primary)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_updateSetting('HIDDEN', false);
|
||||
},
|
||||
child: Text('Да', style: TextStyle(color: cs.primary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
final confirmed = await showConfirmDialog(
|
||||
context,
|
||||
title: 'Вы уверены?',
|
||||
message: 'Вы не сможете видеть статусы посещения других пользователей.',
|
||||
confirmLabel: 'Да',
|
||||
);
|
||||
if (confirmed) _updateSetting('HIDDEN', false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -644,14 +622,7 @@ class _SecurityScreenState extends State<SecurityScreen>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SheetGrabber(margin: EdgeInsets.zero),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Видеть статус «в сети»',
|
||||
@@ -683,32 +654,17 @@ class _SecurityScreenState extends State<SecurityScreen>
|
||||
);
|
||||
}
|
||||
|
||||
void _showHiddenStatusConfirmDialog(BuildContext context, ColorScheme cs) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: Text('Вы уверены?', style: TextStyle(color: cs.onSurface)),
|
||||
content: Text(
|
||||
'Вы не сможете видеть статусы посещения других пользователей.',
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Отмена', style: TextStyle(color: cs.primary)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_updateSetting('HIDDEN', true);
|
||||
},
|
||||
child: Text('Да', style: TextStyle(color: cs.primary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
Future<void> _showHiddenStatusConfirmDialog(
|
||||
BuildContext context,
|
||||
ColorScheme cs,
|
||||
) async {
|
||||
final confirmed = await showConfirmDialog(
|
||||
context,
|
||||
title: 'Вы уверены?',
|
||||
message: 'Вы не сможете видеть статусы посещения других пользователей.',
|
||||
confirmLabel: 'Да',
|
||||
);
|
||||
if (confirmed) _updateSetting('HIDDEN', true);
|
||||
}
|
||||
|
||||
Widget _buildOptionSheetItem(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
@@ -12,6 +11,8 @@ import '../../../core/utils/haptics.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/info_action_sheet.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import '../auth/login_screen.dart';
|
||||
import '../auth/proxy_settings_sheet.dart';
|
||||
import 'cloud_storage_screen.dart';
|
||||
@@ -144,9 +145,7 @@ class _SettingsTabState extends State<SettingsTab> {
|
||||
final confirmed = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
shape: kSheetShape,
|
||||
builder: (ctx) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
@@ -245,11 +244,14 @@ class _SettingsTabState extends State<SettingsTab> {
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: _buildSection(
|
||||
child: _buildSection(
|
||||
context,
|
||||
cs,
|
||||
items: [
|
||||
const _SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'),
|
||||
const _SettingsItem(
|
||||
icon: Symbols.badge,
|
||||
label: 'Цифровой ID',
|
||||
),
|
||||
const _SettingsItem(
|
||||
icon: Symbols.language,
|
||||
label: 'Войти в Сферум',
|
||||
@@ -344,15 +346,9 @@ child: _buildSection(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(24),
|
||||
),
|
||||
),
|
||||
shape: kSheetShape,
|
||||
builder: (_) {
|
||||
return SafeArea(
|
||||
child: const ProxySettingsSheet(),
|
||||
);
|
||||
return SafeArea(child: const ProxySettingsSheet());
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -407,10 +403,7 @@ child: _buildSection(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
heightFactor: animation.value.clamp(0.0, 1.0),
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
child: FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -418,10 +411,7 @@ child: _buildSection(
|
||||
return Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
clipBehavior: Clip.none,
|
||||
children: <Widget>[
|
||||
...previousChildren,
|
||||
?currentChild,
|
||||
],
|
||||
children: <Widget>[...previousChildren, ?currentChild],
|
||||
);
|
||||
},
|
||||
child: _debugMenuVisible
|
||||
@@ -557,18 +547,11 @@ child: _buildSection(
|
||||
width: 2.5,
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: _profile?.baseUrl != null && _profile!.baseUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: _profile!.baseUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 240,
|
||||
memCacheHeight: 240,
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (context, url, error) =>
|
||||
_buildPlaceholderAvatar(cs, name),
|
||||
)
|
||||
: _buildPlaceholderAvatar(cs, name),
|
||||
child: KometAvatar(
|
||||
name: name,
|
||||
imageUrl: _profile?.baseUrl,
|
||||
size: 88,
|
||||
fontSize: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
@@ -614,21 +597,6 @@ child: _buildSection(
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderAvatar(ColorScheme cs, String name) {
|
||||
return Container(
|
||||
color: cs.primaryContainer,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(
|
||||
BuildContext context,
|
||||
ColorScheme cs, {
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../../core/storage/token_storage.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/info_action_sheet.dart';
|
||||
import '../../widgets/section_header.dart';
|
||||
import '../auth/login_screen.dart';
|
||||
|
||||
enum SpoofingMethod { partial, full }
|
||||
@@ -103,6 +104,21 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
_buildNumberController.text =
|
||||
prefs.getInt('spoof_buildnumber')?.toString() ??
|
||||
'$_hardcodedBuildNumber';
|
||||
_pushDeviceTypeController.text =
|
||||
prefs.getString('spoof_pushdevicetype') ?? 'GCM';
|
||||
|
||||
final savedDeviceLocale = prefs.getString('spoof_devicelocale');
|
||||
if (savedDeviceLocale != null && savedDeviceLocale.isNotEmpty) {
|
||||
_deviceLocaleController.text = savedDeviceLocale;
|
||||
}
|
||||
final savedInstanceId = prefs.getString('spoof_instanceid');
|
||||
if (savedInstanceId != null && savedInstanceId.isNotEmpty) {
|
||||
_instanceIdController.text = savedInstanceId;
|
||||
}
|
||||
final savedClientSessionId = prefs.getInt('spoof_clientsessionid');
|
||||
if (savedClientSessionId != null) {
|
||||
_clientSessionIdController.text = '$savedClientSessionId';
|
||||
}
|
||||
|
||||
String savedType = prefs.getString('spoof_devicetype') ?? 'ANDROID';
|
||||
if (savedType == 'WEB') savedType = 'ANDROID';
|
||||
@@ -235,6 +251,13 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
'device_id': prefs.getString('spoof_deviceid') ?? '',
|
||||
'device_type': prefs.getString('spoof_devicetype') ?? 'ANDROID',
|
||||
'arch': prefs.getString('spoof_arch') ?? '',
|
||||
'device_locale': prefs.getString('spoof_devicelocale') ?? '',
|
||||
'app_version': prefs.getString('spoof_appversion') ?? '',
|
||||
'build_number': prefs.getInt('spoof_buildnumber')?.toString() ?? '',
|
||||
'push_device_type': prefs.getString('spoof_pushdevicetype') ?? '',
|
||||
'instance_id': prefs.getString('spoof_instanceid') ?? '',
|
||||
'client_session_id':
|
||||
prefs.getInt('spoof_clientsessionid')?.toString() ?? '',
|
||||
};
|
||||
|
||||
final newValues = {
|
||||
@@ -246,6 +269,12 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
'device_id': _deviceIdController.text,
|
||||
'device_type': _selectedDeviceType,
|
||||
'arch': _selectedArch,
|
||||
'device_locale': _deviceLocaleController.text,
|
||||
'app_version': _appVersionController.text,
|
||||
'build_number': _buildNumberController.text,
|
||||
'push_device_type': _pushDeviceTypeController.text,
|
||||
'instance_id': _instanceIdController.text,
|
||||
'client_session_id': _clientSessionIdController.text,
|
||||
};
|
||||
|
||||
bool otherDataChanged = false;
|
||||
@@ -358,6 +387,27 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
await prefs.setString('spoof_deviceid', _deviceIdController.text);
|
||||
await prefs.setString('spoof_devicetype', _selectedDeviceType);
|
||||
await prefs.setString('spoof_arch', _selectedArch);
|
||||
await prefs.setString('spoof_devicelocale', _deviceLocaleController.text);
|
||||
await prefs.setString('spoof_appversion', _appVersionController.text);
|
||||
await prefs.setString(
|
||||
'spoof_pushdevicetype',
|
||||
_pushDeviceTypeController.text,
|
||||
);
|
||||
await prefs.setString('spoof_instanceid', _instanceIdController.text);
|
||||
|
||||
final buildNumber = int.tryParse(_buildNumberController.text);
|
||||
if (buildNumber != null) {
|
||||
await prefs.setInt('spoof_buildnumber', buildNumber);
|
||||
} else {
|
||||
await prefs.remove('spoof_buildnumber');
|
||||
}
|
||||
|
||||
final clientSessionId = int.tryParse(_clientSessionIdController.text);
|
||||
if (clientSessionId != null) {
|
||||
await prefs.setInt('spoof_clientsessionid', clientSessionId);
|
||||
} else {
|
||||
await prefs.remove('spoof_clientsessionid');
|
||||
}
|
||||
}
|
||||
|
||||
void _generateNewDeviceId() {
|
||||
@@ -572,19 +622,6 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(BuildContext context, String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0, top: 8.0),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMainDataCard() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Card(
|
||||
@@ -593,7 +630,11 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader(context, l10n.spoofMainSectionTitle),
|
||||
SectionHeader(
|
||||
l10n.spoofMainSectionTitle,
|
||||
padding: const EdgeInsets.only(bottom: 16.0, top: 8.0),
|
||||
fontSize: 22,
|
||||
),
|
||||
TextField(
|
||||
controller: _deviceNameController,
|
||||
decoration: _inputDecoration(
|
||||
@@ -623,7 +664,11 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader(context, l10n.spoofRegionalSectionTitle),
|
||||
SectionHeader(
|
||||
l10n.spoofRegionalSectionTitle,
|
||||
padding: const EdgeInsets.only(bottom: 16.0, top: 8.0),
|
||||
fontSize: 22,
|
||||
),
|
||||
TextField(
|
||||
controller: _screenController,
|
||||
decoration: _inputDecoration(
|
||||
@@ -672,7 +717,11 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader(context, l10n.spoofIdentifiersSectionTitle),
|
||||
SectionHeader(
|
||||
l10n.spoofIdentifiersSectionTitle,
|
||||
padding: const EdgeInsets.only(bottom: 16.0, top: 8.0),
|
||||
fontSize: 22,
|
||||
),
|
||||
_buildDescriptionTile(
|
||||
icon: Icons.info_outline,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -8,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/storage/token_storage.dart';
|
||||
import '../../core/utils/haptics.dart';
|
||||
import 'komet_avatar.dart';
|
||||
|
||||
class AccountSwitcherController extends ChangeNotifier {
|
||||
Offset? pointer;
|
||||
@@ -301,9 +301,7 @@ class _AccountSwitcherLayerState extends State<_AccountSwitcherLayer>
|
||||
highlighted: _hoveredIndex == i,
|
||||
active: _accounts[i].id == _activeId,
|
||||
),
|
||||
_AddAccountRow(
|
||||
highlighted: _hoveredIndex == _accounts.length,
|
||||
),
|
||||
_AddAccountRow(highlighted: _hoveredIndex == _accounts.length),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -363,17 +361,15 @@ class _AccountRow extends StatelessWidget {
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: profile.baseUrl != null && profile.baseUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: profile.baseUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
memCacheHeight: 96,
|
||||
errorWidget: (_, __, ___) =>
|
||||
_initialAvatar(cs, fullName, highlighted),
|
||||
)
|
||||
: _initialAvatar(cs, fullName, highlighted),
|
||||
child: KometAvatar(
|
||||
name: fullName,
|
||||
imageUrl: profile.baseUrl,
|
||||
size: 36,
|
||||
backgroundColor: highlighted
|
||||
? cs.primaryContainer
|
||||
: cs.surfaceContainerHighest,
|
||||
foregroundColor: cs.onSurface,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -418,21 +414,6 @@ class _AccountRow extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _initialAvatar(ColorScheme cs, String name, bool highlighted) {
|
||||
return Container(
|
||||
color: highlighted ? cs.primaryContainer : cs.surfaceContainerHighest,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddAccountRow extends StatelessWidget {
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import 'package:komet/core/media/gallery_source.dart';
|
||||
import 'package:komet/core/utils/format.dart';
|
||||
import 'package:komet/frontend/widgets/custom_notification.dart';
|
||||
import 'package:komet/frontend/widgets/sheet_helpers.dart';
|
||||
import 'package:komet/frontend/widgets/sliding_pill_nav.dart';
|
||||
|
||||
const List<PillNavItem> _navItems = [
|
||||
PillNavItem(icon: Symbols.image, label: 'Галерея'),
|
||||
PillNavItem(icon: Symbols.description, label: 'Файл'),
|
||||
PillNavItem(icon: Symbols.location_on, label: 'Геопозиция'),
|
||||
PillNavItem(icon: Symbols.person, label: 'Контакт'),
|
||||
];
|
||||
|
||||
Future<void> showAttachmentSheet(BuildContext context) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.45),
|
||||
builder: (_) => const AttachmentSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class AttachmentSheet extends StatefulWidget {
|
||||
const AttachmentSheet({super.key});
|
||||
|
||||
@override
|
||||
State<AttachmentSheet> createState() => _AttachmentSheetState();
|
||||
}
|
||||
|
||||
class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
final GallerySource _source = GallerySource.create();
|
||||
final ValueNotifier<Set<String>> _selected = ValueNotifier(<String>{});
|
||||
final PageController _pageController = PageController();
|
||||
|
||||
bool _navDragging = false;
|
||||
double _navDragBasePageT = 0;
|
||||
double _navDragAccumDx = 0;
|
||||
|
||||
bool _loading = true;
|
||||
GalleryPermission _permission = GalleryPermission.granted;
|
||||
List<GalleryItem> _items = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadGallery();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
_selected.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadGallery() async {
|
||||
setState(() => _loading = true);
|
||||
final permission = await _source.ensurePermission();
|
||||
if (!mounted) return;
|
||||
if (permission == GalleryPermission.denied) {
|
||||
setState(() {
|
||||
_permission = permission;
|
||||
_items = const [];
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final items = await _source.load(limit: 120);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_permission = permission;
|
||||
_items = items;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleSelection(GalleryItem item) {
|
||||
final next = Set<String>.from(_selected.value);
|
||||
if (!next.remove(item.id)) next.add(item.id);
|
||||
_selected.value = next;
|
||||
}
|
||||
|
||||
void _onSectionTap(int index) {
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: _navAnim,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
void _onCameraTap() {
|
||||
showCustomNotification(context, 'Камера скоро появится');
|
||||
}
|
||||
|
||||
void _onSend() {
|
||||
final count = _selected.value.length;
|
||||
final overlay = Overlay.of(context, rootOverlay: true);
|
||||
Navigator.of(context).pop();
|
||||
showCustomNotificationOnOverlay(
|
||||
overlay,
|
||||
'Отправка $count выбранных скоро появится',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.62,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.94,
|
||||
expand: false,
|
||||
snap: true,
|
||||
snapSizes: const [0.62, 0.94],
|
||||
builder: (context, scrollController) {
|
||||
final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
|
||||
final barReserve = _barHeight + bottomInset;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLow,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
const SheetGrabber(),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildPages(scrollController, cs, barReserve),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _buildBottomBar(),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: barReserve + 8,
|
||||
child: AnimatedBuilder(
|
||||
animation: Listenable.merge([
|
||||
_selected,
|
||||
_pageController,
|
||||
]),
|
||||
builder: (context, _) {
|
||||
final count = _selected.value.length;
|
||||
final galleryT = (1 - _currentPageT()).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
);
|
||||
if (count == 0 || galleryT == 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Opacity(
|
||||
opacity: galleryT,
|
||||
child: IgnorePointer(
|
||||
ignoring: galleryT < 0.5,
|
||||
child: _buildSendButton(cs, count),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static const double _pillMargin = 10;
|
||||
static const double _barHeight = SlidingPillNav.height + _pillMargin;
|
||||
static const Duration _navAnim = Duration(milliseconds: 300);
|
||||
|
||||
Widget _buildPages(
|
||||
ScrollController scrollController,
|
||||
ColorScheme cs,
|
||||
double bottomReserve,
|
||||
) {
|
||||
return PageView(
|
||||
controller: _pageController,
|
||||
children: [
|
||||
_KeepAlivePage(
|
||||
child: _buildGalleryPage(scrollController, cs, bottomReserve),
|
||||
),
|
||||
_buildPlaceholderPage(cs, bottomReserve),
|
||||
_buildPlaceholderPage(cs, bottomReserve),
|
||||
_buildPlaceholderPage(cs, bottomReserve),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGalleryPage(
|
||||
ScrollController scrollController,
|
||||
ColorScheme cs,
|
||||
double bottomReserve,
|
||||
) {
|
||||
if (_loading) {
|
||||
return Center(child: CircularProgressIndicator(color: cs.primary));
|
||||
}
|
||||
if (_permission == GalleryPermission.denied) {
|
||||
return _buildDenied(scrollController, cs, bottomReserve);
|
||||
}
|
||||
if (_items.isEmpty) {
|
||||
return _buildMessage(
|
||||
scrollController,
|
||||
cs,
|
||||
'Изображений не найдено',
|
||||
bottomReserve,
|
||||
);
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
if (_permission == GalleryPermission.limited)
|
||||
SliverToBoxAdapter(child: _buildLimitedBanner(cs)),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(2, 2, 2, bottomReserve + 6),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 2,
|
||||
crossAxisSpacing: 2,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
if (index == 0) return _CameraTile(onTap: _onCameraTap, cs: cs);
|
||||
final item = _items[index - 1];
|
||||
return _GalleryTile(
|
||||
key: ValueKey(item.id),
|
||||
item: item,
|
||||
selectedIds: _selected,
|
||||
onTap: () => _toggleSelection(item),
|
||||
cs: cs,
|
||||
);
|
||||
}, childCount: _items.length + 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLimitedBanner(ColorScheme cs) {
|
||||
return InkWell(
|
||||
onTap: () => _source.manageAccess().then((_) => _loadGallery()),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
color: cs.surfaceContainerHighest,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Symbols.info, size: 18, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Доступны не все фото',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Изменить',
|
||||
style: TextStyle(
|
||||
color: cs.primary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderPage(ColorScheme cs, double bottomReserve) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: bottomReserve),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Symbols.construction, size: 48, color: cs.onSurfaceVariant),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Раздел в разработке',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDenied(
|
||||
ScrollController scrollController,
|
||||
ColorScheme cs,
|
||||
double bottomReserve,
|
||||
) {
|
||||
return _scrollableCenter(
|
||||
scrollController,
|
||||
bottomReserve,
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Symbols.no_photography, size: 48, color: cs.onSurfaceVariant),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Нет доступа к галерее',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Разрешите доступ к фото, чтобы выбрать их отсюда',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _loadGallery,
|
||||
child: const Text('Разрешить'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () => _source.openSettings(),
|
||||
child: const Text('Настройки'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessage(
|
||||
ScrollController scrollController,
|
||||
ColorScheme cs,
|
||||
String text,
|
||||
double bottomReserve,
|
||||
) {
|
||||
return _scrollableCenter(
|
||||
scrollController,
|
||||
bottomReserve,
|
||||
Text(text, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _scrollableCenter(
|
||||
ScrollController scrollController,
|
||||
double bottomReserve,
|
||||
Widget child,
|
||||
) {
|
||||
return CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: bottomReserve),
|
||||
child: Center(child: child),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSendButton(ColorScheme cs, int count) {
|
||||
return Material(
|
||||
color: cs.primary,
|
||||
shape: const StadiumBorder(),
|
||||
elevation: 3,
|
||||
child: InkWell(
|
||||
customBorder: const StadiumBorder(),
|
||||
onTap: _onSend,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Symbols.send, color: cs.onPrimary, size: 22, weight: 500),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _currentPageT() {
|
||||
if (!_pageController.hasClients) return 0;
|
||||
return _pageController.page ?? 0;
|
||||
}
|
||||
|
||||
void _onPillDragStart() {
|
||||
_navDragging = true;
|
||||
_navDragBasePageT = _currentPageT();
|
||||
_navDragAccumDx = 0;
|
||||
}
|
||||
|
||||
void _onPillDragUpdate(double dx, double inactiveWidth) {
|
||||
if (!_navDragging || !_pageController.hasClients) return;
|
||||
_navDragAccumDx += dx;
|
||||
final pageT = (_navDragBasePageT + _navDragAccumDx / inactiveWidth).clamp(
|
||||
0.0,
|
||||
3.0,
|
||||
);
|
||||
_pageController.jumpTo(pageT * _pageController.position.viewportDimension);
|
||||
}
|
||||
|
||||
void _onPillDragEnd() {
|
||||
if (!_navDragging) return;
|
||||
_navDragging = false;
|
||||
final target = _currentPageT().round().clamp(0, 3);
|
||||
_pageController.animateToPage(
|
||||
target,
|
||||
duration: _navAnim,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomBar() {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, _pillMargin),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final geometry = PillNavGeometry.fromInnerWidth(
|
||||
constraints.maxWidth - 4,
|
||||
_navItems.length,
|
||||
);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: (_) => _onPillDragStart(),
|
||||
onHorizontalDragUpdate: (d) =>
|
||||
_onPillDragUpdate(d.delta.dx, geometry.inactiveWidth),
|
||||
onHorizontalDragEnd: (_) => _onPillDragEnd(),
|
||||
onHorizontalDragCancel: _onPillDragEnd,
|
||||
child: AnimatedBuilder(
|
||||
animation: _pageController,
|
||||
builder: (context, _) {
|
||||
return SlidingPillNav(
|
||||
items: _navItems,
|
||||
position: _currentPageT(),
|
||||
geometry: geometry,
|
||||
onTap: _onSectionTap,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KeepAlivePage extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const _KeepAlivePage({required this.child});
|
||||
|
||||
@override
|
||||
State<_KeepAlivePage> createState() => _KeepAlivePageState();
|
||||
}
|
||||
|
||||
class _KeepAlivePageState extends State<_KeepAlivePage>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
class _CameraTile extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
final ColorScheme cs;
|
||||
|
||||
const _CameraTile({required this.onTap, required this.cs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
color: cs.surfaceContainerHighest,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Symbols.photo_camera,
|
||||
size: 34,
|
||||
color: cs.onSurface,
|
||||
weight: 400,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GalleryTile extends StatefulWidget {
|
||||
final GalleryItem item;
|
||||
final ValueListenable<Set<String>> selectedIds;
|
||||
final VoidCallback onTap;
|
||||
final ColorScheme cs;
|
||||
|
||||
const _GalleryTile({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.selectedIds,
|
||||
required this.onTap,
|
||||
required this.cs,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_GalleryTile> createState() => _GalleryTileState();
|
||||
}
|
||||
|
||||
class _GalleryTileState extends State<_GalleryTile> {
|
||||
late bool _selected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selected = widget.selectedIds.value.contains(widget.item.id);
|
||||
widget.selectedIds.addListener(_onSelectionChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.selectedIds.removeListener(_onSelectionChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSelectionChanged() {
|
||||
final selected = widget.selectedIds.value.contains(widget.item.id);
|
||||
if (selected != _selected) setState(() => _selected = selected);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final item = widget.item;
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
AnimatedScale(
|
||||
scale: _selected ? 0.86 : 1.0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
child: _Thumbnail(item: item, cs: widget.cs),
|
||||
),
|
||||
if (item.isVideo)
|
||||
Positioned(
|
||||
left: 6,
|
||||
bottom: 6,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.play_arrow,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
fill: 1,
|
||||
),
|
||||
if (item.duration != null)
|
||||
Text(
|
||||
formatDurationMmSs(item.duration!),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: [Shadow(blurRadius: 3, color: Colors.black54)],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 6,
|
||||
right: 6,
|
||||
child: _SelectionCheck(selected: _selected, cs: widget.cs),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectionCheck extends StatelessWidget {
|
||||
final bool selected;
|
||||
final ColorScheme cs;
|
||||
|
||||
const _SelectionCheck({required this.selected, required this.cs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: selected ? cs.primary : Colors.black.withValues(alpha: 0.25),
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
child: selected
|
||||
? Icon(Symbols.check, size: 16, color: cs.onPrimary, weight: 700)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Thumbnail extends StatefulWidget {
|
||||
final GalleryItem item;
|
||||
final ColorScheme cs;
|
||||
|
||||
const _Thumbnail({required this.item, required this.cs});
|
||||
|
||||
@override
|
||||
State<_Thumbnail> createState() => _ThumbnailState();
|
||||
}
|
||||
|
||||
class _ThumbnailState extends State<_Thumbnail> {
|
||||
static const int _pixelSize = 320;
|
||||
Future<Uint8List?>? _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.item.localFile == null) {
|
||||
_future = widget.item.thumbnail(_pixelSize);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final file = widget.item.localFile;
|
||||
if (file != null) {
|
||||
return Image.file(
|
||||
file,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: _pixelSize,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => _placeholder(),
|
||||
);
|
||||
}
|
||||
return FutureBuilder<Uint8List?>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
final data = snapshot.data;
|
||||
if (data == null) return _placeholder();
|
||||
return Image.memory(
|
||||
data,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => _placeholder(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholder() => ColoredBox(color: widget.cs.surfaceContainerHighest);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shared confirmation dialog. Returns true if confirmed, false otherwise.
|
||||
Future<bool> showConfirmDialog(
|
||||
BuildContext context, {
|
||||
String? title,
|
||||
required String message,
|
||||
String confirmLabel = 'OK',
|
||||
String cancelLabel = 'Отмена',
|
||||
bool destructive = false,
|
||||
}) async {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
title: title == null
|
||||
? null
|
||||
: Text(title, style: TextStyle(color: cs.onSurface)),
|
||||
content: Text(
|
||||
message,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.35),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(cancelLabel, style: TextStyle(color: cs.onSurfaceVariant)),
|
||||
),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: destructive
|
||||
? FilledButton.styleFrom(
|
||||
backgroundColor: cs.errorContainer,
|
||||
foregroundColor: cs.onErrorContainer,
|
||||
)
|
||||
: null,
|
||||
child: Text(confirmLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Circular avatar: shows [imageUrl] when available, otherwise the first letter
|
||||
/// of [name] on a colored background. Falls back to the letter on image error.
|
||||
class KometAvatar extends StatelessWidget {
|
||||
final String name;
|
||||
final String? imageUrl;
|
||||
final double size;
|
||||
final Color? backgroundColor;
|
||||
final Color? foregroundColor;
|
||||
final double? fontSize;
|
||||
|
||||
const KometAvatar({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.size,
|
||||
this.imageUrl,
|
||||
this.backgroundColor,
|
||||
this.foregroundColor,
|
||||
this.fontSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final bg = backgroundColor ?? cs.primaryContainer;
|
||||
final fg = foregroundColor ?? cs.onPrimaryContainer;
|
||||
final letter = name.isNotEmpty ? name[0].toUpperCase() : '?';
|
||||
final placeholder = Center(
|
||||
child: Text(
|
||||
letter,
|
||||
style: TextStyle(
|
||||
color: fg,
|
||||
fontSize: fontSize ?? size * 0.4,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
final url = imageUrl;
|
||||
final cache = (size * 3).round();
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
child: (url != null && url.isNotEmpty)
|
||||
? CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cache,
|
||||
memCacheHeight: cache,
|
||||
errorWidget: (_, _, _) => placeholder,
|
||||
)
|
||||
: placeholder,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import '../../backend/modules/messages.dart';
|
||||
import '../../core/config/app_bubble_behavior.dart';
|
||||
import '../../core/config/app_bubble_shape.dart';
|
||||
import '../../core/utils/bubble_radius.dart';
|
||||
import '../../core/utils/format.dart';
|
||||
import '../../core/utils/haptics.dart';
|
||||
import '../../core/utils/file_download.dart';
|
||||
import '../../core/utils/media_cache.dart';
|
||||
@@ -58,8 +59,9 @@ class MessageBubble extends StatelessWidget {
|
||||
static const Radius _photoRadius = Radius.circular(photoBorderRadius);
|
||||
|
||||
static final Color _reactionChipBg = Colors.black.withValues(alpha: 0.18);
|
||||
static const BorderRadius _reactionChipRadius =
|
||||
BorderRadius.all(Radius.circular(10));
|
||||
static const BorderRadius _reactionChipRadius = BorderRadius.all(
|
||||
Radius.circular(10),
|
||||
);
|
||||
|
||||
static Color bubbleTextColor(BuildContext context) =>
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
@@ -108,13 +110,15 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
final hasPrevFromMe =
|
||||
prevMessage?.senderId == message.senderId && !prevMessage!.isControl;
|
||||
final prevTimeDiff =
|
||||
hasPrevFromMe ? message.time - prevMessage!.time : 999999999;
|
||||
final prevTimeDiff = hasPrevFromMe
|
||||
? message.time - prevMessage!.time
|
||||
: 999999999;
|
||||
|
||||
final hasNextFromMe =
|
||||
nextMessage?.senderId == message.senderId && !nextMessage!.isControl;
|
||||
final nextTimeDiff =
|
||||
hasNextFromMe ? nextMessage!.time - message.time : 999999999;
|
||||
final nextTimeDiff = hasNextFromMe
|
||||
? nextMessage!.time - message.time
|
||||
: 999999999;
|
||||
|
||||
final groupedWithPrev = hasPrevFromMe && prevTimeDiff < 300000;
|
||||
final groupedWithNext = hasNextFromMe && nextTimeDiff < 300000;
|
||||
@@ -133,9 +137,11 @@ class MessageBubble extends StatelessWidget {
|
||||
if (first is ForwardedMessageAttachment) {
|
||||
final fwd = first;
|
||||
final hasContact = fwd.originalContact != null;
|
||||
final hasPhoto = fwd.originalAttachments != null &&
|
||||
final hasPhoto =
|
||||
fwd.originalAttachments != null &&
|
||||
fwd.originalAttachments!.any((a) => a is PhotoAttachment);
|
||||
final hasOther = fwd.originalAttachments != null &&
|
||||
final hasOther =
|
||||
fwd.originalAttachments != null &&
|
||||
fwd.originalAttachments!.isNotEmpty;
|
||||
if (hasContact || hasPhoto || hasOther) return MessageType.attachment;
|
||||
return MessageType.text;
|
||||
@@ -219,8 +225,10 @@ class MessageBubble extends StatelessWidget {
|
||||
bool hasPhotoWithCaption,
|
||||
bool hasMultiplePhotosNoCaption,
|
||||
) {
|
||||
final isTop = shape == BubbleShape.singleTop || shape == BubbleShape.singleMiddle;
|
||||
final isBottom = shape == BubbleShape.singleBottom || shape == BubbleShape.singleMiddle;
|
||||
final isTop =
|
||||
shape == BubbleShape.singleTop || shape == BubbleShape.singleMiddle;
|
||||
final isBottom =
|
||||
shape == BubbleShape.singleBottom || shape == BubbleShape.singleMiddle;
|
||||
return computeBubbleRadius(
|
||||
isMe: isMe,
|
||||
isTop: isTop,
|
||||
@@ -238,7 +246,11 @@ class MessageBubble extends StatelessWidget {
|
||||
if (senderAvatar != null && senderAvatar.isNotEmpty) {
|
||||
return CircleAvatar(
|
||||
radius: 15,
|
||||
backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96),
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
senderAvatar,
|
||||
maxWidth: 96,
|
||||
maxHeight: 96,
|
||||
),
|
||||
backgroundColor: cs.primaryContainer,
|
||||
);
|
||||
}
|
||||
@@ -280,13 +292,13 @@ class MessageBubble extends StatelessWidget {
|
||||
final padding = _paddingFor(contentType, shape);
|
||||
|
||||
final showAvatarSlot = !isMe;
|
||||
final showAvatar = showAvatarSlot &&
|
||||
final showAvatar =
|
||||
showAvatarSlot &&
|
||||
chatType == "CHAT" &&
|
||||
nextMessage?.senderId != message.senderId;
|
||||
|
||||
final maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75;
|
||||
final bubbleColor =
|
||||
isMe ? cs.primaryContainer : cs.surfaceContainerHighest;
|
||||
final bubbleColor = isMe ? cs.primaryContainer : cs.surfaceContainerHighest;
|
||||
|
||||
_BubbleCtx makeCtx() => _BubbleCtx(
|
||||
context: context,
|
||||
@@ -308,8 +320,7 @@ class MessageBubble extends StatelessWidget {
|
||||
: _buildContent(makeCtx());
|
||||
|
||||
final reactionsUnder = _reactionsUnderBubble(contentType);
|
||||
final reactionsInside =
|
||||
contentType != MessageType.text && !reactionsUnder;
|
||||
final reactionsInside = contentType != MessageType.text && !reactionsUnder;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: Haptics.tap,
|
||||
@@ -322,8 +333,9 @@ class MessageBubble extends StatelessWidget {
|
||||
),
|
||||
child: Align(
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
mainAxisAlignment: isMe
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
@@ -337,8 +349,9 @@ class MessageBubble extends StatelessWidget {
|
||||
backgroundColor: Color(0x00000000),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
crossAxisAlignment: isMe
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListenableBuilder(
|
||||
listenable: Listenable.merge([
|
||||
@@ -540,7 +553,8 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
Widget _buildTextContent(_BubbleCtx ctx) {
|
||||
final attachments = message.attachments;
|
||||
final isForwardedContact = attachments != null &&
|
||||
final isForwardedContact =
|
||||
attachments != null &&
|
||||
attachments.isNotEmpty &&
|
||||
attachments.first is ForwardedMessageAttachment &&
|
||||
(attachments.first as ForwardedMessageAttachment).originalContact !=
|
||||
@@ -558,21 +572,18 @@ class MessageBubble extends StatelessWidget {
|
||||
? _buildForwardedInlineText(ctx, forwarded)
|
||||
: Text(
|
||||
message.text ?? '',
|
||||
style: TextStyle(
|
||||
color: ctx.text,
|
||||
fontSize: 16,
|
||||
height: 1.3,
|
||||
),
|
||||
style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3),
|
||||
);
|
||||
|
||||
final metaWidget = Text(
|
||||
message.status == 'EDITED'
|
||||
? '${_formatTime(message.time)} ред.'
|
||||
: _formatTime(message.time),
|
||||
? '${formatClock(DateTime.fromMillisecondsSinceEpoch(message.time))} ред.'
|
||||
: formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)),
|
||||
style: TextStyle(color: ctx.dim, fontSize: 10),
|
||||
);
|
||||
|
||||
final showSender = message.senderId != message.accountId &&
|
||||
final showSender =
|
||||
message.senderId != message.accountId &&
|
||||
prevMessage?.senderId != message.senderId &&
|
||||
chatType == "CHAT";
|
||||
|
||||
@@ -604,10 +615,7 @@ class MessageBubble extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: metaWidget,
|
||||
),
|
||||
if (isMe) ...[
|
||||
const SizedBox(width: 4),
|
||||
_buildStatusIcon(ctx),
|
||||
],
|
||||
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -634,10 +642,7 @@ class MessageBubble extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: metaWidget,
|
||||
),
|
||||
if (isMe) ...[
|
||||
const SizedBox(width: 4),
|
||||
_buildStatusIcon(ctx),
|
||||
],
|
||||
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -667,7 +672,11 @@ class MessageBubble extends StatelessWidget {
|
||||
if (senderAvatar != null && senderAvatar.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96),
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
senderAvatar,
|
||||
maxWidth: 96,
|
||||
maxHeight: 96,
|
||||
),
|
||||
backgroundColor: ctx.cs.primaryContainer,
|
||||
)
|
||||
else
|
||||
@@ -735,8 +744,9 @@ class MessageBubble extends StatelessWidget {
|
||||
if (fwd.originalContact != null) {
|
||||
return _buildForwardedContactContent(ctx, fwd);
|
||||
}
|
||||
final photos =
|
||||
fwd.originalAttachments?.whereType<PhotoAttachment>().toList();
|
||||
final photos = fwd.originalAttachments
|
||||
?.whereType<PhotoAttachment>()
|
||||
.toList();
|
||||
if (photos != null && photos.isNotEmpty) {
|
||||
return _buildForwardedPhotoContent(ctx, fwd, photos);
|
||||
}
|
||||
@@ -885,7 +895,11 @@ class MessageBubble extends StatelessWidget {
|
||||
if (senderAvatar != null && senderAvatar.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96),
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
senderAvatar,
|
||||
maxWidth: 96,
|
||||
maxHeight: 96,
|
||||
),
|
||||
backgroundColor: ctx.cs.primaryContainer,
|
||||
)
|
||||
else
|
||||
@@ -954,7 +968,11 @@ class MessageBubble extends StatelessWidget {
|
||||
if (senderAvatar != null && senderAvatar.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96),
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
senderAvatar,
|
||||
maxWidth: 96,
|
||||
maxHeight: 96,
|
||||
),
|
||||
backgroundColor: ctx.cs.primaryContainer,
|
||||
)
|
||||
else
|
||||
@@ -1007,10 +1025,12 @@ class MessageBubble extends StatelessWidget {
|
||||
final matchBottom = !ctx.hasPhotoWithCaption;
|
||||
|
||||
final topR = matchTop ? _bigRadius : _photoRadius;
|
||||
final bottomL =
|
||||
matchBottom ? (isMe ? _bigRadius : _smallRadius) : _smallRadius;
|
||||
final bottomR =
|
||||
matchBottom ? (isMe ? _smallRadius : _bigRadius) : _smallRadius;
|
||||
final bottomL = matchBottom
|
||||
? (isMe ? _bigRadius : _smallRadius)
|
||||
: _smallRadius;
|
||||
final bottomR = matchBottom
|
||||
? (isMe ? _smallRadius : _bigRadius)
|
||||
: _smallRadius;
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.only(
|
||||
@@ -1123,8 +1143,8 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) {
|
||||
final imageUrl = photo.baseUrl ?? '';
|
||||
final cachePx =
|
||||
(photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round();
|
||||
final cachePx = (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio)
|
||||
.round();
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Stack(
|
||||
@@ -1160,8 +1180,8 @@ class MessageBubble extends StatelessWidget {
|
||||
String overlay,
|
||||
) {
|
||||
final imageUrl = photo.baseUrl ?? '';
|
||||
final cachePx =
|
||||
(photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round();
|
||||
final cachePx = (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio)
|
||||
.round();
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Stack(
|
||||
@@ -1270,8 +1290,11 @@ class MessageBubble extends StatelessWidget {
|
||||
color: Colors.black54,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Symbols.play_arrow,
|
||||
color: Colors.white, size: 30),
|
||||
child: const Icon(
|
||||
Symbols.play_arrow,
|
||||
color: Colors.white,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
@@ -1289,10 +1312,7 @@ class MessageBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _playVideo(
|
||||
BuildContext context,
|
||||
MessageAttachment video,
|
||||
) async {
|
||||
Future<void> _playVideo(BuildContext context, MessageAttachment video) async {
|
||||
final videoId = (video as dynamic).videoId as int?;
|
||||
final token = (video as dynamic).videoToken as String?;
|
||||
if (videoId == null) {
|
||||
@@ -1335,7 +1355,7 @@ class MessageBubble extends StatelessWidget {
|
||||
Widget _buildFileAttachment(_BubbleCtx ctx, MessageAttachment file) {
|
||||
final name = (file as dynamic).name as String? ?? 'File';
|
||||
final size = (file as dynamic).size as int? ?? 0;
|
||||
final sizeStr = _formatFileSize(size);
|
||||
final sizeStr = formatBytes(size);
|
||||
final fileId = (file as dynamic).fileId as int?;
|
||||
final cacheName = '${fileId}_$name';
|
||||
|
||||
@@ -1384,7 +1404,9 @@ class MessageBubble extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
ValueListenableBuilder<double?>(
|
||||
valueListenable: MediaDownloadProgress.notifier(cacheName),
|
||||
valueListenable: MediaDownloadProgress.notifier(
|
||||
cacheName,
|
||||
),
|
||||
builder: (context, progress, _) => Text(
|
||||
progress != null
|
||||
? '${(progress * 100).round()}% · $sizeStr'
|
||||
@@ -1413,7 +1435,9 @@ class MessageBubble extends StatelessWidget {
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: isMe
|
||||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||||
? ctx.cs.onPrimaryContainer.withValues(
|
||||
alpha: 0.12,
|
||||
)
|
||||
: ctx.cs.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
@@ -1448,12 +1472,6 @@ class MessageBubble extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String _formatFileSize(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} МБ';
|
||||
}
|
||||
|
||||
Widget _buildStickerAttachment(_BubbleCtx ctx, MessageAttachment sticker) {
|
||||
final url = sticker.baseUrl ?? '';
|
||||
final preview = sticker.previewData ?? '';
|
||||
@@ -1533,8 +1551,7 @@ class MessageBubble extends StatelessWidget {
|
||||
)
|
||||
: Icon(
|
||||
Symbols.person,
|
||||
color:
|
||||
isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
||||
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
@@ -1559,11 +1576,7 @@ class MessageBubble extends StatelessWidget {
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
contactData.phoneNumber!,
|
||||
style: TextStyle(
|
||||
color: ctx.dim,
|
||||
fontSize: 12,
|
||||
height: 1.2,
|
||||
),
|
||||
style: TextStyle(color: ctx.dim, fontSize: 12, height: 1.2),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -1614,7 +1627,11 @@ class MessageBubble extends StatelessWidget {
|
||||
if (senderAvatar != null && senderAvatar.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96),
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
senderAvatar,
|
||||
maxWidth: 96,
|
||||
maxHeight: 96,
|
||||
),
|
||||
backgroundColor: ctx.cs.primaryContainer,
|
||||
)
|
||||
else
|
||||
@@ -1816,13 +1833,10 @@ class MessageBubble extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(message.time),
|
||||
formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)),
|
||||
style: TextStyle(color: ctx.dim, fontSize: 11),
|
||||
),
|
||||
if (isMe) ...[
|
||||
const SizedBox(width: 4),
|
||||
_buildStatusIcon(ctx),
|
||||
],
|
||||
if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1840,7 +1854,7 @@ class MessageBubble extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_formatTime(message.time),
|
||||
formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
@@ -1880,13 +1894,6 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
return Icon(icon, size: 14, color: color);
|
||||
}
|
||||
|
||||
String _formatTime(int timestamp) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
final hour = dt.hour.toString().padLeft(2, '0');
|
||||
final minute = dt.minute.toString().padLeft(2, '0');
|
||||
return '$hour:$minute';
|
||||
}
|
||||
}
|
||||
|
||||
class _VoiceMessageBubble extends StatefulWidget {
|
||||
@@ -1941,19 +1948,6 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _formatDuration(int seconds) {
|
||||
final min = seconds ~/ 60;
|
||||
final sec = seconds % 60;
|
||||
return '$min:${sec.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _formatTime(int timestamp) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
final hour = dt.hour.toString().padLeft(2, '0');
|
||||
final minute = dt.minute.toString().padLeft(2, '0');
|
||||
return '$hour:$minute';
|
||||
}
|
||||
|
||||
Widget _buildStatusIcon() {
|
||||
final status = widget.status;
|
||||
IconData icon;
|
||||
@@ -2050,7 +2044,8 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||||
),
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: _progress,
|
||||
builder: (context, progress, _) => FractionallySizedBox(
|
||||
builder: (context, progress, _) =>
|
||||
FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: progress.clamp(0.0, 1.0),
|
||||
child: Container(
|
||||
@@ -2103,7 +2098,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||||
width: 32,
|
||||
child: Center(
|
||||
child: Text(
|
||||
_formatDuration(widget.duration),
|
||||
formatSecondsMmSs(widget.duration),
|
||||
style: TextStyle(
|
||||
color: widget.textColor.withValues(alpha: 0.7),
|
||||
fontSize: 11,
|
||||
@@ -2133,7 +2128,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||||
),
|
||||
if (!_transcriptionVisible) ...[
|
||||
Text(
|
||||
_formatTime(widget.time),
|
||||
formatClock(DateTime.fromMillisecondsSinceEpoch(widget.time)),
|
||||
style: TextStyle(
|
||||
color: widget.textColor.withValues(alpha: 0.6),
|
||||
fontSize: 10,
|
||||
@@ -2151,7 +2146,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(widget.time),
|
||||
formatClock(DateTime.fromMillisecondsSinceEpoch(widget.time)),
|
||||
style: TextStyle(
|
||||
color: widget.textColor.withValues(alpha: 0.6),
|
||||
fontSize: 10,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Small primary-colored section title used across settings/profile screens.
|
||||
class SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final double fontSize;
|
||||
|
||||
const SectionHeader(
|
||||
this.title, {
|
||||
super.key,
|
||||
this.padding = const EdgeInsets.only(top: 16, bottom: 8, left: 4, right: 4),
|
||||
this.fontSize = 13,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: cs.primary,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Standard rounded top shape for modal bottom sheets.
|
||||
const RoundedRectangleBorder kSheetShape = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
);
|
||||
|
||||
/// The little drag "grabber" pill shown at the top of a bottom sheet.
|
||||
class SheetGrabber extends StatelessWidget {
|
||||
final EdgeInsetsGeometry margin;
|
||||
|
||||
const SheetGrabber({
|
||||
super.key,
|
||||
this.margin = const EdgeInsets.symmetric(vertical: 10),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: margin,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PillNavItem {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool longPressable;
|
||||
|
||||
const PillNavItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.longPressable = false,
|
||||
});
|
||||
}
|
||||
|
||||
class PillNavGeometry {
|
||||
final double navInnerW;
|
||||
final double activeWidth;
|
||||
final double inactiveWidth;
|
||||
|
||||
const PillNavGeometry(this.navInnerW, this.activeWidth, this.inactiveWidth);
|
||||
|
||||
factory PillNavGeometry.fromInnerWidth(double navInnerW, int itemCount) {
|
||||
final totalWeight = (itemCount - 1) + _activeWeight;
|
||||
final unit = navInnerW / totalWeight;
|
||||
return PillNavGeometry(navInnerW, unit * _activeWeight, unit);
|
||||
}
|
||||
|
||||
static const double _activeWeight = 2.2;
|
||||
}
|
||||
|
||||
class SlidingPillNav extends StatelessWidget {
|
||||
final List<PillNavItem> items;
|
||||
final double position;
|
||||
final Duration animationDuration;
|
||||
final PillNavGeometry geometry;
|
||||
final ValueChanged<int> onTap;
|
||||
final void Function(int index, Offset globalPosition)? onItemLongPress;
|
||||
final double iconSize;
|
||||
final double labelGap;
|
||||
|
||||
const SlidingPillNav({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.position,
|
||||
required this.geometry,
|
||||
required this.onTap,
|
||||
this.animationDuration = Duration.zero,
|
||||
this.onItemLongPress,
|
||||
this.iconSize = 22,
|
||||
this.labelGap = 6,
|
||||
});
|
||||
|
||||
static const double height = 68;
|
||||
|
||||
double _interpWidth(int tab) {
|
||||
final maxIndex = items.length - 1;
|
||||
final rt = position.clamp(0.0, maxIndex.toDouble());
|
||||
final i0 = rt.floor();
|
||||
final i1 = rt.ceil();
|
||||
final frac = i0 == i1 ? 0.0 : rt - i0;
|
||||
double at(int sel) =>
|
||||
(tab == sel ? geometry.activeWidth : geometry.inactiveWidth) - 0.5;
|
||||
return at(i0) + (at(i1) - at(i0)) * frac;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final visualSel = position.round().clamp(0, items.length - 1);
|
||||
return Container(
|
||||
height: height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(34),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: animationDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
left: position * geometry.inactiveWidth + 4,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
width: geometry.activeWidth - 8,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: geometry.navInnerW,
|
||||
child: Row(
|
||||
children: List.generate(items.length, (i) {
|
||||
return AnimatedContainer(
|
||||
duration: animationDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
width: _interpWidth(i),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
child: _PillNavCell(
|
||||
item: items[i],
|
||||
selected: i == visualSel,
|
||||
cs: cs,
|
||||
animationDuration: animationDuration,
|
||||
iconSize: iconSize,
|
||||
labelGap: labelGap,
|
||||
onTap: () => onTap(i),
|
||||
onLongPress:
|
||||
(onItemLongPress == null || !items[i].longPressable)
|
||||
? null
|
||||
: (pos) => onItemLongPress!(i, pos),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PillNavCell extends StatelessWidget {
|
||||
final PillNavItem item;
|
||||
final bool selected;
|
||||
final ColorScheme cs;
|
||||
final Duration animationDuration;
|
||||
final double iconSize;
|
||||
final double labelGap;
|
||||
final VoidCallback onTap;
|
||||
final void Function(Offset globalPosition)? onLongPress;
|
||||
|
||||
const _PillNavCell({
|
||||
required this.item,
|
||||
required this.selected,
|
||||
required this.cs,
|
||||
required this.animationDuration,
|
||||
required this.iconSize,
|
||||
required this.labelGap,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final opacityDuration = animationDuration == Duration.zero
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 200);
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onLongPressStart: onLongPress == null
|
||||
? null
|
||||
: (d) => onLongPress!(d.globalPosition),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
item.icon,
|
||||
color: selected ? cs.onPrimary : cs.onSurface,
|
||||
size: iconSize,
|
||||
fill: 1,
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: animationDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
width: selected ? null : 0,
|
||||
child: AnimatedOpacity(
|
||||
duration: opacityDuration,
|
||||
opacity: selected ? 1.0 : 0.0,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(width: labelGap),
|
||||
Text(
|
||||
item.label,
|
||||
style: TextStyle(
|
||||
color: cs.onPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
-8
@@ -91,8 +91,6 @@ void main() async {
|
||||
final storiesFuture = AppStories.load();
|
||||
final cacheLimitFuture = AppMediaCacheLimit.load();
|
||||
|
||||
await api.connect();
|
||||
|
||||
final packageInfo = await packageInfoFuture;
|
||||
if (packageInfo.packageName == 'ru.oneme.app') {
|
||||
await PushService.instance.init(api: api, account: accountModule);
|
||||
@@ -714,28 +712,26 @@ class _StartupScreenState extends State<_StartupScreen> {
|
||||
}
|
||||
|
||||
Future<void> _tryAutoLogin() async {
|
||||
unawaited(api.connect());
|
||||
|
||||
int? accountId = await TokenStorage.getActiveAccountId();
|
||||
|
||||
if (accountId == null || await TokenStorage.readToken(accountId) == null) {
|
||||
accountId = await _recoverActiveAccount();
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (accountId == null) {
|
||||
_goToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await accountModule.login(accountId: accountId);
|
||||
} catch (_) {}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AdaptiveShell()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<int?> _recoverActiveAccount() async {
|
||||
final profiles = await AppDatabase.loadAllProfiles();
|
||||
|
||||
+12
-4
@@ -601,10 +601,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.18.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -749,6 +749,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
photo_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: photo_manager
|
||||
sha256: fb3bc8ea653370f88742b3baa304700107c83d12748aa58b2b9f2ed3ef15e6c2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.9.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -982,10 +990,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
version: "0.7.11"
|
||||
timezone:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
+2
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+1
|
||||
version: 0.5.0+10
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.4
|
||||
@@ -45,6 +45,7 @@ dependencies:
|
||||
flutter_timezone: ^5.0.1
|
||||
timezone: ^0.11.0
|
||||
file_picker: ^8.0.0
|
||||
photo_manager: ^3.0.0
|
||||
image: ^4.3.0
|
||||
sqflite: ^2.4.2
|
||||
sqflite_common_ffi: ^2.4.0+2
|
||||
|
||||
Reference in New Issue
Block a user