feat/fix/refactor: Кнопка добавления человека в контакты, возможность развертывания аватарки на экране профиля, включение сторисов по умолчанию, показ сторисов на экране чатов, чата, и при просмотре списка участников, миниапы у ботов. Фикс прогресса просмотра сториса, фикс предпросмотра медиав пикере, прогресс бар отправки фото/видео/файлов + фото продолжат отправку даже если выйти с чата. Вынес превью фото, добавил отдельный модуль отправки медиа
This commit is contained in:
@@ -465,6 +465,10 @@ class ContactsModule {
|
||||
}
|
||||
}
|
||||
|
||||
static final Map<int, ContactPhotos> _photosHead = {};
|
||||
|
||||
static ContactPhotos? cachedPhotos(int contactId) => _photosHead[contactId];
|
||||
|
||||
static Future<ContactPhotos> fetchPhotos(
|
||||
Api api,
|
||||
int contactId, {
|
||||
@@ -482,7 +486,9 @@ class ContactsModule {
|
||||
? rawUrls.whereType<String>().toList()
|
||||
: <String>[];
|
||||
final total = map['total'] is int ? map['total'] as int : urls.length;
|
||||
return ContactPhotos(urls: urls, total: total);
|
||||
final photos = ContactPhotos(urls: urls, total: total);
|
||||
if (from == 0) _photosHead[contactId] = photos;
|
||||
return photos;
|
||||
}
|
||||
|
||||
static Future<List<CachedContact>> getContacts(int accountId) async {
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/cache/message_session_cache.dart';
|
||||
import '../../core/media/gallery_source.dart';
|
||||
import '../../core/media/image_optimizer.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../../main.dart' show fileUploader, messagesModule;
|
||||
import 'messages.dart';
|
||||
|
||||
sealed class MediaSendEvent {
|
||||
final int chatId;
|
||||
final String tempId;
|
||||
final bool scheduled;
|
||||
|
||||
const MediaSendEvent({
|
||||
required this.chatId,
|
||||
required this.tempId,
|
||||
required this.scheduled,
|
||||
});
|
||||
}
|
||||
|
||||
class MediaSendDone extends MediaSendEvent {
|
||||
final CachedMessage? message;
|
||||
final int? scheduledTime;
|
||||
|
||||
const MediaSendDone({
|
||||
required super.chatId,
|
||||
required super.tempId,
|
||||
required super.scheduled,
|
||||
this.message,
|
||||
this.scheduledTime,
|
||||
});
|
||||
}
|
||||
|
||||
class MediaSendFailed extends MediaSendEvent {
|
||||
const MediaSendFailed({
|
||||
required super.chatId,
|
||||
required super.tempId,
|
||||
required super.scheduled,
|
||||
});
|
||||
}
|
||||
|
||||
class MediaSendService {
|
||||
MediaSendService._();
|
||||
|
||||
static final MediaSendService instance = MediaSendService._();
|
||||
|
||||
final StreamController<MediaSendEvent> _events =
|
||||
StreamController<MediaSendEvent>.broadcast();
|
||||
|
||||
static const int _historyLimit = 60;
|
||||
static const int _photoConcurrency = 3;
|
||||
static const int _photoAttempts = 3;
|
||||
|
||||
final Map<String, ValueNotifier<List<double>>> _progress = {};
|
||||
final Map<int, List<CachedMessage>> _pending = {};
|
||||
final Map<String, CachedMessage> _completed = {};
|
||||
final Set<String> _failed = {};
|
||||
|
||||
Stream<MediaSendEvent> get events => _events.stream;
|
||||
|
||||
ValueListenable<List<double>>? progressFor(String tempId) =>
|
||||
_progress[tempId];
|
||||
|
||||
List<CachedMessage> pendingFor(int chatId) =>
|
||||
List<CachedMessage>.unmodifiable(_pending[chatId] ?? const []);
|
||||
|
||||
CachedMessage? completedFor(String tempId) => _completed[tempId];
|
||||
|
||||
bool didFail(String tempId) => _failed.contains(tempId);
|
||||
|
||||
Future<void> sendPhotos({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required List<({File file, GalleryItem? item})> jobs,
|
||||
required String caption,
|
||||
CachedMessage? placeholder,
|
||||
int? scheduledTime,
|
||||
}) {
|
||||
return _run(
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
tempId: tempId,
|
||||
placeholder: placeholder,
|
||||
scheduledTime: scheduledTime,
|
||||
slots: jobs.length,
|
||||
upload: (progress) async {
|
||||
final tokens = await _uploadPhotos(jobs, progress);
|
||||
if (tokens.any((t) => t == null)) return null;
|
||||
return messagesModule.sendPhotoMessage(
|
||||
chatId,
|
||||
tokens.cast<String>(),
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendVideo({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required File file,
|
||||
required String caption,
|
||||
CachedMessage? placeholder,
|
||||
int? scheduledTime,
|
||||
}) {
|
||||
return _run(
|
||||
accountId: accountId,
|
||||
chatId: chatId,
|
||||
tempId: tempId,
|
||||
placeholder: placeholder,
|
||||
scheduledTime: scheduledTime,
|
||||
slots: 1,
|
||||
upload: (progress) async {
|
||||
final info = await messagesModule.requestVideoUploadUrl();
|
||||
if (info == null || info.url.isEmpty) return null;
|
||||
final ok = await fileUploader.uploadVideoFile(
|
||||
Uri.parse(info.url),
|
||||
file,
|
||||
onProgress: (sent, total) {
|
||||
if (total > 0) {
|
||||
progress.value = [(sent / total).clamp(0.0, 1.0)];
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!ok) return null;
|
||||
progress.value = const [1];
|
||||
return messagesModule.sendVideoMessage(
|
||||
chatId,
|
||||
info.token,
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _run({
|
||||
required int accountId,
|
||||
required int chatId,
|
||||
required String tempId,
|
||||
required int slots,
|
||||
required Future<Map<String, dynamic>?> Function(
|
||||
ValueNotifier<List<double>> progress,
|
||||
)
|
||||
upload,
|
||||
CachedMessage? placeholder,
|
||||
int? scheduledTime,
|
||||
}) async {
|
||||
final scheduled = scheduledTime != null;
|
||||
final progress = ValueNotifier<List<double>>(
|
||||
List<double>.filled(slots < 1 ? 1 : slots, 0),
|
||||
);
|
||||
_progress[tempId] = progress;
|
||||
if (placeholder != null) {
|
||||
(_pending[chatId] ??= <CachedMessage>[]).add(placeholder);
|
||||
}
|
||||
|
||||
try {
|
||||
final serverMsg = await upload(progress);
|
||||
if (serverMsg == null) throw Exception('send_failed');
|
||||
|
||||
if (scheduled) {
|
||||
_finish(chatId, tempId);
|
||||
_events.add(
|
||||
MediaSendDone(
|
||||
chatId: chatId,
|
||||
tempId: tempId,
|
||||
scheduled: true,
|
||||
scheduledTime: scheduledTime,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final real = CachedMessage.fromPushPayload(accountId, chatId, serverMsg);
|
||||
try {
|
||||
await AppDatabase.saveMessages([real.toDbRow()]);
|
||||
if (real.id != tempId) {
|
||||
await AppDatabase.deleteMessage(accountId, chatId, tempId);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('MediaSendService: не удалось сохранить сообщение: $e');
|
||||
}
|
||||
_replaceInSessionCache(accountId, chatId, tempId, real);
|
||||
_remember(tempId, real);
|
||||
_finish(chatId, tempId);
|
||||
_events.add(
|
||||
MediaSendDone(
|
||||
chatId: chatId,
|
||||
tempId: tempId,
|
||||
scheduled: false,
|
||||
message: real,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.w('MediaSendService: $e');
|
||||
if (!scheduled) {
|
||||
_replaceInSessionCache(accountId, chatId, tempId, null);
|
||||
_remember(tempId, null);
|
||||
}
|
||||
_finish(chatId, tempId);
|
||||
_events.add(
|
||||
MediaSendFailed(chatId: chatId, tempId: tempId, scheduled: scheduled),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _remember(String tempId, CachedMessage? real) {
|
||||
if (_completed.length + _failed.length > _historyLimit) {
|
||||
_completed.clear();
|
||||
_failed.clear();
|
||||
}
|
||||
if (real == null) {
|
||||
_failed.add(tempId);
|
||||
} else {
|
||||
_completed[tempId] = real;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String?>> _uploadPhotos(
|
||||
List<({File file, GalleryItem? item})> jobs,
|
||||
ValueNotifier<List<double>> progress,
|
||||
) async {
|
||||
final tokens = List<String?>.filled(jobs.length, null);
|
||||
var nextIndex = 0;
|
||||
var failed = false;
|
||||
|
||||
Future<void> worker() async {
|
||||
while (!failed) {
|
||||
final i = nextIndex++;
|
||||
if (i >= jobs.length) return;
|
||||
final token = await _uploadOnePhoto(jobs[i], i, progress);
|
||||
if (token == null) {
|
||||
failed = true;
|
||||
return;
|
||||
}
|
||||
tokens[i] = token;
|
||||
}
|
||||
}
|
||||
|
||||
final workerCount = jobs.length < _photoConcurrency
|
||||
? jobs.length
|
||||
: _photoConcurrency;
|
||||
await Future.wait(List.generate(workerCount, (_) => worker()));
|
||||
return tokens;
|
||||
}
|
||||
|
||||
Future<String?> _uploadOnePhoto(
|
||||
({File file, GalleryItem? item}) job,
|
||||
int index,
|
||||
ValueNotifier<List<double>> progress,
|
||||
) async {
|
||||
File file;
|
||||
try {
|
||||
file = await optimizePhotoForUpload(job.file, item: job.item);
|
||||
} catch (e) {
|
||||
logger.w('optimize photo: $e');
|
||||
file = job.file;
|
||||
}
|
||||
for (var attempt = 0; attempt < _photoAttempts; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await Future.delayed(Duration(seconds: attempt));
|
||||
_setSlot(progress, index, 0);
|
||||
}
|
||||
try {
|
||||
final url = await messagesModule.requestPhotoUploadUrl();
|
||||
if (url == null || url.isEmpty) continue;
|
||||
final token = await fileUploader.uploadPhoto(
|
||||
Uri.parse(url),
|
||||
file,
|
||||
filename: _photoFilename(file),
|
||||
onProgress: (sent, total) {
|
||||
if (total <= 0) return;
|
||||
_setSlot(progress, index, (sent / total).clamp(0.0, 1.0));
|
||||
},
|
||||
);
|
||||
if (token != null) return token;
|
||||
} catch (e) {
|
||||
logger.w('uploadOnePhoto attempt ${attempt + 1}: $e');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _setSlot(
|
||||
ValueNotifier<List<double>> progress,
|
||||
int index,
|
||||
double value,
|
||||
) {
|
||||
final next = List<double>.from(progress.value);
|
||||
if (index < next.length) {
|
||||
next[index] = value;
|
||||
progress.value = next;
|
||||
}
|
||||
}
|
||||
|
||||
String _photoFilename(File file) {
|
||||
final segments = file.uri.pathSegments;
|
||||
final name = segments.isNotEmpty ? segments.last : '';
|
||||
return name.isNotEmpty ? name : 'photo.jpg';
|
||||
}
|
||||
|
||||
void _finish(int chatId, String tempId) {
|
||||
_progress.remove(tempId);
|
||||
final list = _pending[chatId];
|
||||
if (list == null) return;
|
||||
list.removeWhere((m) => m.id == tempId);
|
||||
if (list.isEmpty) _pending.remove(chatId);
|
||||
}
|
||||
|
||||
void _replaceInSessionCache(
|
||||
int accountId,
|
||||
int chatId,
|
||||
String tempId,
|
||||
CachedMessage? real,
|
||||
) {
|
||||
final cached = MessageSessionCache.get(accountId, chatId);
|
||||
if (cached == null) return;
|
||||
final list = List<CachedMessage>.of(cached.messages);
|
||||
final idx = list.indexWhere((m) => m.id == tempId);
|
||||
if (idx == -1) return;
|
||||
list[idx] = real ?? list[idx].copyWith(status: 'error');
|
||||
MessageSessionCache.save(
|
||||
accountId,
|
||||
chatId,
|
||||
list,
|
||||
reachedStart: cached.reachedStart,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -297,6 +297,8 @@ class ReplyInfo {
|
||||
this.attachments,
|
||||
});
|
||||
|
||||
bool get missing => previewText().isEmpty;
|
||||
|
||||
static ReplyInfo? fromPayload(Map<String, dynamic>? payload) {
|
||||
if (payload == null) return null;
|
||||
final link = payload['link'];
|
||||
|
||||
@@ -123,6 +123,7 @@ class StoriesModule {
|
||||
if (acc == null) return;
|
||||
final map = <String, dynamic>{};
|
||||
_peerStories.forEach((ownerId, stories) {
|
||||
if (!_previews.containsKey(ownerId)) return;
|
||||
map['$ownerId'] = stories.map((s) => s.toJson()).toList();
|
||||
});
|
||||
await AppDatabase.setSyncValue(acc, _peersKey, jsonEncode(map));
|
||||
@@ -147,6 +148,11 @@ class StoriesModule {
|
||||
|
||||
int? lastViewedStoryId(int ownerId) => _lastViewed[ownerId];
|
||||
|
||||
void clearLastViewed(int ownerId) {
|
||||
if (_lastViewed.remove(ownerId) == null) return;
|
||||
unawaited(_persistProgress());
|
||||
}
|
||||
|
||||
/// Кольца-превью, отсортированные: сначала непрочитанные, затем по времени.
|
||||
List<StoryPreview> get previews {
|
||||
final list = _previews.values.where((p) => !p.isEmpty).toList();
|
||||
@@ -161,6 +167,83 @@ class StoriesModule {
|
||||
|
||||
StoryPreview? previewFor(int ownerId) => _previews[ownerId];
|
||||
|
||||
final Map<int, StoryPreview> _peerPreviews = {};
|
||||
final Set<int> _requestedOwners = {};
|
||||
|
||||
static const int _ownersChunk = 20;
|
||||
|
||||
StoryPreview? previewOf(int ownerId) {
|
||||
final feed = _previews[ownerId];
|
||||
if (feed != null) return feed.isEmpty ? null : feed;
|
||||
final peer = _peerPreviews[ownerId];
|
||||
return (peer == null || peer.isEmpty) ? null : peer;
|
||||
}
|
||||
|
||||
Future<void> loadOwnersPreviews(List<int> ownerIds) async {
|
||||
if (_api.state != SessionState.online) return;
|
||||
final missing = ownerIds
|
||||
.where((id) => id > 0 && !_requestedOwners.contains(id))
|
||||
.toSet()
|
||||
.toList();
|
||||
if (missing.isEmpty) return;
|
||||
_requestedOwners.addAll(missing);
|
||||
|
||||
var changed = false;
|
||||
for (var i = 0; i < missing.length; i += _ownersChunk) {
|
||||
final end = i + _ownersChunk > missing.length
|
||||
? missing.length
|
||||
: i + _ownersChunk;
|
||||
final chunk = missing.sublist(i, end);
|
||||
try {
|
||||
final packet = await _api.sendRequest(Opcode.storiesGetByOwner, {
|
||||
'owners': [
|
||||
for (final id in chunk) StoryOwner(ownerId: id).toMap(),
|
||||
],
|
||||
}, silent: true);
|
||||
if (packet.isError) continue;
|
||||
if (_applyOwnerPayload(packet.payload, chunk)) changed = true;
|
||||
} catch (e) {
|
||||
logger.w('StoriesModule.loadOwnersPreviews: $e');
|
||||
}
|
||||
}
|
||||
if (changed) _bump();
|
||||
}
|
||||
|
||||
bool _applyOwnerPayload(Object? data, List<int> requested) {
|
||||
if (data is! Map) return false;
|
||||
var changed = false;
|
||||
final seen = <int>{};
|
||||
final rawPreviews = data['storiesPreviews'];
|
||||
if (rawPreviews is List) {
|
||||
for (final raw in rawPreviews) {
|
||||
final preview = StoryPreview.fromMap(raw);
|
||||
if (preview == null) continue;
|
||||
final id = preview.owner.ownerId;
|
||||
seen.add(id);
|
||||
if (preview.isEmpty) {
|
||||
_peerPreviews.remove(id);
|
||||
} else {
|
||||
_peerPreviews[id] = preview;
|
||||
}
|
||||
_refreshPreview(preview);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (final id in requested) {
|
||||
if (!seen.contains(id) && _peerPreviews.remove(id) != null) changed = true;
|
||||
}
|
||||
final rawPeers = data['peerStories'];
|
||||
if (rawPeers is List) {
|
||||
for (final raw in rawPeers) {
|
||||
final peer = PeerStories.fromMap(raw);
|
||||
if (peer == null) continue;
|
||||
_peerStories[peer.owner.ownerId] = peer.stories;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
List<Story>? cachedStories(int ownerId) => _peerStories[ownerId];
|
||||
|
||||
/// Подписка на серверные пуши обновления колец (NOTIF_STORIES_UPDATE).
|
||||
@@ -180,6 +263,11 @@ class StoriesModule {
|
||||
unawaited(_persistPreviews());
|
||||
}
|
||||
|
||||
void _refreshPreview(StoryPreview preview) {
|
||||
if (!_previews.containsKey(preview.owner.ownerId)) return;
|
||||
_applyPreview(preview);
|
||||
}
|
||||
|
||||
void _applyPreview(StoryPreview preview) {
|
||||
if (preview.isEmpty) {
|
||||
_previews.remove(preview.owner.ownerId);
|
||||
@@ -234,7 +322,7 @@ class StoriesModule {
|
||||
if (rawPreviews is List) {
|
||||
for (final raw in rawPreviews) {
|
||||
final preview = StoryPreview.fromMap(raw);
|
||||
if (preview != null) _applyPreview(preview);
|
||||
if (preview != null) _refreshPreview(preview);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +346,31 @@ class StoriesModule {
|
||||
}
|
||||
}
|
||||
|
||||
Future<StoryPreview?> loadOwnerPreview(StoryOwner owner) async {
|
||||
final cached = _previews[owner.ownerId];
|
||||
if (_api.state != SessionState.online) return cached;
|
||||
try {
|
||||
final packet = await _api.sendRequest(Opcode.storiesGetByOwner, {
|
||||
'owners': [owner.toMap()],
|
||||
});
|
||||
throwIfPacketError(packet);
|
||||
final data = packet.payload;
|
||||
if (data is! Map) return cached;
|
||||
|
||||
_applyOwnerPayload(data, [owner.ownerId]);
|
||||
_requestedOwners.add(owner.ownerId);
|
||||
final own = previewOf(owner.ownerId);
|
||||
|
||||
_bump();
|
||||
unawaited(_persistPreviews());
|
||||
unawaited(_persistPeers());
|
||||
return own;
|
||||
} catch (e) {
|
||||
logger.w('StoriesModule.loadOwnerPreview: $e');
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
/// Отметить историю просмотренной. Оптимистично поднимает readCount кольца.
|
||||
Future<bool> mark(StoryOwner owner, int storyId) async {
|
||||
if (_api.state != SessionState.online) return false;
|
||||
|
||||
@@ -13,6 +13,11 @@ abstract class EntryBannerApps {
|
||||
};
|
||||
}
|
||||
|
||||
const Set<String> kMiniAppOptions = {'HAS_WEBAPP', 'HAS_WEB_APP', 'WEBAPP'};
|
||||
|
||||
bool hasMiniAppOption(Set<String>? options) =>
|
||||
options != null && options.any(kMiniAppOptions.contains);
|
||||
|
||||
class WebAppLaunch {
|
||||
final String url;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'persisted_setting.dart';
|
||||
|
||||
class AppStories {
|
||||
static const prefKey = 'dev_stories';
|
||||
static const bool defaultValue = false;
|
||||
static const bool defaultValue = true;
|
||||
|
||||
static final _setting = PersistedSetting<bool>(
|
||||
prefKey: prefKey,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
class DesktopVideoProbe {
|
||||
static const Duration _timeout = Duration(seconds: 6);
|
||||
static const int _maxCache = 60;
|
||||
|
||||
static bool get supported =>
|
||||
!Platform.isAndroid && !Platform.isIOS && !Platform.isFuchsia;
|
||||
|
||||
static bool? _hasTools;
|
||||
static final Map<String, Duration?> _durations = {};
|
||||
static final Map<String, Uint8List?> _thumbs = {};
|
||||
|
||||
static Future<bool> _toolsAvailable() async {
|
||||
if (_hasTools != null) return _hasTools!;
|
||||
if (!supported) return _hasTools = false;
|
||||
try {
|
||||
final probe = await Process.run('ffprobe', const [
|
||||
'-version',
|
||||
]).timeout(_timeout);
|
||||
_hasTools = probe.exitCode == 0;
|
||||
} catch (_) {
|
||||
_hasTools = false;
|
||||
}
|
||||
return _hasTools!;
|
||||
}
|
||||
|
||||
static Future<Duration?> duration(String path) async {
|
||||
if (_durations.containsKey(path)) return _durations[path];
|
||||
if (!await _toolsAvailable()) return null;
|
||||
Duration? result;
|
||||
try {
|
||||
final out = await Process.run('ffprobe', [
|
||||
'-v',
|
||||
'error',
|
||||
'-show_entries',
|
||||
'format=duration',
|
||||
'-of',
|
||||
'default=noprint_wrappers=1:nokey=1',
|
||||
path,
|
||||
]).timeout(_timeout);
|
||||
final seconds = double.tryParse('${out.stdout}'.trim());
|
||||
if (seconds != null && seconds > 0) {
|
||||
result = Duration(milliseconds: (seconds * 1000).round());
|
||||
}
|
||||
} catch (_) {}
|
||||
_remember(_durations, path, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
static final Map<String, (int, int)?> _sizes = {};
|
||||
|
||||
static Future<(int, int)?> dimensions(String path) async {
|
||||
if (_sizes.containsKey(path)) return _sizes[path];
|
||||
if (!await _toolsAvailable()) return null;
|
||||
(int, int)? result;
|
||||
try {
|
||||
final out = await Process.run('ffprobe', [
|
||||
'-v',
|
||||
'error',
|
||||
'-select_streams',
|
||||
'v:0',
|
||||
'-show_entries',
|
||||
'stream=width,height',
|
||||
'-of',
|
||||
'csv=s=x:p=0',
|
||||
path,
|
||||
]).timeout(_timeout);
|
||||
final parts = '${out.stdout}'.trim().split('x');
|
||||
if (parts.length >= 2) {
|
||||
final w = int.tryParse(parts[0].trim());
|
||||
final h = int.tryParse(parts[1].trim());
|
||||
if (w != null && h != null && w > 0 && h > 0) result = (w, h);
|
||||
}
|
||||
} catch (_) {}
|
||||
_remember(_sizes, path, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
static Future<Uint8List?> thumbnail(String path, int size) async {
|
||||
final key = '$path@$size';
|
||||
if (_thumbs.containsKey(key)) return _thumbs[key];
|
||||
if (!await _toolsAvailable()) return null;
|
||||
var bytes = await _grabFrame(path, size, '1');
|
||||
bytes ??= await _grabFrame(path, size, '0');
|
||||
_remember(_thumbs, key, bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static Future<Uint8List?> _grabFrame(
|
||||
String path,
|
||||
int size,
|
||||
String seek,
|
||||
) async {
|
||||
try {
|
||||
final out = await Process.run('ffmpeg', [
|
||||
'-v',
|
||||
'error',
|
||||
'-ss',
|
||||
seek,
|
||||
'-i',
|
||||
path,
|
||||
'-frames:v',
|
||||
'1',
|
||||
'-vf',
|
||||
'scale=$size:-2:force_original_aspect_ratio=decrease',
|
||||
'-f',
|
||||
'image2',
|
||||
'-vcodec',
|
||||
'mjpeg',
|
||||
'pipe:1',
|
||||
], stdoutEncoding: null).timeout(_timeout);
|
||||
final data = out.stdout;
|
||||
if (data is List<int> && data.isNotEmpty) {
|
||||
return Uint8List.fromList(data);
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static void _remember<T>(Map<String, T> cache, String key, T value) {
|
||||
if (cache.length > _maxCache) cache.clear();
|
||||
cache[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
import 'desktop_video_probe.dart';
|
||||
|
||||
enum GalleryPermission { granted, limited, denied }
|
||||
|
||||
abstract class GalleryItem {
|
||||
@@ -134,17 +137,37 @@ class _AssetGalleryItem implements GalleryItem {
|
||||
}
|
||||
}
|
||||
|
||||
const Set<String> kGalleryImageExtensions = {
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.png',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
'.heic',
|
||||
'.heif',
|
||||
};
|
||||
|
||||
const Set<String> kGalleryVideoExtensions = {
|
||||
'.mp4',
|
||||
'.mov',
|
||||
'.m4v',
|
||||
'.mkv',
|
||||
'.webm',
|
||||
'.avi',
|
||||
'.3gp',
|
||||
};
|
||||
|
||||
String _fileExtension(String path) {
|
||||
final dot = path.lastIndexOf('.');
|
||||
if (dot < 0) return '';
|
||||
return path.substring(dot).toLowerCase();
|
||||
}
|
||||
|
||||
bool isVideoPath(String path) =>
|
||||
kGalleryVideoExtensions.contains(_fileExtension(path));
|
||||
|
||||
class _DesktopGallerySource implements GallerySource {
|
||||
static const _imageExtensions = {
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.png',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
'.heic',
|
||||
'.heif',
|
||||
};
|
||||
|
||||
@override
|
||||
Future<GalleryPermission> ensurePermission() async =>
|
||||
@@ -157,13 +180,30 @@ class _DesktopGallerySource implements GallerySource {
|
||||
if (!dir.existsSync()) continue;
|
||||
try {
|
||||
for (final entity in dir.listSync(followLinks: false)) {
|
||||
if (entity is! File || !_isImage(entity.path)) continue;
|
||||
if (entity is! File || !_isMedia(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();
|
||||
final items = entries
|
||||
.take(limit)
|
||||
.map((e) => _FileGalleryItem(e.file))
|
||||
.toList();
|
||||
const batch = 8;
|
||||
const eager = 24;
|
||||
|
||||
Future<void> probeRange(int from, int to) async {
|
||||
for (var i = from; i < to; i += batch) {
|
||||
final end = i + batch > to ? to : i + batch;
|
||||
await Future.wait(items.sublist(i, end).map((it) => it.probe()));
|
||||
}
|
||||
}
|
||||
|
||||
final head = items.length < eager ? items.length : eager;
|
||||
await probeRange(0, head);
|
||||
if (head < items.length) unawaited(probeRange(head, items.length));
|
||||
return items;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -180,35 +220,45 @@ class _DesktopGallerySource implements GallerySource {
|
||||
Directory('$home/Pictures'),
|
||||
Directory('$home/Изображения'),
|
||||
Directory('$home/Images'),
|
||||
Directory('$home/Videos'),
|
||||
Directory('$home/Видео'),
|
||||
Directory('$home/Movies'),
|
||||
];
|
||||
}
|
||||
|
||||
bool _isImage(String path) {
|
||||
final dot = path.lastIndexOf('.');
|
||||
if (dot < 0) return false;
|
||||
return _imageExtensions.contains(path.substring(dot).toLowerCase());
|
||||
bool _isMedia(String path) {
|
||||
final ext = _fileExtension(path);
|
||||
return kGalleryImageExtensions.contains(ext) ||
|
||||
kGalleryVideoExtensions.contains(ext);
|
||||
}
|
||||
}
|
||||
|
||||
class _FileGalleryItem implements GalleryItem {
|
||||
final File file;
|
||||
Duration? _duration;
|
||||
|
||||
_FileGalleryItem(this.file);
|
||||
_FileGalleryItem(this.file, {Duration? duration}) : _duration = duration;
|
||||
|
||||
Future<void> probe() async {
|
||||
if (!isVideo || _duration != null) return;
|
||||
_duration = await DesktopVideoProbe.duration(file.path);
|
||||
}
|
||||
|
||||
@override
|
||||
String get id => file.path;
|
||||
|
||||
@override
|
||||
bool get isVideo => false;
|
||||
bool get isVideo => isVideoPath(file.path);
|
||||
|
||||
@override
|
||||
Duration? get duration => null;
|
||||
Duration? get duration => _duration;
|
||||
|
||||
@override
|
||||
File? get localFile => file;
|
||||
|
||||
@override
|
||||
Future<Uint8List?> thumbnail(int size) async => null;
|
||||
Future<Uint8List?> thumbnail(int size) async =>
|
||||
isVideo ? DesktopVideoProbe.thumbnail(file.path, size) : null;
|
||||
|
||||
@override
|
||||
Future<File?> originFile() async => file;
|
||||
@@ -220,5 +270,7 @@ class _FileGalleryItem implements GalleryItem {
|
||||
}) async => null;
|
||||
|
||||
@override
|
||||
Future<(int, int)?> dimensions() => imageFileDimensions(file);
|
||||
Future<(int, int)?> dimensions() => isVideo
|
||||
? DesktopVideoProbe.dimensions(file.path)
|
||||
: imageFileDimensions(file);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
final Expando<ImageProvider> _providers = Expando<ImageProvider>('preview');
|
||||
|
||||
ImageProvider? dataUriImage(Object owner, String? data) {
|
||||
if (data == null || !data.startsWith('data:')) return null;
|
||||
final cached = _providers[owner];
|
||||
if (cached != null) return cached;
|
||||
final comma = data.indexOf(',');
|
||||
if (comma < 0) return null;
|
||||
try {
|
||||
final provider = MemoryImage(base64Decode(data.substring(comma + 1)));
|
||||
_providers[owner] = provider;
|
||||
return provider;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:komet/core/config/app_frost.dart';
|
||||
import 'package:komet/core/config/app_stories.dart';
|
||||
import 'package:komet/core/utils/haptics.dart';
|
||||
import 'package:komet/frontend/screens/stories/story_owner_info.dart';
|
||||
import 'package:komet/frontend/screens/stories/story_ring.dart';
|
||||
import 'package:komet/frontend/screens/stories/story_viewer_screen.dart';
|
||||
import 'package:komet/frontend/widgets/encryption_lock_badge.dart';
|
||||
import 'package:komet/frontend/widgets/glossy_pill.dart';
|
||||
import 'package:komet/frontend/widgets/online_dot.dart';
|
||||
import 'package:komet/frontend/widgets/profile_hero.dart';
|
||||
import 'package:komet/main.dart' show storiesModule;
|
||||
import 'package:komet/models/story.dart';
|
||||
|
||||
class ChatHeaderRow extends StatelessWidget {
|
||||
final bool glossy;
|
||||
@@ -120,12 +129,11 @@ class ChatHeaderRow extends StatelessWidget {
|
||||
children: [
|
||||
_withOnlineDot(
|
||||
cs,
|
||||
ProfileHeroAvatar(
|
||||
tag: heroTag,
|
||||
size: 44,
|
||||
child: imageUrl.isNotEmpty
|
||||
_heroAvatar(
|
||||
44,
|
||||
(d) => imageUrl.isNotEmpty
|
||||
? CircleAvatar(
|
||||
radius: 22,
|
||||
radius: d / 2,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
imageUrl,
|
||||
maxWidth: 144,
|
||||
@@ -133,13 +141,13 @@ class ChatHeaderRow extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
: CircleAvatar(
|
||||
radius: 22,
|
||||
radius: d / 2,
|
||||
backgroundColor: cs.primaryContainer,
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 16,
|
||||
fontSize: d * 0.36,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
@@ -287,12 +295,11 @@ class ChatHeaderRow extends StatelessWidget {
|
||||
children: [
|
||||
_withOnlineDot(
|
||||
cs,
|
||||
ProfileHeroAvatar(
|
||||
tag: heroTag,
|
||||
size: 36,
|
||||
child: imageUrl.isNotEmpty
|
||||
_heroAvatar(
|
||||
36,
|
||||
(d) => imageUrl.isNotEmpty
|
||||
? CircleAvatar(
|
||||
radius: 18,
|
||||
radius: d / 2,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
imageUrl,
|
||||
maxWidth: 144,
|
||||
@@ -300,13 +307,13 @@ class ChatHeaderRow extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
: CircleAvatar(
|
||||
radius: 18,
|
||||
radius: d / 2,
|
||||
backgroundColor: cs.primaryContainer,
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 12,
|
||||
fontSize: d / 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -395,6 +402,67 @@ class ChatHeaderRow extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
int get _storyOwnerId => chatType == 'DIALOG' ? chatId ^ myId : chatId;
|
||||
|
||||
Widget _heroAvatar(
|
||||
double size,
|
||||
Widget Function(double diameter) avatarBuilder,
|
||||
) {
|
||||
final ownerId = _storyOwnerId;
|
||||
if (!AppStories.current.value || ownerId <= 0) {
|
||||
return ProfileHeroAvatar(
|
||||
tag: heroTag,
|
||||
size: size,
|
||||
child: avatarBuilder(size),
|
||||
);
|
||||
}
|
||||
const gap = 3.0;
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: storiesModule.storiesChanged,
|
||||
builder: (context, _, _) {
|
||||
final preview = storiesModule.previewFor(ownerId);
|
||||
final hasStory = preview != null && !preview.isEmpty;
|
||||
final inner = hasStory ? size - gap * 2 : size;
|
||||
return GestureDetector(
|
||||
behavior: hasStory
|
||||
? HitTestBehavior.opaque
|
||||
: HitTestBehavior.deferToChild,
|
||||
onTap: hasStory ? () => _openStories(context, preview) : null,
|
||||
child: StoryAvatarRing(
|
||||
diameter: inner,
|
||||
total: preview?.totalCount ?? 0,
|
||||
read: preview?.readCount ?? 0,
|
||||
strokeWidth: 2,
|
||||
ringGap: hasStory ? gap : 0,
|
||||
haloWidth: 1.2,
|
||||
child: ProfileHeroAvatar(
|
||||
tag: heroTag,
|
||||
size: inner,
|
||||
child: avatarBuilder(inner),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _openStories(BuildContext context, StoryPreview preview) {
|
||||
Haptics.tap();
|
||||
unawaited(
|
||||
openStoryViewer(
|
||||
context,
|
||||
previews: [preview],
|
||||
origin: storyOriginOf(context),
|
||||
ownerOverrides: {
|
||||
preview.owner.ownerId: StoryOwnerInfo(
|
||||
name: name,
|
||||
avatarUrl: imageUrl.isEmpty ? null : imageUrl,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _withOnlineDot(ColorScheme cs, Widget avatar, {double dotSize = 12}) {
|
||||
final otherId = chatId ^ myId;
|
||||
final showDot = chatType == 'DIALOG' && myId != 0 && otherId > 0;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show lerpDouble;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart' show listEquals;
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:komet/main.dart';
|
||||
@@ -10,11 +15,14 @@ import '../../../backend/modules/messages.dart' show ContactCache;
|
||||
import '../../../core/cache/info_cache.dart';
|
||||
import '../../../core/calls/call_controller.dart';
|
||||
import '../../../core/config/app_show_extra_info.dart';
|
||||
import '../../../core/config/app_stories.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
import '../../../core/utils/haptics.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../models/chat_info.dart';
|
||||
import '../../../models/contact_info.dart';
|
||||
import '../../../models/story.dart';
|
||||
import '../../widgets/animated_text_swap.dart';
|
||||
import '../../widgets/avatar_history_screen.dart';
|
||||
import '../../widgets/chat_info/shared_content_tabs.dart';
|
||||
@@ -24,11 +32,16 @@ import '../../widgets/formatted_message_text.dart';
|
||||
import '../../widgets/reload_on_reconnect.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
import '../../widgets/profile_header_scroll.dart';
|
||||
import '../../widgets/profile_hero.dart';
|
||||
import '../../widgets/swipe_route.dart';
|
||||
import '../../../backend/modules/chats.dart';
|
||||
import '../calls/call_screen.dart';
|
||||
import '../contacts/open_contact_profile.dart';
|
||||
import '../stories/story_owner_info.dart';
|
||||
import '../stories/story_peanut.dart';
|
||||
import '../stories/story_ring.dart';
|
||||
import '../stories/story_viewer_screen.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'group_invite_sheets.dart';
|
||||
import 'profile_action_sheets.dart';
|
||||
@@ -98,7 +111,7 @@ class ChatInfoScreen extends StatefulWidget {
|
||||
class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
with ReloadOnReconnect {
|
||||
final _tabScrollController = ScrollController();
|
||||
final _bodyScrollController = ScrollController();
|
||||
ScrollController? _bodyScrollController;
|
||||
|
||||
int _myId = 0;
|
||||
bool _isLoading = true;
|
||||
@@ -136,17 +149,37 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
bool _muteBusy = false;
|
||||
bool _addContactBusy = false;
|
||||
|
||||
StoryPreview? _storyPreview;
|
||||
List<Story> _unreadStories = const [];
|
||||
final GlobalKey _avatarKey = GlobalKey();
|
||||
|
||||
final PageController _avatarPageController = PageController();
|
||||
List<String> _avatarPages = const [];
|
||||
int _avatarIndex = 0;
|
||||
int _avatarTotal = 0;
|
||||
|
||||
double _headerDelta = 0;
|
||||
bool _expandArmed = false;
|
||||
bool _headerEverExpanded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bodyScrollController.addListener(_onBodyScroll);
|
||||
storiesModule.storiesChanged.addListener(_onStoriesChanged);
|
||||
_load();
|
||||
}
|
||||
|
||||
void _onStoriesChanged() {
|
||||
if (!mounted) return;
|
||||
setState(_refreshUnreadStories);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
storiesModule.storiesChanged.removeListener(_onStoriesChanged);
|
||||
_tabScrollController.dispose();
|
||||
_bodyScrollController.dispose();
|
||||
_bodyScrollController?.dispose();
|
||||
_avatarPageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -261,6 +294,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
|
||||
if (!_isBot && _otherId != _myId) _loadBlockedState(_otherId!);
|
||||
if (!_isBot) unawaited(_loadStories(_otherId!));
|
||||
unawaited(_loadAvatarHistory(_otherId!));
|
||||
}
|
||||
} else if (info == null) {
|
||||
setState(() => _isLoading = false);
|
||||
@@ -394,13 +429,18 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
|
||||
var added = 0;
|
||||
final fresh = <int>[];
|
||||
for (final e in page.members) {
|
||||
if (_seenMemberIds.add(e.id)) {
|
||||
_addMember(_memberFrom(e));
|
||||
fresh.add(e.id);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
if (added > 0) _rebuildMembers();
|
||||
if (fresh.isNotEmpty && AppStories.current.value) {
|
||||
unawaited(storiesModule.loadOwnersPreviews(fresh));
|
||||
}
|
||||
|
||||
final total = _chatInfo?.participantsCount;
|
||||
if (page.members.isEmpty ||
|
||||
@@ -419,7 +459,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
if (_membersLoading || _membersEnd) return;
|
||||
if (_selectedTab != AppLocalizations.of(context)!.chatInfoTabMembers)
|
||||
return;
|
||||
final pos = _bodyScrollController.position;
|
||||
final controller = _bodyScrollController;
|
||||
if (controller == null || !controller.hasClients) return;
|
||||
final pos = controller.position;
|
||||
if (pos.pixels >= pos.maxScrollExtent - 400) {
|
||||
_fetchMembersPage();
|
||||
}
|
||||
@@ -473,26 +515,430 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
backgroundColor: cs.surface,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.startFloat,
|
||||
floatingActionButton: const ConnectionSpinner(),
|
||||
body: SafeArea(child: _buildScrollBody(cs)),
|
||||
body: _buildScrollBody(cs),
|
||||
);
|
||||
}
|
||||
|
||||
static const double _headerAvatarSize = 96;
|
||||
static const double _headerCollapsedBody = 232;
|
||||
static const double _headerVignette = 64;
|
||||
|
||||
bool get _headerHasPhoto => widget.imageUrl.isNotEmpty && !_peerDeleted;
|
||||
|
||||
Widget _buildScrollBody(ColorScheme cs) {
|
||||
return CustomScrollView(
|
||||
controller: _bodyScrollController,
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
floating: true,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: cs.onSurface),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
return LayoutBuilder(
|
||||
builder: (context, viewport) {
|
||||
final media = MediaQuery.of(context);
|
||||
final topPad = media.padding.top;
|
||||
final collapsedH = topPad + _headerCollapsedBody;
|
||||
final expandedH = _headerHasPhoto
|
||||
? math.max(
|
||||
collapsedH,
|
||||
math.min(media.size.width, viewport.maxHeight * 0.62),
|
||||
)
|
||||
: collapsedH;
|
||||
final delta = expandedH - collapsedH;
|
||||
_syncHeaderDelta(delta);
|
||||
final controller = _bodyScrollController ??=
|
||||
(ScrollController(initialScrollOffset: delta)
|
||||
..addListener(_onBodyScroll));
|
||||
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (n) => _onHeaderScrollNotification(n, delta),
|
||||
child: CustomScrollView(
|
||||
key: ValueKey(delta),
|
||||
controller: controller,
|
||||
physics: HeaderPullScrollPhysics(
|
||||
delta: delta,
|
||||
isArmed: () => _expandArmed,
|
||||
parent: const BouncingScrollPhysics(),
|
||||
),
|
||||
slivers: [
|
||||
SliverPersistentHeader(
|
||||
delegate: MorphHeaderDelegate(
|
||||
collapsedExtent: collapsedH,
|
||||
expandedExtent: expandedH,
|
||||
headerBuilder: (ctx, t) =>
|
||||
_buildMorphHeader(ctx, cs, _headerHasPhoto ? t : 0.0),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: math.max(0, viewport.maxHeight - collapsedH),
|
||||
),
|
||||
child: _buildBody(cs),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _syncHeaderDelta(double delta) {
|
||||
if (_headerDelta == delta) return;
|
||||
final prev = _headerDelta;
|
||||
_headerDelta = delta;
|
||||
if (_bodyScrollController == null) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final c = _bodyScrollController;
|
||||
if (!mounted || c == null || !c.hasClients) return;
|
||||
final target = (c.offset + (delta - prev)).clamp(
|
||||
0.0,
|
||||
c.position.maxScrollExtent,
|
||||
);
|
||||
c.jumpTo(target);
|
||||
});
|
||||
}
|
||||
|
||||
bool _onHeaderScrollNotification(ScrollNotification n, double delta) {
|
||||
if (n.depth != 0) return false;
|
||||
if (n is ScrollStartNotification) {
|
||||
if (n.dragDetails != null) {
|
||||
_expandArmed = delta > 0 && n.metrics.pixels <= delta + 8;
|
||||
}
|
||||
} else if (n is ScrollEndNotification) {
|
||||
_snapHeader(delta);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _snapHeader(double delta) {
|
||||
final c = _bodyScrollController;
|
||||
if (c == null || !c.hasClients || delta <= 0) return;
|
||||
final offset = c.offset;
|
||||
if (offset <= 0 || offset >= delta) return;
|
||||
final target = (offset < delta / 2 ? 0.0 : delta).clamp(
|
||||
0.0,
|
||||
c.position.maxScrollExtent,
|
||||
);
|
||||
if ((target - offset).abs() < 1) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !c.hasClients) return;
|
||||
c.animateTo(
|
||||
target,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildMorphHeader(BuildContext context, ColorScheme cs, double t) {
|
||||
final topPad = MediaQuery.paddingOf(context).top;
|
||||
if (t > 0) _headerEverExpanded = true;
|
||||
final iconColor = Color.lerp(cs.onSurface, Colors.white, t)!;
|
||||
final nameColor = Color.lerp(cs.onSurface, Colors.white, t)!;
|
||||
final subColor = Color.lerp(
|
||||
cs.onSurfaceVariant,
|
||||
Colors.white.withValues(alpha: 0.85),
|
||||
t,
|
||||
)!;
|
||||
final ringOpacity = (1 - t * 3).clamp(0.0, 1.0);
|
||||
final chipOpacity = ((t - 0.3) / 0.5).clamp(0.0, 1.0);
|
||||
final unread = _unreadStories;
|
||||
final totalPhotos = math.max(_avatarTotal, _avatarPages.length);
|
||||
|
||||
return ClipRect(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final w = constraints.maxWidth;
|
||||
final h = constraints.maxHeight;
|
||||
const size = _headerAvatarSize;
|
||||
final avatarRect = Rect.lerp(
|
||||
Rect.fromLTWH((w - size) / 2, topPad + 52, size, size),
|
||||
Rect.fromLTWH(0, 0, w, h),
|
||||
t,
|
||||
)!;
|
||||
final radius = lerpDouble(size / 2, 0, t)!;
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
Positioned.fromRect(
|
||||
rect: avatarRect,
|
||||
child: _headerAvatar(cs, radius, t),
|
||||
),
|
||||
if (_headerHasPhoto) ...[
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: topPad + 72,
|
||||
child: IgnorePointer(
|
||||
child: Opacity(
|
||||
opacity: t,
|
||||
child: const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.black45, Colors.transparent],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 170,
|
||||
child: IgnorePointer(
|
||||
child: Opacity(
|
||||
opacity: t,
|
||||
child: const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black54],
|
||||
stops: [0.0, 0.62],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: _headerVignette,
|
||||
child: IgnorePointer(
|
||||
child: Opacity(
|
||||
opacity: t,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
cs.surface.withValues(alpha: 0),
|
||||
cs.surface.withValues(alpha: 0.55),
|
||||
cs.surface,
|
||||
],
|
||||
stops: const [0.0, 0.55, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
Positioned.fromRect(
|
||||
rect: avatarRect.inflate(7 * (1 - t)),
|
||||
child: IgnorePointer(
|
||||
child: Opacity(
|
||||
opacity: ringOpacity,
|
||||
child: CustomPaint(
|
||||
painter: _storyPreview == null
|
||||
? null
|
||||
: SegmentedRingPainter(
|
||||
total: _storyPreview!.totalCount,
|
||||
read: _storyPreview!.readCount,
|
||||
unreadColors: [cs.primary, cs.tertiary, cs.primary],
|
||||
readColor: cs.outlineVariant,
|
||||
strokeWidth: 3.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 4,
|
||||
right: 4,
|
||||
top: topPad + 4,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: iconColor),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
Expanded(
|
||||
child: chipOpacity > 0 && unread.isNotEmpty
|
||||
? Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Opacity(
|
||||
opacity: chipOpacity,
|
||||
child: _storyChip(unread),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
if (chipOpacity > 0 && totalPhotos > 1)
|
||||
Opacity(
|
||||
opacity: chipOpacity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Text(
|
||||
'${_avatarIndex + 1}/$totalPhotos',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildMoreButton(cs, iconColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: lerpDouble(16, 18, t)!,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_headerAligned(t, _buildNameRow(cs, nameColor, t)),
|
||||
const SizedBox(height: 2),
|
||||
_headerAligned(
|
||||
t,
|
||||
SelectionArea(
|
||||
child: Text(
|
||||
_subtitle(),
|
||||
style: TextStyle(color: subColor, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _headerAligned(double t, Widget child) {
|
||||
return Align(
|
||||
alignment: Alignment.lerp(Alignment.center, Alignment.centerLeft, t)!,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: lerpDouble(12, 18, t)!),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _headerAvatar(ColorScheme cs, double radius, double t) {
|
||||
final expanded = t > 0.5;
|
||||
final openHistory = _headerHasPhoto
|
||||
? () => AvatarHistoryScreen.open(
|
||||
context,
|
||||
contactId: _otherId ?? widget.dialogPeerId ?? 0,
|
||||
name: widget.name,
|
||||
currentAvatarUrl: _avatarPages.isEmpty
|
||||
? widget.imageUrl
|
||||
: _avatarPages[_avatarIndex.clamp(0, _avatarPages.length - 1)],
|
||||
)
|
||||
: null;
|
||||
final openStories = _storyPreview == null ? null : _openStories;
|
||||
|
||||
return KeyedSubtree(
|
||||
key: _avatarKey,
|
||||
child: ProfileHeroAvatar(
|
||||
tag: widget.heroTag,
|
||||
size: _headerAvatarSize,
|
||||
child: GestureDetector(
|
||||
onTap: expanded ? openHistory : (openStories ?? openHistory),
|
||||
onLongPress: expanded ? null : (openStories == null ? null : openHistory),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: _headerAvatarContent(cs, t),
|
||||
),
|
||||
actions: [_buildMoreButton(cs)],
|
||||
),
|
||||
SliverToBoxAdapter(child: _buildBody(cs)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _headerAvatarContent(ColorScheme cs, double t) {
|
||||
if (_peerDeleted) {
|
||||
return _ghostAvatar(radius: _headerAvatarSize / 2, fontSize: 52);
|
||||
}
|
||||
final pages = _avatarPages.isNotEmpty
|
||||
? _avatarPages
|
||||
: (widget.imageUrl.isEmpty ? const <String>[] : [widget.imageUrl]);
|
||||
if (pages.isEmpty) {
|
||||
return KometAvatar(
|
||||
name: widget.name,
|
||||
size: _headerAvatarSize,
|
||||
fontSize: 36,
|
||||
fadeIn: false,
|
||||
);
|
||||
}
|
||||
return PageView.builder(
|
||||
controller: _avatarPageController,
|
||||
itemCount: pages.length,
|
||||
physics: t > 0.99
|
||||
? const PageScrollPhysics()
|
||||
: const NeverScrollableScrollPhysics(),
|
||||
onPageChanged: (i) => setState(() => _avatarIndex = i),
|
||||
itemBuilder: (_, i) => CachedNetworkImage(
|
||||
imageUrl: pages[i],
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: _headerEverExpanded ? 720 : 288,
|
||||
fadeInDuration: const Duration(milliseconds: 150),
|
||||
errorWidget: (_, _, _) => ColoredBox(
|
||||
color: cs.surfaceContainerHigh,
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 32),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _refreshUnreadStories() {
|
||||
final preview = _storyPreview;
|
||||
if (preview == null || preview.unreadCount <= 0) {
|
||||
_unreadStories = const [];
|
||||
return;
|
||||
}
|
||||
final stories = storiesModule.cachedStories(preview.owner.ownerId);
|
||||
if (stories == null || stories.isEmpty) {
|
||||
_unreadStories = const [];
|
||||
return;
|
||||
}
|
||||
final from = (stories.length - preview.unreadCount).clamp(
|
||||
0,
|
||||
stories.length,
|
||||
);
|
||||
_unreadStories = stories.sublist(from);
|
||||
}
|
||||
|
||||
Widget _storyChip(List<Story> unread) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _openStories,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
StoryPeanut(stories: unread),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${unread.length} '
|
||||
'${pluralRu(unread.length, 'история', 'истории', 'историй')}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Outfit',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -502,29 +948,16 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
_avatar(),
|
||||
const SizedBox(height: 14),
|
||||
_buildNameRow(cs),
|
||||
const SizedBox(height: 4),
|
||||
if (_isLoading)
|
||||
..._loadingBlocks(cs)
|
||||
else ...[
|
||||
SelectionArea(
|
||||
child: Text(
|
||||
_subtitle(),
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildActions(cs),
|
||||
const SizedBox(height: 16),
|
||||
_buildPersistentInfo(cs),
|
||||
_buildTabBar(cs),
|
||||
const SizedBox(height: 12),
|
||||
_buildTabContent(cs),
|
||||
const SizedBox(height: 40),
|
||||
SizedBox(height: 40 + MediaQuery.paddingOf(context).bottom),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -553,16 +986,17 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
return widget.name;
|
||||
}
|
||||
|
||||
Widget _buildMoreButton(ColorScheme cs) {
|
||||
Widget _buildMoreButton(ColorScheme cs, [Color? iconColor]) {
|
||||
final entries = _moreMenuEntries();
|
||||
final color = iconColor ?? cs.onSurface;
|
||||
if (entries.isEmpty) {
|
||||
return IconButton(
|
||||
icon: Icon(Icons.more_vert, color: cs.onSurface),
|
||||
icon: Icon(Icons.more_vert, color: color),
|
||||
onPressed: null,
|
||||
);
|
||||
}
|
||||
return PopupMenuButton<VoidCallback>(
|
||||
icon: Icon(Icons.more_vert, color: cs.onSurface),
|
||||
icon: Icon(Icons.more_vert, color: color),
|
||||
onSelected: (action) => action(),
|
||||
itemBuilder: (_) => [
|
||||
for (final entry in entries)
|
||||
@@ -675,10 +1109,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget _buildNameRow(ColorScheme cs) {
|
||||
Widget _buildNameRow(ColorScheme cs, Color textColor, double t) {
|
||||
final nameStyle = TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 22,
|
||||
color: textColor,
|
||||
fontSize: lerpDouble(22, 25, t)!,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: 'Outfit',
|
||||
);
|
||||
@@ -687,9 +1121,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
final hasToggle = _isContact && real != null && real != custom;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(width: 36),
|
||||
SizedBox(width: (hasToggle ? 30.0 : 0.0) * (1 - t)),
|
||||
Flexible(
|
||||
child: SelectionArea(
|
||||
child: ProfileHeroName(
|
||||
@@ -703,24 +1138,34 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
real ?? custom,
|
||||
style: nameStyle,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child: Text(
|
||||
custom,
|
||||
style: nameStyle,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 36,
|
||||
width: hasToggle ? 30 : 0,
|
||||
height: 28,
|
||||
child: hasToggle
|
||||
? IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 28,
|
||||
minHeight: 28,
|
||||
),
|
||||
iconSize: 20,
|
||||
color: _showRealName ? cs.primary : cs.onSurfaceVariant,
|
||||
color: _showRealName
|
||||
? Color.lerp(cs.primary, Colors.white, t)
|
||||
: textColor.withValues(alpha: 0.7),
|
||||
icon: Icon(
|
||||
_showRealName ? Symbols.visibility : Symbols.visibility_off,
|
||||
),
|
||||
@@ -1183,7 +1628,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
items.add(_simpleInfoCard(cs, l10n.chatInfoBio, bio));
|
||||
}
|
||||
}
|
||||
} else if (widget.chatType == 'CHANNEL') {
|
||||
} else {
|
||||
final link = _chatInfo?.link;
|
||||
if (link != null && link.isNotEmpty) {
|
||||
items.add(_linkCard(cs, link));
|
||||
@@ -1520,15 +1965,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
Widget _buildInfoTabContent(ColorScheme cs) {
|
||||
final items = <Widget>[];
|
||||
|
||||
if (widget.chatType == 'CHAT') {
|
||||
final desc = _chatInfo?.description;
|
||||
if (desc != null && desc.isNotEmpty) {
|
||||
items
|
||||
..add(_infoCard(cs, l10n.contactProfileInfoDescription, desc))
|
||||
..add(const SizedBox(height: 8));
|
||||
}
|
||||
}
|
||||
|
||||
items.add(_buildInfoRowsCard(cs));
|
||||
|
||||
return SelectionArea(
|
||||
@@ -1549,36 +1985,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoCard(ColorScheme cs, String label, String value) {
|
||||
return GlossyPill(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 14),
|
||||
depth: 6,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMembersTabContent(ColorScheme cs) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -1699,6 +2105,11 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
? l10n.chatInfoRoleOwner
|
||||
: (member.isAdmin ? l10n.chatInfoRoleAdmin : null);
|
||||
|
||||
final story = member.blocked || !AppStories.current.value
|
||||
? null
|
||||
: storiesModule.previewOf(member.id);
|
||||
final avatarRadius = story == null ? 22.0 : 19.0;
|
||||
|
||||
return InkWell(
|
||||
onTap: member.isMe
|
||||
? null
|
||||
@@ -1714,24 +2125,13 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
children: [
|
||||
if (member.blocked)
|
||||
_ghostAvatar()
|
||||
else if (avatar != null && avatar.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
avatar,
|
||||
maxWidth: 144,
|
||||
maxHeight: 144,
|
||||
),
|
||||
backgroundColor: cs.primaryContainer,
|
||||
)
|
||||
else
|
||||
CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: cs.primaryContainer,
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(color: cs.onPrimaryContainer, fontSize: 16),
|
||||
),
|
||||
_memberAvatar(
|
||||
cs,
|
||||
story: story,
|
||||
radius: avatarRadius,
|
||||
name: name,
|
||||
avatarUrl: avatar,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
@@ -1766,6 +2166,71 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _memberAvatar(
|
||||
ColorScheme cs, {
|
||||
required StoryPreview? story,
|
||||
required double radius,
|
||||
required String name,
|
||||
String? avatarUrl,
|
||||
}) {
|
||||
final circle = (avatarUrl != null && avatarUrl.isNotEmpty)
|
||||
? CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
avatarUrl,
|
||||
maxWidth: 144,
|
||||
maxHeight: 144,
|
||||
),
|
||||
backgroundColor: cs.primaryContainer,
|
||||
)
|
||||
: CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: cs.primaryContainer,
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: radius * 0.72,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (story == null) return circle;
|
||||
return Builder(
|
||||
builder: (avatarContext) => GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _openMemberStories(avatarContext, story, name, avatarUrl),
|
||||
child: StoryAvatarRing(
|
||||
diameter: radius * 2,
|
||||
total: story.totalCount,
|
||||
read: story.readCount,
|
||||
strokeWidth: 2.2,
|
||||
ringGap: 3,
|
||||
haloWidth: 1.5,
|
||||
child: circle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openMemberStories(
|
||||
BuildContext avatarContext,
|
||||
StoryPreview story,
|
||||
String name,
|
||||
String? avatarUrl,
|
||||
) {
|
||||
Haptics.tap();
|
||||
unawaited(
|
||||
openStoryViewer(
|
||||
context,
|
||||
previews: [story],
|
||||
origin: storyOriginOf(avatarContext),
|
||||
ownerOverrides: {
|
||||
story.owner.ownerId: StoryOwnerInfo(name: name, avatarUrl: avatarUrl),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _ghostAvatar({double radius = 22, double fontSize = 24}) {
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
@@ -2023,34 +2488,66 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _avatar() {
|
||||
const size = 96.0;
|
||||
final peerId = widget.chatType == 'DIALOG' ? _otherId : null;
|
||||
final hasHistory =
|
||||
peerId != null && widget.imageUrl.isNotEmpty && !_peerDeleted;
|
||||
return ProfileHeroAvatar(
|
||||
tag: widget.heroTag,
|
||||
size: size,
|
||||
child: GestureDetector(
|
||||
onTap: hasHistory
|
||||
? () => AvatarHistoryScreen.open(
|
||||
context,
|
||||
contactId: peerId,
|
||||
name: widget.name,
|
||||
currentAvatarUrl: widget.imageUrl,
|
||||
)
|
||||
: null,
|
||||
child: _peerDeleted
|
||||
? _ghostAvatar(radius: size / 2, fontSize: 52)
|
||||
: KometAvatar(
|
||||
name: widget.name,
|
||||
imageUrl: widget.imageUrl,
|
||||
size: size,
|
||||
fontSize: 36,
|
||||
fadeIn: false,
|
||||
),
|
||||
),
|
||||
Future<void> _loadAvatarHistory(int peerId) async {
|
||||
if (!_headerHasPhoto) return;
|
||||
final cached = ContactsModule.cachedPhotos(peerId);
|
||||
if (cached != null) _applyAvatarPhotos(cached);
|
||||
final photos = await ContactsModule.fetchPhotos(api, peerId, count: 30);
|
||||
if (!mounted) return;
|
||||
_applyAvatarPhotos(photos);
|
||||
}
|
||||
|
||||
void _applyAvatarPhotos(ContactPhotos photos) {
|
||||
final urls = <String>[];
|
||||
if (widget.imageUrl.isNotEmpty) urls.add(widget.imageUrl);
|
||||
for (final url in photos.urls) {
|
||||
if (url.isNotEmpty && !urls.contains(url)) urls.add(url);
|
||||
}
|
||||
if (urls.isEmpty || listEquals(urls, _avatarPages)) return;
|
||||
setState(() {
|
||||
_avatarPages = urls;
|
||||
_avatarTotal = math.max(photos.total, urls.length);
|
||||
_avatarIndex = _avatarIndex.clamp(0, urls.length - 1);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadStories(int peerId) async {
|
||||
if (!AppStories.current.value || _peerDeleted) return;
|
||||
final cached = storiesModule.previewOf(peerId);
|
||||
if (cached != null && !cached.isEmpty && mounted) {
|
||||
setState(() {
|
||||
_storyPreview = cached;
|
||||
_refreshUnreadStories();
|
||||
});
|
||||
}
|
||||
final fresh = await storiesModule.loadOwnerPreview(
|
||||
StoryOwner(ownerId: peerId),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_storyPreview = (fresh == null || fresh.isEmpty) ? null : fresh;
|
||||
_refreshUnreadStories();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openStories() async {
|
||||
final preview = _storyPreview;
|
||||
if (preview == null) return;
|
||||
Haptics.tap();
|
||||
final avatarContext = _avatarKey.currentContext;
|
||||
await openStoryViewer(
|
||||
context,
|
||||
previews: [preview],
|
||||
origin: avatarContext == null ? null : storyOriginOf(avatarContext),
|
||||
ownerOverrides: {
|
||||
preview.owner.ownerId: StoryOwnerInfo(
|
||||
name: _customName,
|
||||
avatarUrl: _contactData?.avatarUrl ?? widget.imageUrl,
|
||||
),
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
await _loadStories(preview.owner.ownerId);
|
||||
}
|
||||
|
||||
List<Widget> _loadingBlocks(ColorScheme cs) {
|
||||
@@ -2064,10 +2561,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
);
|
||||
|
||||
return [
|
||||
const SizedBox(height: 4),
|
||||
block(110, 16, r: 6),
|
||||
const SizedBox(height: 24),
|
||||
block(240, 54, r: 14),
|
||||
block(double.infinity, 60, r: 14),
|
||||
const SizedBox(height: 16),
|
||||
block(double.infinity, 36, r: 20),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@@ -80,7 +80,11 @@ import '../../widgets/spectrum_tint.dart';
|
||||
import '../../widgets/update_dialog.dart';
|
||||
import '../stories/story_composer_screen.dart';
|
||||
import '../stories/story_owner_info.dart';
|
||||
import '../../../backend/modules/webapp.dart';
|
||||
import '../../../models/story.dart';
|
||||
import '../webapp/open_mini_app.dart';
|
||||
import '../stories/story_ring.dart';
|
||||
import '../../widgets/sending_clock_icon.dart';
|
||||
import '../stories/story_viewer_screen.dart';
|
||||
import '../downloads_screen.dart';
|
||||
|
||||
@@ -291,6 +295,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
_storiesDockedOpen,
|
||||
_storiesAnimClosing,
|
||||
_storiesOverscrollRevealArmed,
|
||||
storiesModule.storiesChanged.value,
|
||||
_sessionState,
|
||||
identityHashCode(_profile),
|
||||
]);
|
||||
@@ -730,6 +735,21 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
};
|
||||
}
|
||||
|
||||
StoryPreview? _storyPreviewFor(int ownerId) {
|
||||
if (!AppStories.current.value || ownerId == 0) return null;
|
||||
final preview = storiesModule.previewFor(ownerId);
|
||||
return (preview == null || preview.isEmpty) ? null : preview;
|
||||
}
|
||||
|
||||
void _openStoriesForOwner(int ownerId, [Offset? origin]) {
|
||||
final index = storiesModule.previews.indexWhere(
|
||||
(p) => p.owner.ownerId == ownerId,
|
||||
);
|
||||
if (index < 0) return;
|
||||
Haptics.tap();
|
||||
_openStories(index, origin);
|
||||
}
|
||||
|
||||
void _openStories(int index, [Offset? origin]) {
|
||||
final previews = storiesModule.previews;
|
||||
if (previews.isEmpty) return;
|
||||
@@ -1849,6 +1869,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
previewCipherText: isPlaceholder
|
||||
? null
|
||||
: chat.lastMsgTextOneLine,
|
||||
hasMiniApp: _hasMiniApp(secondId, chat),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -2656,6 +2677,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
}
|
||||
|
||||
Widget _ownStatusIcon(ColorScheme cs, String status, bool read) {
|
||||
if (isSendingStatus(status)) {
|
||||
return SendingClockIcon(color: cs.outline, size: 14);
|
||||
}
|
||||
IconData icon;
|
||||
Color color;
|
||||
switch (status) {
|
||||
@@ -2791,6 +2815,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
int? previewMessageId,
|
||||
String previewPrefix = '',
|
||||
String? previewCipherText,
|
||||
bool hasMiniApp = false,
|
||||
}) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isSelected = _selectedChats.contains(id);
|
||||
@@ -2839,8 +2864,16 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
)
|
||||
: _buildPreviewLine(cs, message, messageRanges, draft, messageItalic);
|
||||
|
||||
final Widget avatarCircle = CircleAvatar(
|
||||
radius: 24,
|
||||
final storyOwnerId = chatType == 'DIALOG'
|
||||
? presenceUserId
|
||||
: (int.tryParse(id) ?? 0);
|
||||
final story = (_isSelectionMode || widget.forwardMode)
|
||||
? null
|
||||
: _storyPreviewFor(storyOwnerId);
|
||||
final avatarRadius = story == null ? 24.0 : 20.0;
|
||||
|
||||
final CircleAvatar rawAvatar = CircleAvatar(
|
||||
radius: avatarRadius,
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
backgroundImage: imageUrl.isNotEmpty
|
||||
? CachedNetworkImageProvider(
|
||||
@@ -2852,10 +2885,34 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
child: imageUrl.isEmpty
|
||||
? Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 20),
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: story == null ? 20 : 17,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
|
||||
final Widget avatarCircle = story == null
|
||||
? rawAvatar
|
||||
: Builder(
|
||||
builder: (avatarContext) => GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _openStoriesForOwner(
|
||||
storyOwnerId,
|
||||
storyOriginOf(avatarContext),
|
||||
),
|
||||
child: StoryAvatarRing(
|
||||
diameter: avatarRadius * 2,
|
||||
total: story.totalCount,
|
||||
read: story.readCount,
|
||||
strokeWidth: 2.2,
|
||||
ringGap: 4,
|
||||
haloWidth: 1.5,
|
||||
child: rawAvatar,
|
||||
),
|
||||
),
|
||||
);
|
||||
return SpringyTap(
|
||||
key: ValueKey('chat_$id'),
|
||||
child: InkWell(
|
||||
@@ -3060,6 +3117,15 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
size: 16,
|
||||
weight: 400,
|
||||
),
|
||||
if (hasMiniApp) ...[
|
||||
const SizedBox(width: 8),
|
||||
_miniAppButton(
|
||||
cs,
|
||||
botId: presenceUserId,
|
||||
chatId: int.tryParse(id) ?? 0,
|
||||
name: name,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -3075,6 +3141,42 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
);
|
||||
}
|
||||
|
||||
bool _hasMiniApp(int contactId, CachedChat chat) {
|
||||
if (contactId == 0 || widget.forwardMode || _isSelectionMode) return false;
|
||||
final options = ContactCache.getOptions(contactId);
|
||||
if (options != null && options.any(kMiniAppOptions.contains)) return true;
|
||||
return chat.options.any(kMiniAppOptions.contains);
|
||||
}
|
||||
|
||||
Widget _miniAppButton(
|
||||
ColorScheme cs, {
|
||||
required int botId,
|
||||
required int chatId,
|
||||
required String name,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => unawaited(
|
||||
openMiniApp(context, botId: botId, chatId: chatId, title: name),
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.miniAppOpen,
|
||||
style: TextStyle(
|
||||
color: cs.onPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openAccountSwitcher(Offset point) {
|
||||
Haptics.medium();
|
||||
final controller = AccountSwitcherController()..attach(point);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert' show base64Encode;
|
||||
import 'dart:io' show File;
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
@@ -13,8 +14,12 @@ import 'package:komet/backend/modules/chats.dart';
|
||||
import 'package:komet/backend/modules/comments.dart';
|
||||
import 'package:komet/backend/modules/file_uploader.dart';
|
||||
import 'package:komet/backend/modules/upload_notification_service.dart';
|
||||
import 'package:komet/backend/modules/media_send.dart';
|
||||
import 'package:komet/backend/modules/webapp.dart';
|
||||
import 'package:komet/frontend/screens/webapp/open_mini_app.dart';
|
||||
import 'package:komet/frontend/widgets/sending_clock_icon.dart';
|
||||
import 'package:komet/core/media/desktop_video_probe.dart';
|
||||
import 'package:komet/core/media/gallery_source.dart';
|
||||
import 'package:komet/core/media/image_optimizer.dart';
|
||||
import 'package:komet/core/utils/format.dart';
|
||||
import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
|
||||
import 'package:komet/frontend/screens/contacts/open_contact_profile.dart';
|
||||
@@ -345,8 +350,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
formatElapsed: formatVoiceElapsed,
|
||||
);
|
||||
|
||||
StreamSubscription<MediaSendEvent>? _mediaSendSub;
|
||||
|
||||
ValueListenable<List<double>>? _photoProgressFor(CachedMessage m) =>
|
||||
_photoUploadProgress[m.id];
|
||||
_photoUploadProgress[m.id] ?? MediaSendService.instance.progressFor(m.id);
|
||||
|
||||
ValueNotifier<Map<String, dynamic>?> _reactionNotifierFor(CachedMessage m) {
|
||||
final existing = _reactionNotifiers[m.id];
|
||||
@@ -654,6 +661,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
.catchError((_) {}),
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_mediaSendSub = MediaSendService.instance.events.listen(_onMediaSendEvent);
|
||||
chats.chatsChanged.addListener(_onChatsBump);
|
||||
_messageController.addListener(_onTextChanged);
|
||||
_scrollController.addListener(_onScrollForDate);
|
||||
@@ -865,6 +873,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_hasMoreHistory = !cached.reachedStart;
|
||||
_messagesRev.value++;
|
||||
});
|
||||
_mergePendingMedia();
|
||||
_syncReactionNotifiersFromMessages();
|
||||
_requestCommentCounts();
|
||||
_revealOrHoldInitial();
|
||||
@@ -886,6 +895,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_messages = first;
|
||||
_messagesRev.value++;
|
||||
});
|
||||
_mergePendingMedia();
|
||||
_requestCommentCounts();
|
||||
_revealOrHoldInitial();
|
||||
}
|
||||
@@ -1999,6 +2009,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
unawaited(chats.subscribeChat(api, widget.chatId, subscribe: false));
|
||||
}
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_mediaSendSub?.cancel();
|
||||
chats.chatsChanged.removeListener(_onChatsBump);
|
||||
_otherUnread.dispose();
|
||||
_animojiHold.dispose();
|
||||
@@ -3194,6 +3205,25 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
);
|
||||
}
|
||||
|
||||
bool get _hasMiniApp {
|
||||
if (widget.chatType != 'DIALOG' || _commentsMode) return false;
|
||||
final peerId = _resolveOtherId();
|
||||
if (peerId == null) return false;
|
||||
if (hasMiniAppOption(ContactCache.getOptions(peerId))) return true;
|
||||
return hasMiniAppOption(chat?.options);
|
||||
}
|
||||
|
||||
Future<void> _openMiniApp() async {
|
||||
final peerId = _resolveOtherId();
|
||||
if (peerId == null) return;
|
||||
await openMiniApp(
|
||||
context,
|
||||
botId: peerId,
|
||||
chatId: widget.chatId,
|
||||
title: _headerName(),
|
||||
);
|
||||
}
|
||||
|
||||
void _openChatMenu(BuildContext btnContext) {
|
||||
final box = btnContext.findRenderObject() as RenderBox?;
|
||||
if (box == null || !box.hasSize) return;
|
||||
@@ -3202,6 +3232,13 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
context: context,
|
||||
anchorRect: anchorRect,
|
||||
items: [
|
||||
if (_hasMiniApp)
|
||||
ChatMenuItem(
|
||||
icon: Symbols.apps,
|
||||
label: AppLocalizations.of(context)!.miniAppOpen,
|
||||
dividerAfter: true,
|
||||
onTap: () => unawaited(_openMiniApp()),
|
||||
),
|
||||
ChatMenuItem(
|
||||
icon: (chat?.isMuted ?? false)
|
||||
? Symbols.volume_off
|
||||
@@ -6150,75 +6187,33 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (jobs.isEmpty || !mounted) return;
|
||||
|
||||
final tempId = _nextTempId();
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final progress = ValueNotifier<List<double>>(
|
||||
List<double>.filled(jobs.length, 0),
|
||||
final placeholder = CachedMessage(
|
||||
id: tempId,
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
senderId: _myId,
|
||||
text: caption.isEmpty ? null : caption,
|
||||
time: DateTime.now().millisecondsSinceEpoch,
|
||||
status: 'sending',
|
||||
attachments: attachments,
|
||||
);
|
||||
_photoUploadProgress[tempId] = progress;
|
||||
|
||||
_messages.add(
|
||||
CachedMessage(
|
||||
id: tempId,
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
senderId: _myId,
|
||||
text: caption.isEmpty ? null : caption,
|
||||
time: now,
|
||||
status: 'sending',
|
||||
attachments: attachments,
|
||||
),
|
||||
);
|
||||
_messages.add(placeholder);
|
||||
_lastSentId = tempId;
|
||||
_bumpMessages();
|
||||
Haptics.send();
|
||||
_scrollToBottom();
|
||||
|
||||
try {
|
||||
final tokens = await _uploadPhotos(jobs, progress);
|
||||
if (!mounted) {
|
||||
_disposePhotoProgress(tempId);
|
||||
return;
|
||||
}
|
||||
if (tokens.any((t) => t == null)) {
|
||||
_failPhotoMessage(tempId);
|
||||
return;
|
||||
}
|
||||
|
||||
progress.value = List<double>.filled(jobs.length, 1);
|
||||
|
||||
final serverMsg = await messagesModule.sendPhotoMessage(
|
||||
widget.chatId,
|
||||
tokens.cast<String>(),
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
);
|
||||
if (!mounted) {
|
||||
_disposePhotoProgress(tempId);
|
||||
return;
|
||||
}
|
||||
if (serverMsg == null) {
|
||||
_failPhotoMessage(tempId);
|
||||
return;
|
||||
}
|
||||
|
||||
final real = CachedMessage.fromPushPayload(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
serverMsg,
|
||||
);
|
||||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||||
if (idx != -1) {
|
||||
_messages[idx] = real;
|
||||
_bumpMessages();
|
||||
unawaited(_persistOutgoing(real));
|
||||
}
|
||||
_disposePhotoProgress(tempId);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_failPhotoMessage(tempId);
|
||||
} else {
|
||||
_disposePhotoProgress(tempId);
|
||||
}
|
||||
}
|
||||
unawaited(
|
||||
MediaSendService.instance.sendPhotos(
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
tempId: tempId,
|
||||
jobs: jobs,
|
||||
caption: caption,
|
||||
placeholder: placeholder,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendVideo(
|
||||
@@ -6233,103 +6228,127 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
await video.item.originFile();
|
||||
if (file == null || !mounted) return;
|
||||
|
||||
final scheduled = scheduledTime != null;
|
||||
final durationMs = video.item.duration?.inMilliseconds;
|
||||
var durationMs = video.item.duration?.inMilliseconds;
|
||||
if (durationMs == null && DesktopVideoProbe.supported) {
|
||||
durationMs = (await DesktopVideoProbe.duration(file.path))?.inMilliseconds;
|
||||
}
|
||||
final dims = await video.item.dimensions();
|
||||
Uint8List? thumbBytes;
|
||||
try {
|
||||
thumbBytes = await video.item.thumbnail(512);
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
final thumbData = thumbBytes == null || thumbBytes.isEmpty
|
||||
? null
|
||||
: 'data:image/jpeg;base64,${base64Encode(thumbBytes)}';
|
||||
|
||||
String? tempId;
|
||||
ValueNotifier<List<double>>? progress;
|
||||
if (scheduled) {
|
||||
final tempId = _nextTempId();
|
||||
CachedMessage? placeholder;
|
||||
|
||||
if (scheduledTime != null) {
|
||||
showCustomNotification(context, 'Загрузка…');
|
||||
} else {
|
||||
tempId = _nextTempId();
|
||||
progress = ValueNotifier<List<double>>(const [0]);
|
||||
_photoUploadProgress[tempId] = progress;
|
||||
_messages.add(
|
||||
CachedMessage(
|
||||
id: tempId,
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
senderId: _myId,
|
||||
text: caption.isEmpty ? null : caption,
|
||||
time: DateTime.now().millisecondsSinceEpoch,
|
||||
status: 'sending',
|
||||
attachments: [VideoAttachment(duration: durationMs)],
|
||||
),
|
||||
placeholder = CachedMessage(
|
||||
id: tempId,
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
senderId: _myId,
|
||||
text: caption.isEmpty ? null : caption,
|
||||
time: DateTime.now().millisecondsSinceEpoch,
|
||||
status: 'sending',
|
||||
attachments: [
|
||||
VideoAttachment(
|
||||
duration: durationMs,
|
||||
localPath: file.path,
|
||||
previewData: thumbData,
|
||||
width: dims?.$1,
|
||||
height: dims?.$2,
|
||||
),
|
||||
],
|
||||
);
|
||||
_messages.add(placeholder);
|
||||
_lastSentId = tempId;
|
||||
_bumpMessages();
|
||||
Haptics.send();
|
||||
_scrollToBottom();
|
||||
}
|
||||
|
||||
final progressNotifier = progress;
|
||||
try {
|
||||
final info = await messagesModule.requestVideoUploadUrl();
|
||||
if (info == null || info.url.isEmpty) throw Exception('no_url');
|
||||
|
||||
final ok = await fileUploader.uploadVideoFile(
|
||||
Uri.parse(info.url),
|
||||
file,
|
||||
onProgress: progressNotifier == null
|
||||
? null
|
||||
: (sent, total) {
|
||||
if (total > 0) {
|
||||
progressNotifier.value = [(sent / total).clamp(0.0, 1.0)];
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!ok) throw Exception('upload_failed');
|
||||
if (!mounted) {
|
||||
if (tempId != null) _disposePhotoProgress(tempId);
|
||||
return;
|
||||
}
|
||||
|
||||
final serverMsg = await messagesModule.sendVideoMessage(
|
||||
widget.chatId,
|
||||
info.token,
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
unawaited(
|
||||
MediaSendService.instance.sendVideo(
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
tempId: tempId,
|
||||
file: file,
|
||||
caption: caption,
|
||||
placeholder: placeholder,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
if (!mounted) {
|
||||
if (tempId != null) _disposePhotoProgress(tempId);
|
||||
return;
|
||||
}
|
||||
if (serverMsg == null) throw Exception('send_failed');
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (scheduled) {
|
||||
void _onMediaSendEvent(MediaSendEvent event) {
|
||||
if (!mounted || event.chatId != widget.chatId) return;
|
||||
if (event is MediaSendDone) {
|
||||
if (event.scheduled) {
|
||||
Haptics.send();
|
||||
_markHasScheduled();
|
||||
final at = event.scheduledTime;
|
||||
showCustomNotification(
|
||||
context,
|
||||
'Запланировано на '
|
||||
'${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}',
|
||||
at == null
|
||||
? 'Запланировано'
|
||||
: 'Запланировано на '
|
||||
'${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(at))}',
|
||||
);
|
||||
} else {
|
||||
final real = CachedMessage.fromPushPayload(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
serverMsg,
|
||||
);
|
||||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||||
if (idx != -1) {
|
||||
_messages[idx] = real;
|
||||
_bumpMessages();
|
||||
unawaited(_persistOutgoing(real, removeId: tempId));
|
||||
}
|
||||
_disposePhotoProgress(tempId!);
|
||||
}
|
||||
} catch (_) {
|
||||
if (!mounted) {
|
||||
if (tempId != null) _disposePhotoProgress(tempId);
|
||||
return;
|
||||
}
|
||||
if (scheduled) {
|
||||
final real = event.message;
|
||||
if (real == null) return;
|
||||
final idx = _messages.indexWhere((m) => m.id == event.tempId);
|
||||
if (idx != -1) {
|
||||
_messages[idx] = real;
|
||||
_bumpMessages();
|
||||
}
|
||||
} else if (event is MediaSendFailed) {
|
||||
if (event.scheduled) {
|
||||
Haptics.error();
|
||||
showCustomNotification(context, 'Не удалось запланировать видео');
|
||||
} else {
|
||||
_failPhotoMessage(tempId!);
|
||||
showCustomNotification(context, 'Не удалось запланировать');
|
||||
return;
|
||||
}
|
||||
_failPhotoMessage(event.tempId);
|
||||
}
|
||||
}
|
||||
|
||||
void _mergePendingMedia() {
|
||||
final service = MediaSendService.instance;
|
||||
var changed = false;
|
||||
|
||||
for (var i = _messages.length - 1; i >= 0; i--) {
|
||||
final msg = _messages[i];
|
||||
if (!isSendingStatus(msg.status)) continue;
|
||||
final done = service.completedFor(msg.id);
|
||||
if (done != null) {
|
||||
if (done.id != msg.id && _messages.any((m) => m.id == done.id)) {
|
||||
_messages.removeAt(i);
|
||||
} else {
|
||||
_messages[i] = done;
|
||||
}
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (service.didFail(msg.id)) {
|
||||
_messages[i] = msg.copyWith(status: 'error');
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (final msg in service.pendingFor(widget.chatId)) {
|
||||
if (_messages.any((m) => m.id == msg.id)) continue;
|
||||
_messages.add(msg);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) _bumpMessages();
|
||||
}
|
||||
|
||||
Future<void> _sendScheduledPhotos(
|
||||
@@ -6360,43 +6379,16 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (jobs.isEmpty || !mounted) return;
|
||||
|
||||
showCustomNotification(context, 'Загрузка…');
|
||||
final progress = ValueNotifier<List<double>>(
|
||||
List<double>.filled(jobs.length, 0),
|
||||
);
|
||||
try {
|
||||
final tokens = await _uploadPhotos(jobs, progress);
|
||||
if (!mounted) return;
|
||||
if (tokens.any((t) => t == null)) {
|
||||
showCustomNotification(context, 'Не удалось загрузить фото');
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await messagesModule.sendPhotoMessage(
|
||||
widget.chatId,
|
||||
tokens.cast<String>(),
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
unawaited(
|
||||
MediaSendService.instance.sendPhotos(
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
tempId: _nextTempId(),
|
||||
jobs: jobs,
|
||||
caption: caption,
|
||||
scheduledTime: scheduledTime,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (result != null) {
|
||||
Haptics.send();
|
||||
_markHasScheduled();
|
||||
showCustomNotification(
|
||||
context,
|
||||
'Запланировано на '
|
||||
'${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}',
|
||||
);
|
||||
} else {
|
||||
showCustomNotification(context, 'Не удалось запланировать');
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
Haptics.error();
|
||||
showCustomNotification(context, 'Ошибка при загрузке');
|
||||
}
|
||||
} finally {
|
||||
progress.dispose();
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendAttachMessage(
|
||||
@@ -6547,105 +6539,11 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
);
|
||||
}
|
||||
|
||||
static const int _photoUploadConcurrency = 3;
|
||||
static const int _photoUploadAttempts = 3;
|
||||
|
||||
Future<List<String?>> _uploadPhotos(
|
||||
List<({File file, GalleryItem? item})> jobs,
|
||||
ValueNotifier<List<double>> progress,
|
||||
) async {
|
||||
final tokens = List<String?>.filled(jobs.length, null);
|
||||
var nextIndex = 0;
|
||||
var failed = false;
|
||||
|
||||
Future<void> worker() async {
|
||||
while (!failed) {
|
||||
final i = nextIndex++;
|
||||
if (i >= jobs.length) return;
|
||||
final token = await _uploadOnePhoto(jobs[i], i, progress);
|
||||
if (token == null) {
|
||||
failed = true;
|
||||
return;
|
||||
}
|
||||
tokens[i] = token;
|
||||
}
|
||||
}
|
||||
|
||||
final workerCount = math.min(_photoUploadConcurrency, jobs.length);
|
||||
await Future.wait(List.generate(workerCount, (_) => worker()));
|
||||
return tokens;
|
||||
}
|
||||
|
||||
Future<String?> _uploadOnePhoto(
|
||||
({File file, GalleryItem? item}) job,
|
||||
int index,
|
||||
ValueNotifier<List<double>> progress,
|
||||
) async {
|
||||
File file;
|
||||
try {
|
||||
file = await optimizePhotoForUpload(job.file, item: job.item);
|
||||
} catch (e) {
|
||||
logger.w('optimize photo: $e');
|
||||
file = job.file;
|
||||
}
|
||||
for (var attempt = 0; attempt < _photoUploadAttempts; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await Future.delayed(Duration(seconds: attempt));
|
||||
if (!mounted) return null;
|
||||
_setPhotoProgress(progress, index, 0);
|
||||
}
|
||||
try {
|
||||
final url = await messagesModule.requestPhotoUploadUrl();
|
||||
if (url == null || url.isEmpty) continue;
|
||||
final token = await fileUploader.uploadPhoto(
|
||||
Uri.parse(url),
|
||||
file,
|
||||
filename: _photoFilename(file),
|
||||
onProgress: (sent, total) {
|
||||
if (total <= 0) return;
|
||||
_setPhotoProgress(progress, index, (sent / total).clamp(0.0, 1.0));
|
||||
},
|
||||
);
|
||||
if (token != null) return token;
|
||||
} catch (e) {
|
||||
logger.w('uploadOnePhoto attempt ${attempt + 1}: $e');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _setPhotoProgress(
|
||||
ValueNotifier<List<double>> progress,
|
||||
int index,
|
||||
double value,
|
||||
) {
|
||||
final next = List<double>.from(progress.value);
|
||||
if (index < next.length) {
|
||||
next[index] = value;
|
||||
progress.value = next;
|
||||
}
|
||||
}
|
||||
|
||||
String _photoFilename(File file) {
|
||||
final segments = file.uri.pathSegments;
|
||||
final name = segments.isNotEmpty ? segments.last : '';
|
||||
return name.isNotEmpty ? name : 'photo.jpg';
|
||||
}
|
||||
|
||||
void _failPhotoMessage(String tempId) {
|
||||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||||
if (idx != -1) {
|
||||
final old = _messages[idx];
|
||||
_messages[idx] = CachedMessage(
|
||||
id: old.id,
|
||||
accountId: old.accountId,
|
||||
chatId: old.chatId,
|
||||
senderId: old.senderId,
|
||||
text: old.text,
|
||||
time: old.time,
|
||||
status: 'error',
|
||||
attachments: old.attachments,
|
||||
);
|
||||
_messages[idx] = _messages[idx].copyWith(status: 'error');
|
||||
_bumpMessages();
|
||||
}
|
||||
_disposePhotoProgress(tempId);
|
||||
@@ -6748,6 +6646,11 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
: _addOptimisticFileMessage(
|
||||
FileAttachment(name: file.name, size: file.size),
|
||||
);
|
||||
ValueNotifier<List<double>>? fileProgress;
|
||||
if (tempId != null) {
|
||||
fileProgress = ValueNotifier<List<double>>(const [0]);
|
||||
_photoUploadProgress[tempId] = fileProgress;
|
||||
}
|
||||
|
||||
UploadNotificationService.start(file.name);
|
||||
|
||||
@@ -6777,6 +6680,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
sent: sent,
|
||||
total: total,
|
||||
);
|
||||
if (total > 0) {
|
||||
fileProgress?.value = [(sent / total).clamp(0.0, 1.0)];
|
||||
}
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
final elapsed = nowMs - notifLastMs;
|
||||
if (elapsed >= 500) {
|
||||
@@ -6819,8 +6725,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
'${formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(scheduledTime))}',
|
||||
);
|
||||
} else {
|
||||
_disposePhotoProgress(tempId!);
|
||||
_updateFileMessageStatus(
|
||||
tempId!,
|
||||
tempId,
|
||||
'sent',
|
||||
realId: messageId,
|
||||
attachment: FileAttachment(
|
||||
@@ -6834,7 +6741,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
case UploadError(:final message):
|
||||
stopNotif();
|
||||
showCustomNotification(context, 'Ошибка: $message');
|
||||
if (tempId != null) _updateFileMessageStatus(tempId, 'error');
|
||||
if (tempId != null) {
|
||||
_disposePhotoProgress(tempId);
|
||||
_updateFileMessageStatus(tempId, 'error');
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
@@ -6852,6 +6762,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
),
|
||||
);
|
||||
if (inFlight.id == tempId && inFlight.status == 'sending') {
|
||||
_disposePhotoProgress(tempId);
|
||||
_updateFileMessageStatus(tempId, 'error');
|
||||
}
|
||||
}
|
||||
@@ -6863,7 +6774,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (!mounted) return;
|
||||
stopNotif();
|
||||
showCustomNotification(context, 'Ошибка: $e');
|
||||
if (tempId != null) _updateFileMessageStatus(tempId, 'error');
|
||||
if (tempId != null) {
|
||||
_disposePhotoProgress(tempId);
|
||||
_updateFileMessageStatus(tempId, 'error');
|
||||
}
|
||||
_uploadStatus.value = const UploadStatus();
|
||||
_uploadSub = null;
|
||||
if (!done.isCompleted) done.complete();
|
||||
|
||||
@@ -4,8 +4,6 @@ import 'dart:ui' show lerpDouble;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart'
|
||||
show OverScrollHeaderStretchConfiguration;
|
||||
import 'package:flutter/services.dart' show HapticFeedback;
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
@@ -22,6 +20,7 @@ import '../../widgets/avatar_history_screen.dart';
|
||||
import '../../widgets/connection_status.dart';
|
||||
import '../../widgets/info_action_sheet.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
import '../../widgets/profile_header_scroll.dart';
|
||||
import '../../widgets/settings_card.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import '../../widgets/small_spinner.dart';
|
||||
@@ -367,14 +366,14 @@ class _SettingsTabState extends State<SettingsTab> with SpectrumSurface {
|
||||
controller: _scrollController ??= ScrollController(
|
||||
initialScrollOffset: delta,
|
||||
),
|
||||
physics: _HeaderPullScrollPhysics(
|
||||
physics: HeaderPullScrollPhysics(
|
||||
delta: delta,
|
||||
isArmed: () => _expandArmed,
|
||||
parent: const BouncingScrollPhysics(),
|
||||
),
|
||||
slivers: [
|
||||
SliverPersistentHeader(
|
||||
delegate: _ProfileHeaderDelegate(
|
||||
delegate: MorphHeaderDelegate(
|
||||
collapsedExtent: collapsedH,
|
||||
expandedExtent: expandedH,
|
||||
headerBuilder: (ctx, t) =>
|
||||
@@ -1060,125 +1059,6 @@ class _SettingsTabState extends State<SettingsTab> with SpectrumSurface {
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderPullScrollPhysics extends ScrollPhysics {
|
||||
final double delta;
|
||||
final ValueGetter<bool> isArmed;
|
||||
|
||||
const _HeaderPullScrollPhysics({
|
||||
required this.delta,
|
||||
required this.isArmed,
|
||||
super.parent,
|
||||
});
|
||||
|
||||
static final SpringDescription _expressiveSpring =
|
||||
SpringDescription.withDampingRatio(mass: 1, stiffness: 380, ratio: 0.9);
|
||||
|
||||
static const double _flingVelocity = 400;
|
||||
|
||||
@override
|
||||
_HeaderPullScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return _HeaderPullScrollPhysics(
|
||||
delta: delta,
|
||||
isArmed: isArmed,
|
||||
parent: buildParent(ancestor),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
|
||||
if (delta <= 0 || offset <= 0 || position.pixels <= 0) {
|
||||
return super.applyPhysicsToUserOffset(position, offset);
|
||||
}
|
||||
final px = position.pixels;
|
||||
final free = math.max(0.0, px - delta);
|
||||
if (offset <= free) return offset;
|
||||
if (!isArmed()) return free;
|
||||
final inZone = offset - free;
|
||||
final expandedFraction = (1 - math.min(px, delta) / delta).clamp(0.0, 1.0);
|
||||
final friction = lerpDouble(0.58, 0.3, expandedFraction)!;
|
||||
return free + inZone * friction;
|
||||
}
|
||||
|
||||
@override
|
||||
Simulation? createBallisticSimulation(
|
||||
ScrollMetrics position,
|
||||
double velocity,
|
||||
) {
|
||||
if (delta > 0) {
|
||||
final px = position.pixels;
|
||||
final tolerance = toleranceFor(position);
|
||||
if (px > 0 && px < delta) {
|
||||
final double target;
|
||||
if (velocity <= -_flingVelocity) {
|
||||
target = 0;
|
||||
} else if (velocity >= _flingVelocity) {
|
||||
target = delta;
|
||||
} else {
|
||||
target = px < delta / 2 ? 0 : delta;
|
||||
}
|
||||
if ((target - px).abs() < tolerance.distance &&
|
||||
velocity.abs() < tolerance.velocity) {
|
||||
return null;
|
||||
}
|
||||
return ScrollSpringSimulation(
|
||||
_expressiveSpring,
|
||||
px,
|
||||
target,
|
||||
velocity,
|
||||
tolerance: tolerance,
|
||||
);
|
||||
}
|
||||
if (px >= delta && velocity < 0) {
|
||||
return BouncingScrollSimulation(
|
||||
position: px,
|
||||
velocity: velocity,
|
||||
leadingExtent: delta,
|
||||
trailingExtent: math.max(delta, position.maxScrollExtent),
|
||||
spring: spring,
|
||||
tolerance: tolerance,
|
||||
);
|
||||
}
|
||||
}
|
||||
return super.createBallisticSimulation(position, velocity);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||
final double collapsedExtent;
|
||||
final double expandedExtent;
|
||||
final Widget Function(BuildContext context, double t) headerBuilder;
|
||||
|
||||
_ProfileHeaderDelegate({
|
||||
required this.collapsedExtent,
|
||||
required this.expandedExtent,
|
||||
required this.headerBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
double get minExtent => collapsedExtent;
|
||||
|
||||
@override
|
||||
double get maxExtent => expandedExtent;
|
||||
|
||||
@override
|
||||
OverScrollHeaderStretchConfiguration get stretchConfiguration =>
|
||||
OverScrollHeaderStretchConfiguration();
|
||||
|
||||
@override
|
||||
Widget build(
|
||||
BuildContext context,
|
||||
double shrinkOffset,
|
||||
bool overlapsContent,
|
||||
) {
|
||||
final range = expandedExtent - collapsedExtent;
|
||||
final t = range <= 0 ? 0.0 : (1 - shrinkOffset / range).clamp(0.0, 1.0);
|
||||
return headerBuilder(context, t);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRebuild(covariant _ProfileHeaderDelegate oldDelegate) => true;
|
||||
}
|
||||
|
||||
class _SettingsItem {
|
||||
final IconData? icon;
|
||||
final Widget? leading;
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/media/preview_image.dart';
|
||||
import '../../../models/story.dart';
|
||||
|
||||
ImageProvider? storyThumbProvider(Story story) {
|
||||
final media = story.media;
|
||||
if (media == null) return null;
|
||||
final url = media.isVideo ? (media.thumbnailUrl ?? media.url) : media.url;
|
||||
if (url != null && url.isNotEmpty) {
|
||||
return CachedNetworkImageProvider(url, maxWidth: 160, maxHeight: 160);
|
||||
}
|
||||
return dataUriImage(story, media.previewData);
|
||||
}
|
||||
|
||||
class StoryPeanut extends StatefulWidget {
|
||||
final List<Story> stories;
|
||||
final double diameter;
|
||||
final double strokeWidth;
|
||||
final double gap;
|
||||
final Color outlineColor;
|
||||
final Duration cycle;
|
||||
|
||||
const StoryPeanut({
|
||||
super.key,
|
||||
required this.stories,
|
||||
this.diameter = 30,
|
||||
this.strokeWidth = 1.8,
|
||||
this.gap = 1.6,
|
||||
this.outlineColor = Colors.white,
|
||||
this.cycle = const Duration(seconds: 3),
|
||||
});
|
||||
|
||||
static const int maxCircles = 3;
|
||||
|
||||
@override
|
||||
State<StoryPeanut> createState() => _StoryPeanutState();
|
||||
}
|
||||
|
||||
class _StoryPeanutState extends State<StoryPeanut> {
|
||||
Timer? _timer;
|
||||
int _cycleIndex = 0;
|
||||
|
||||
bool get _cycling => widget.stories.length > StoryPeanut.maxCircles;
|
||||
|
||||
int get _circleCount =>
|
||||
_cycling ? 1 : widget.stories.length.clamp(0, StoryPeanut.maxCircles);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(StoryPeanut old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.stories.length != widget.stories.length ||
|
||||
old.cycle != widget.cycle) {
|
||||
_cycleIndex = 0;
|
||||
_syncTimer();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncTimer() {
|
||||
_timer?.cancel();
|
||||
if (!_cycling) return;
|
||||
_timer = Timer.periodic(widget.cycle, (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_cycleIndex = (_cycleIndex + 1) % widget.stories.length;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final count = _circleCount;
|
||||
if (count == 0) return const SizedBox.shrink();
|
||||
|
||||
final d = widget.diameter;
|
||||
final step = d * 0.68;
|
||||
final pad = widget.strokeWidth + widget.gap;
|
||||
final width = d + step * (count - 1);
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: d,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
for (var i = 0; i < count; i++)
|
||||
Positioned(
|
||||
left: step * i,
|
||||
top: 0,
|
||||
width: d,
|
||||
height: d,
|
||||
child: ClipPath(
|
||||
clipper: i == count - 1
|
||||
? null
|
||||
: _NotchClipper(
|
||||
center: Offset(step + d / 2, d / 2),
|
||||
radius: d / 2 + widget.gap,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(pad),
|
||||
child: ClipOval(child: _thumb(i)),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(
|
||||
painter: _PeanutOutlinePainter(
|
||||
count: count,
|
||||
diameter: d,
|
||||
step: step,
|
||||
strokeWidth: widget.strokeWidth,
|
||||
color: widget.outlineColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _thumb(int index) {
|
||||
final story = _cycling
|
||||
? widget.stories[_cycleIndex % widget.stories.length]
|
||||
: widget.stories[index];
|
||||
final provider = storyThumbProvider(story);
|
||||
final fill = ColoredBox(color: Colors.white.withValues(alpha: 0.22));
|
||||
final image = provider == null
|
||||
? fill
|
||||
: Image(image: provider, fit: BoxFit.cover, gaplessPlayback: true);
|
||||
if (!_cycling) return image;
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 320),
|
||||
child: KeyedSubtree(key: ValueKey(story.id), child: image),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NotchClipper extends CustomClipper<Path> {
|
||||
final Offset center;
|
||||
final double radius;
|
||||
|
||||
const _NotchClipper({required this.center, required this.radius});
|
||||
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
return Path.combine(
|
||||
PathOperation.difference,
|
||||
Path()..addOval(Offset.zero & size),
|
||||
Path()..addOval(Rect.fromCircle(center: center, radius: radius)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(_NotchClipper old) =>
|
||||
old.center != center || old.radius != radius;
|
||||
}
|
||||
|
||||
class _PeanutOutlinePainter extends CustomPainter {
|
||||
final int count;
|
||||
final double diameter;
|
||||
final double step;
|
||||
final double strokeWidth;
|
||||
final Color color;
|
||||
|
||||
const _PeanutOutlinePainter({
|
||||
required this.count,
|
||||
required this.diameter,
|
||||
required this.step,
|
||||
required this.strokeWidth,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final radius = diameter / 2 - strokeWidth / 2;
|
||||
Path union = Path();
|
||||
for (var i = 0; i < count; i++) {
|
||||
final circle = Path()
|
||||
..addOval(
|
||||
Rect.fromCircle(
|
||||
center: Offset(diameter / 2 + step * i, diameter / 2),
|
||||
radius: radius,
|
||||
),
|
||||
);
|
||||
union = i == 0 ? circle : Path.combine(PathOperation.union, union, circle);
|
||||
}
|
||||
canvas.drawPath(
|
||||
union,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..color = color,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_PeanutOutlinePainter old) =>
|
||||
old.count != count ||
|
||||
old.diameter != diameter ||
|
||||
old.step != step ||
|
||||
old.strokeWidth != strokeWidth ||
|
||||
old.color != color;
|
||||
}
|
||||
@@ -8,6 +8,64 @@ import '../../../models/story.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
import 'story_owner_info.dart';
|
||||
|
||||
class StoryAvatarRing extends StatelessWidget {
|
||||
final double diameter;
|
||||
final int total;
|
||||
final int read;
|
||||
final double strokeWidth;
|
||||
final double ringGap;
|
||||
final double haloWidth;
|
||||
final Widget child;
|
||||
|
||||
const StoryAvatarRing({
|
||||
super.key,
|
||||
required this.diameter,
|
||||
required this.child,
|
||||
this.total = 0,
|
||||
this.read = 0,
|
||||
this.strokeWidth = 2.8,
|
||||
this.ringGap = 6,
|
||||
this.haloWidth = 2,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final outer = diameter + ringGap * 2;
|
||||
final visible = total > 0;
|
||||
return SizedBox(
|
||||
width: outer,
|
||||
height: outer,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CustomPaint(
|
||||
size: Size.square(outer),
|
||||
painter: visible
|
||||
? SegmentedRingPainter(
|
||||
total: total,
|
||||
read: read,
|
||||
unreadColors: [cs.primary, cs.tertiary, cs.primary],
|
||||
readColor: cs.outlineVariant,
|
||||
strokeWidth: strokeWidth,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
Container(
|
||||
width: diameter + haloWidth * 2,
|
||||
height: diameter + haloWidth * 2,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: visible ? cs.surface : Colors.transparent,
|
||||
),
|
||||
),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Кольцо-превью истории владельца в шапке списка чатов.
|
||||
class StoryRing extends StatefulWidget {
|
||||
final StoryPreview preview;
|
||||
@@ -71,36 +129,14 @@ class _StoryRingState extends State<StoryRing> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: diameter + 12,
|
||||
height: diameter + 12,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CustomPaint(
|
||||
size: Size.square(diameter + 12),
|
||||
painter: _SegmentedRingPainter(
|
||||
total: widget.preview.totalCount,
|
||||
read: widget.preview.readCount,
|
||||
unreadColors: [cs.primary, cs.tertiary, cs.primary],
|
||||
readColor: cs.outlineVariant,
|
||||
strokeWidth: 2.8,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: diameter + 4,
|
||||
height: diameter + 4,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: cs.surface,
|
||||
),
|
||||
),
|
||||
KometAvatar(
|
||||
name: name == '…' ? '?' : name,
|
||||
size: diameter,
|
||||
imageUrl: info?.avatarUrl,
|
||||
),
|
||||
],
|
||||
StoryAvatarRing(
|
||||
diameter: diameter,
|
||||
total: widget.preview.totalCount,
|
||||
read: widget.preview.readCount,
|
||||
child: KometAvatar(
|
||||
name: name == '…' ? '?' : name,
|
||||
size: diameter,
|
||||
imageUrl: info?.avatarUrl,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@@ -128,14 +164,14 @@ class _StoryRingState extends State<StoryRing> {
|
||||
}
|
||||
|
||||
/// Прерывистое кольцо: одна дуга на каждую историю; прочитанные приглушены.
|
||||
class _SegmentedRingPainter extends CustomPainter {
|
||||
class SegmentedRingPainter extends CustomPainter {
|
||||
final int total;
|
||||
final int read;
|
||||
final List<Color> unreadColors;
|
||||
final Color readColor;
|
||||
final double strokeWidth;
|
||||
|
||||
_SegmentedRingPainter({
|
||||
SegmentedRingPainter({
|
||||
required this.total,
|
||||
required this.read,
|
||||
required this.unreadColors,
|
||||
@@ -146,9 +182,12 @@ class _SegmentedRingPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final n = total < 1 ? 1 : total;
|
||||
final center = size.center(Offset.zero);
|
||||
final radius = (size.width - strokeWidth) / 2;
|
||||
final rect = Rect.fromCircle(center: center, radius: radius);
|
||||
final rect = Rect.fromLTWH(
|
||||
strokeWidth / 2,
|
||||
strokeWidth / 2,
|
||||
size.width - strokeWidth,
|
||||
size.height - strokeWidth,
|
||||
);
|
||||
|
||||
final segment = (2 * math.pi) / n;
|
||||
final gap = n == 1 ? 0.0 : math.min(0.16, segment * 0.30);
|
||||
@@ -178,7 +217,7 @@ class _SegmentedRingPainter extends CustomPainter {
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_SegmentedRingPainter old) =>
|
||||
bool shouldRepaint(SegmentedRingPainter old) =>
|
||||
old.total != total ||
|
||||
old.read != read ||
|
||||
old.readColor != readColor ||
|
||||
@@ -260,7 +299,7 @@ class _StorySelfTileState extends State<StorySelfTile> {
|
||||
if (preview != null)
|
||||
CustomPaint(
|
||||
size: Size.square(diameter + 12),
|
||||
painter: _SegmentedRingPainter(
|
||||
painter: SegmentedRingPainter(
|
||||
total: preview.totalCount,
|
||||
read: preview.readCount,
|
||||
unreadColors: [cs.primary, cs.tertiary, cs.primary],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
@@ -9,28 +10,46 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../core/utils/haptics.dart';
|
||||
import '../../../main.dart' show storiesModule;
|
||||
import '../../../main.dart' show animojiModule, storiesModule;
|
||||
import '../../../models/story.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/komet_avatar.dart';
|
||||
import '../../widgets/liquid_glass.dart';
|
||||
import '../../widgets/lottie_image.dart';
|
||||
import '../../widgets/small_spinner.dart';
|
||||
import 'story_owner_info.dart';
|
||||
|
||||
const _quickReactions = ['❤️', '🔥', '😍', '👏', '😂', '😮'];
|
||||
const Duration _photoDuration = Duration(seconds: 5);
|
||||
|
||||
class _ReactionItem {
|
||||
final String emoji;
|
||||
final String? lottieUrl;
|
||||
final String? iconUrl;
|
||||
|
||||
const _ReactionItem(this.emoji, {this.lottieUrl, this.iconUrl});
|
||||
|
||||
bool get animated =>
|
||||
(lottieUrl?.isNotEmpty ?? false) || (iconUrl?.isNotEmpty ?? false);
|
||||
}
|
||||
|
||||
Offset? storyOriginOf(BuildContext context) {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box == null || !box.hasSize) return null;
|
||||
return box.localToGlobal(box.size.center(Offset.zero));
|
||||
}
|
||||
|
||||
/// Открывает вьюер историй. Если задан [origin] (глобальный центр нажатого
|
||||
/// кольца) — открытие анимируется расширяющимся из этой точки кругом; иначе —
|
||||
/// масштабным «зумом».
|
||||
void openStoryViewer(
|
||||
Future<void> openStoryViewer(
|
||||
BuildContext context, {
|
||||
required List<StoryPreview> previews,
|
||||
int initialIndex = 0,
|
||||
Map<int, StoryOwnerInfo> ownerOverrides = const {},
|
||||
Offset? origin,
|
||||
}) {
|
||||
Navigator.of(context).push(
|
||||
return Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
opaque: false,
|
||||
transitionDuration: const Duration(milliseconds: 420),
|
||||
@@ -127,6 +146,10 @@ class _StoryViewerScreenState extends State<StoryViewerScreen>
|
||||
final List<_Burst> _bursts = [];
|
||||
int _burstSeq = 0;
|
||||
|
||||
List<_ReactionItem> _reactions = _quickReactions
|
||||
.map((e) => _ReactionItem(e))
|
||||
.toList();
|
||||
|
||||
VideoPlayerController? _video;
|
||||
|
||||
StoryPreview get _owner => widget.previews[_ownerIndex];
|
||||
@@ -150,6 +173,23 @@ class _StoryViewerScreenState extends State<StoryViewerScreen>
|
||||
if (s == AnimationStatus.completed) _advance();
|
||||
});
|
||||
_loadOwner(_ownerIndex, autostart: true);
|
||||
unawaited(_loadReactions());
|
||||
}
|
||||
|
||||
Future<void> _loadReactions() async {
|
||||
try {
|
||||
await animojiModule.ensureLoaded();
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
final quick = animojiModule.quickAnimojis;
|
||||
if (!mounted || quick.isEmpty) return;
|
||||
setState(() {
|
||||
_reactions = [
|
||||
for (final a in quick)
|
||||
_ReactionItem(a.emoji, lottieUrl: a.lottieUrl, iconUrl: a.iconUrl),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -185,19 +225,16 @@ class _StoryViewerScreenState extends State<StoryViewerScreen>
|
||||
}
|
||||
}
|
||||
|
||||
/// Индекс, с которого начать показ: сначала — сохранённая позиция просмотра,
|
||||
/// иначе — первая непрочитанная.
|
||||
int _resumeIndex(int index, List<Story> stories) {
|
||||
if (stories.isEmpty) return 0;
|
||||
final ownerId = widget.previews[index].owner.ownerId;
|
||||
final savedId = storiesModule.lastViewedStoryId(ownerId);
|
||||
if (savedId != null) {
|
||||
final i = stories.indexWhere((s) => s.id == savedId);
|
||||
if (i >= 0) return i;
|
||||
}
|
||||
final read = widget.previews[index].readCount;
|
||||
if (read > 0 && read < stories.length) return read;
|
||||
return 0;
|
||||
final preview = widget.previews[index];
|
||||
final read = preview.readCount;
|
||||
final firstUnread = (read > 0 && read < stories.length) ? read : 0;
|
||||
final savedId = storiesModule.lastViewedStoryId(preview.owner.ownerId);
|
||||
if (savedId == null) return firstUnread;
|
||||
final saved = stories.indexWhere((s) => s.id == savedId);
|
||||
if (saved <= firstUnread || saved >= stories.length - 1) return firstUnread;
|
||||
return saved;
|
||||
}
|
||||
|
||||
void _startStory(int index) {
|
||||
@@ -262,6 +299,7 @@ class _StoryViewerScreenState extends State<StoryViewerScreen>
|
||||
if (_storyIndex + 1 < _ownerStories.length) {
|
||||
_startStory(_storyIndex + 1);
|
||||
} else {
|
||||
storiesModule.clearLastViewed(_owner.owner.ownerId);
|
||||
_nextOwner();
|
||||
}
|
||||
}
|
||||
@@ -317,9 +355,9 @@ class _StoryViewerScreenState extends State<StoryViewerScreen>
|
||||
}
|
||||
}
|
||||
|
||||
void _spawnBurst(String emoji, Alignment from) {
|
||||
void _spawnBurst(_ReactionItem item, Alignment from) {
|
||||
final id = _burstSeq++;
|
||||
setState(() => _bursts.add(_Burst(id, emoji, from)));
|
||||
setState(() => _bursts.add(_Burst(id, item, from)));
|
||||
}
|
||||
|
||||
void _removeBurst(int id) {
|
||||
@@ -327,20 +365,22 @@ class _StoryViewerScreenState extends State<StoryViewerScreen>
|
||||
setState(() => _bursts.removeWhere((b) => b.id == id));
|
||||
}
|
||||
|
||||
Future<void> _toggleReaction(String emoji) async {
|
||||
Future<void> _toggleReaction(_ReactionItem item) async {
|
||||
final story = _currentStory;
|
||||
if (story == null || story.id == 0) return;
|
||||
final isSame = story.reaction?.id == emoji;
|
||||
final isSame = story.reaction?.id == item.emoji;
|
||||
if (!isSame) {
|
||||
Haptics.medium();
|
||||
_spawnBurst(emoji, const Alignment(0, 0.55));
|
||||
_spawnBurst(item, const Alignment(0, 0.55));
|
||||
final animoji = animojiModule.findByEmoji(item.emoji);
|
||||
if (animoji != null) unawaited(animojiModule.noteUsed(animoji));
|
||||
} else {
|
||||
Haptics.tap();
|
||||
}
|
||||
final ok = await storiesModule.react(
|
||||
story.owner,
|
||||
story.id,
|
||||
isSame ? null : StoryReaction(id: emoji),
|
||||
isSame ? null : StoryReaction(id: item.emoji),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (ok) {
|
||||
@@ -434,7 +474,7 @@ class _StoryViewerScreenState extends State<StoryViewerScreen>
|
||||
for (final burst in _bursts)
|
||||
_FloatingReaction(
|
||||
key: ValueKey(burst.id),
|
||||
emoji: burst.emoji,
|
||||
item: burst.item,
|
||||
alignment: burst.from,
|
||||
onDone: () => _removeBurst(burst.id),
|
||||
),
|
||||
@@ -635,11 +675,11 @@ class _StoryViewerScreenState extends State<StoryViewerScreen>
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final emoji in _quickReactions)
|
||||
for (final item in _reactions)
|
||||
_ReactionButton(
|
||||
emoji: emoji,
|
||||
selected: current == emoji,
|
||||
onTap: () => _toggleReaction(emoji),
|
||||
item: item,
|
||||
selected: current == item.emoji,
|
||||
onTap: () => _toggleReaction(item),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -754,12 +794,12 @@ class _SegmentBar extends StatelessWidget {
|
||||
|
||||
// ─── Reaction emoji button ────────────────────────────────────────────────
|
||||
class _ReactionButton extends StatefulWidget {
|
||||
final String emoji;
|
||||
final _ReactionItem item;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ReactionButton({
|
||||
required this.emoji,
|
||||
required this.item,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
@@ -803,7 +843,18 @@ class _ReactionButtonState extends State<_ReactionButton>
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Transform.scale(
|
||||
scale: scale,
|
||||
child: Text(widget.emoji, style: const TextStyle(fontSize: 28)),
|
||||
child: widget.item.animated
|
||||
? LottieImage(
|
||||
lottieUrl: widget.item.lottieUrl,
|
||||
url: widget.item.iconUrl,
|
||||
size: 30,
|
||||
shimmer: false,
|
||||
memCacheWidth: 90,
|
||||
)
|
||||
: Text(
|
||||
widget.item.emoji,
|
||||
style: const TextStyle(fontSize: 28),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -867,19 +918,19 @@ class _TopScrim extends StatelessWidget {
|
||||
// ─── Floating reaction burst ──────────────────────────────────────────────
|
||||
class _Burst {
|
||||
final int id;
|
||||
final String emoji;
|
||||
final _ReactionItem item;
|
||||
final Alignment from;
|
||||
const _Burst(this.id, this.emoji, this.from);
|
||||
const _Burst(this.id, this.item, this.from);
|
||||
}
|
||||
|
||||
class _FloatingReaction extends StatefulWidget {
|
||||
final String emoji;
|
||||
final _ReactionItem item;
|
||||
final Alignment alignment;
|
||||
final VoidCallback onDone;
|
||||
|
||||
const _FloatingReaction({
|
||||
super.key,
|
||||
required this.emoji,
|
||||
required this.item,
|
||||
required this.alignment,
|
||||
required this.onDone,
|
||||
});
|
||||
@@ -894,7 +945,7 @@ class _FloatingReactionState extends State<_FloatingReaction>
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
);
|
||||
late final double _drift = (widget.emoji.hashCode % 40 - 20).toDouble();
|
||||
late final double _drift = (widget.item.emoji.hashCode % 40 - 20).toDouble();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -928,10 +979,19 @@ class _FloatingReactionState extends State<_FloatingReaction>
|
||||
opacity: opacity.clamp(0.0, 1.0),
|
||||
child: Transform.scale(
|
||||
scale: scale,
|
||||
child: Text(
|
||||
widget.emoji,
|
||||
style: const TextStyle(fontSize: 64),
|
||||
),
|
||||
child: widget.item.animated
|
||||
? LottieImage(
|
||||
lottieUrl: widget.item.lottieUrl,
|
||||
url: widget.item.iconUrl,
|
||||
size: 64,
|
||||
shimmer: false,
|
||||
repeat: false,
|
||||
memCacheWidth: 192,
|
||||
)
|
||||
: Text(
|
||||
widget.item.emoji,
|
||||
style: const TextStyle(fontSize: 64),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../backend/modules/webapp.dart';
|
||||
import '../../../core/utils/haptics.dart';
|
||||
import '../../../core/utils/link_opener.dart';
|
||||
import '../../../core/utils/webview_support.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../main.dart' show webAppModule;
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import 'web_app_screen.dart';
|
||||
|
||||
Future<void> openMiniApp(
|
||||
BuildContext context, {
|
||||
required int botId,
|
||||
required String title,
|
||||
int? chatId,
|
||||
}) async {
|
||||
if (botId <= 0) return;
|
||||
Haptics.tap();
|
||||
|
||||
if (webViewSupported) {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => WebAppScreen(
|
||||
title: title,
|
||||
loader: () => webAppModule.fetchLaunch(botId, chatId: chatId),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final launch = await webAppModule.fetchLaunch(botId, chatId: chatId);
|
||||
if (!context.mounted) return;
|
||||
await openExternalUrl(context, launch.url);
|
||||
} on WebAppUnavailable catch (e) {
|
||||
if (context.mounted) showCustomNotification(context, e.message);
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
showCustomNotification(
|
||||
context,
|
||||
AppLocalizations.of(context)!.miniAppFailed,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,6 +160,10 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
_thumbKeys.putIfAbsent(id, () => GlobalKey<_ThumbnailState>());
|
||||
|
||||
void _openPreview(GalleryItem item) {
|
||||
if (item.isVideo) {
|
||||
_toggleSelection(item);
|
||||
return;
|
||||
}
|
||||
final thumbKey = _thumbKey(item.id);
|
||||
final hero = PhotoHeroController(
|
||||
origin: () => photoHeroRect(thumbKey),
|
||||
@@ -1089,7 +1093,7 @@ class _ThumbnailState extends State<_Thumbnail> {
|
||||
}
|
||||
|
||||
void _resolveProvider() {
|
||||
final file = widget.editedFile ?? widget.item.localFile;
|
||||
final file = widget.editedFile ?? (widget.item.isVideo ? null : widget.item.localFile);
|
||||
if (file != null) {
|
||||
_provider = ResizeImage(
|
||||
FileImage(file),
|
||||
@@ -1102,7 +1106,7 @@ class _ThumbnailState extends State<_Thumbnail> {
|
||||
final id = widget.item.id;
|
||||
widget.item.thumbnail(_pixelSize).then((data) {
|
||||
if (!mounted || data == null || widget.item.id != id) return;
|
||||
if (widget.editedFile != null || widget.item.localFile != null) return;
|
||||
if (widget.editedFile != null) return;
|
||||
setState(() => _provider = MemoryImage(data));
|
||||
});
|
||||
}
|
||||
@@ -1119,5 +1123,16 @@ class _ThumbnailState extends State<_Thumbnail> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholder() => ColoredBox(color: widget.cs.surfaceContainerHighest);
|
||||
Widget _placeholder() => ColoredBox(
|
||||
color: widget.cs.surfaceContainerHighest,
|
||||
child: widget.item.isVideo
|
||||
? Center(
|
||||
child: Icon(
|
||||
Symbols.movie,
|
||||
size: 28,
|
||||
color: widget.cs.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../../../core/config/komet_settings.dart';
|
||||
import '../../../../core/utils/format.dart';
|
||||
import '../../../../models/attachment.dart';
|
||||
import '../../formatted_message_text.dart';
|
||||
import '../../sending_clock_icon.dart';
|
||||
import '../../photo_viewer.dart';
|
||||
|
||||
enum MessageType { text, attachment, voice, control }
|
||||
@@ -202,6 +203,9 @@ class BubbleContext {
|
||||
|
||||
Widget _statusIconFor(String? status, {Color? color, double size = 14}) {
|
||||
final v = messageStatusVisual(status, dimColor: color ?? dim);
|
||||
if (isSendingStatus(status)) {
|
||||
return SendingClockIcon(color: v.color, size: size);
|
||||
}
|
||||
return Icon(v.icon, size: size, color: v.color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import '../../../../models/attachment.dart';
|
||||
import '../../custom_notification.dart';
|
||||
import '../../decrypted_photo.dart';
|
||||
import '../../photo_viewer.dart';
|
||||
import '../../upload_progress_ring.dart';
|
||||
import 'bubble_context.dart';
|
||||
|
||||
class FileBubble extends StatelessWidget {
|
||||
@@ -69,11 +70,24 @@ class FileBubble extends StatelessWidget {
|
||||
color: isMe ? ctx.systemTint : ctx.cs.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
Symbols.description,
|
||||
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
||||
size: 20,
|
||||
),
|
||||
child: ctx.uploadProgress == null
|
||||
? Icon(
|
||||
Symbols.description,
|
||||
color: isMe
|
||||
? ctx.cs.onPrimaryContainer
|
||||
: ctx.cs.primary,
|
||||
size: 20,
|
||||
)
|
||||
: UploadProgressRing(
|
||||
progress: ctx.uploadProgress!,
|
||||
color: isMe
|
||||
? ctx.cs.onPrimaryContainer
|
||||
: ctx.cs.primary,
|
||||
size: 38,
|
||||
strokeWidth: 2.4,
|
||||
iconSize: 14,
|
||||
padding: const EdgeInsets.all(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../../models/attachment.dart';
|
||||
import '../../lottie_image.dart';
|
||||
import '../../sending_clock_icon.dart';
|
||||
import 'bubble_context.dart';
|
||||
|
||||
class StickerBubble extends StatelessWidget {
|
||||
@@ -86,6 +87,9 @@ class StickerBubble extends StatelessWidget {
|
||||
Widget _buildStickerStatusIcon() {
|
||||
final status = ctx.overrideStatus ?? ctx.message.status;
|
||||
final v = messageStatusVisual(status, dimColor: Colors.white);
|
||||
if (isSendingStatus(status)) {
|
||||
return SendingClockIcon(color: v.color, size: 13);
|
||||
}
|
||||
return Icon(v.icon, size: 13, color: v.color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:komet/main.dart';
|
||||
|
||||
import '../../../../core/media/preview_image.dart';
|
||||
import '../../../../core/utils/format.dart';
|
||||
import '../../../../core/utils/haptics.dart';
|
||||
import '../../../../models/attachment.dart';
|
||||
import '../../custom_notification.dart';
|
||||
import '../../upload_progress_ring.dart';
|
||||
import '../../photo_viewer.dart';
|
||||
import 'bubble_context.dart';
|
||||
import 'video_note_bubble.dart';
|
||||
@@ -63,39 +65,78 @@ class VideoBubble extends StatelessWidget {
|
||||
child: Icon(Symbols.videocam, size: 48, color: ctx.cs.onSurfaceVariant),
|
||||
);
|
||||
|
||||
final localThumb = dataUriImage(video, video.previewData);
|
||||
final uploading = ctx.uploadProgress;
|
||||
|
||||
Widget previewImage() {
|
||||
if (previewUrl.isNotEmpty && !previewUrl.startsWith('data:')) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: previewUrl,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (width * dpr).round(),
|
||||
fadeInDuration: Duration.zero,
|
||||
placeholderFadeInDuration: Duration.zero,
|
||||
errorWidget: (_, _, _) => localThumb == null
|
||||
? placeholder()
|
||||
: Image(
|
||||
image: localThumb,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (localThumb != null) {
|
||||
return Image(
|
||||
image: localThumb,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => placeholder(),
|
||||
);
|
||||
}
|
||||
return placeholder();
|
||||
}
|
||||
|
||||
final preview = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(BubbleContext.photoBorderRadius),
|
||||
child: Stack(
|
||||
children: [
|
||||
previewUrl.isEmpty
|
||||
? placeholder()
|
||||
: CachedNetworkImage(
|
||||
imageUrl: previewUrl,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (width * dpr).round(),
|
||||
fadeInDuration: Duration.zero,
|
||||
placeholderFadeInDuration: Duration.zero,
|
||||
errorWidget: (_, _, _) => placeholder(),
|
||||
previewImage(),
|
||||
if (uploading != null)
|
||||
Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Colors.black.withValues(alpha: 0.35),
|
||||
child: Center(
|
||||
child: UploadProgressRing(
|
||||
progress: uploading,
|
||||
color: Colors.white,
|
||||
trackColor: Colors.white24,
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black54,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Symbols.play_arrow,
|
||||
color: Colors.white,
|
||||
size: 30,
|
||||
),
|
||||
)
|
||||
else
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black54,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Symbols.play_arrow,
|
||||
color: Colors.white,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (durationMs != null && durationMs > 0)
|
||||
Positioned(
|
||||
left: 6,
|
||||
@@ -112,12 +153,13 @@ class VideoBubble extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _playVideo(ctx.context, video),
|
||||
if (uploading == null)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _playVideo(ctx.context, video),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import '../../core/utils/webview_support.dart';
|
||||
import '../../core/config/app_link_preview.dart';
|
||||
import 'custom_notification.dart';
|
||||
import 'formatted_message_text.dart';
|
||||
import 'sending_clock_icon.dart';
|
||||
import 'photo_viewer.dart';
|
||||
import 'selectable_message_text.dart';
|
||||
import '../../models/attachment.dart';
|
||||
@@ -1371,7 +1372,10 @@ class MessageBubble extends StatelessWidget {
|
||||
),
|
||||
if (ctx.isMe) ...[
|
||||
const SizedBox(width: 3),
|
||||
Icon(statusVisual.icon, size: 13, color: statusVisual.color),
|
||||
if (isSendingStatus(status))
|
||||
SendingClockIcon(color: statusVisual.color, size: 13)
|
||||
else
|
||||
Icon(statusVisual.icon, size: 13, color: statusVisual.color),
|
||||
],
|
||||
if (ctx.message.deleted) ...[
|
||||
const SizedBox(width: 3),
|
||||
@@ -1677,6 +1681,27 @@ class MessageBubble extends StatelessWidget {
|
||||
final rawPreview = reply.previewText();
|
||||
final quotedId = reply.messageId;
|
||||
|
||||
if (reply.missing) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 3, 8, 3),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: accent.withValues(alpha: 0.10),
|
||||
border: Border(left: BorderSide(color: accent, width: 3)),
|
||||
),
|
||||
child: Text(
|
||||
'сообщение удалено',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: textColor.withValues(alpha: 0.7),
|
||||
fontSize: 13,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final quote = Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 3, 8, 3),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart'
|
||||
show OverScrollHeaderStretchConfiguration;
|
||||
|
||||
class HeaderPullScrollPhysics extends ScrollPhysics {
|
||||
final double delta;
|
||||
final ValueGetter<bool> isArmed;
|
||||
|
||||
const HeaderPullScrollPhysics({
|
||||
required this.delta,
|
||||
required this.isArmed,
|
||||
super.parent,
|
||||
});
|
||||
|
||||
static final SpringDescription _expressiveSpring =
|
||||
SpringDescription.withDampingRatio(mass: 1, stiffness: 380, ratio: 0.9);
|
||||
|
||||
static const double _flingVelocity = 400;
|
||||
|
||||
@override
|
||||
HeaderPullScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return HeaderPullScrollPhysics(
|
||||
delta: delta,
|
||||
isArmed: isArmed,
|
||||
parent: buildParent(ancestor),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
|
||||
if (delta <= 0 || offset <= 0 || position.pixels <= 0) {
|
||||
return super.applyPhysicsToUserOffset(position, offset);
|
||||
}
|
||||
final px = position.pixels;
|
||||
final free = math.max(0.0, px - delta);
|
||||
if (offset <= free) return offset;
|
||||
if (!isArmed()) return free;
|
||||
final inZone = offset - free;
|
||||
final expandedFraction = (1 - math.min(px, delta) / delta).clamp(0.0, 1.0);
|
||||
final friction = lerpDouble(0.58, 0.3, expandedFraction)!;
|
||||
return free + inZone * friction;
|
||||
}
|
||||
|
||||
@override
|
||||
Simulation? createBallisticSimulation(
|
||||
ScrollMetrics position,
|
||||
double velocity,
|
||||
) {
|
||||
if (delta > 0) {
|
||||
final px = position.pixels;
|
||||
final tolerance = toleranceFor(position);
|
||||
if (px > 0 && px < delta) {
|
||||
final collapsed = math.min(delta, position.maxScrollExtent);
|
||||
final double target;
|
||||
if (velocity <= -_flingVelocity) {
|
||||
target = 0;
|
||||
} else if (velocity >= _flingVelocity) {
|
||||
target = collapsed;
|
||||
} else {
|
||||
target = px < delta / 2 ? 0 : collapsed;
|
||||
}
|
||||
if ((target - px).abs() < tolerance.distance &&
|
||||
velocity.abs() < tolerance.velocity) {
|
||||
return null;
|
||||
}
|
||||
return ScrollSpringSimulation(
|
||||
_expressiveSpring,
|
||||
px,
|
||||
target,
|
||||
velocity,
|
||||
tolerance: tolerance,
|
||||
);
|
||||
}
|
||||
if (px >= delta && velocity < 0) {
|
||||
return BouncingScrollSimulation(
|
||||
position: px,
|
||||
velocity: velocity,
|
||||
leadingExtent: delta,
|
||||
trailingExtent: math.max(delta, position.maxScrollExtent),
|
||||
spring: spring,
|
||||
tolerance: tolerance,
|
||||
);
|
||||
}
|
||||
}
|
||||
return super.createBallisticSimulation(position, velocity);
|
||||
}
|
||||
}
|
||||
|
||||
class MorphHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||
final double collapsedExtent;
|
||||
final double expandedExtent;
|
||||
final Widget Function(BuildContext context, double t) headerBuilder;
|
||||
|
||||
MorphHeaderDelegate({
|
||||
required this.collapsedExtent,
|
||||
required this.expandedExtent,
|
||||
required this.headerBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
double get minExtent => collapsedExtent;
|
||||
|
||||
@override
|
||||
double get maxExtent => expandedExtent;
|
||||
|
||||
@override
|
||||
OverScrollHeaderStretchConfiguration get stretchConfiguration =>
|
||||
OverScrollHeaderStretchConfiguration();
|
||||
|
||||
@override
|
||||
Widget build(
|
||||
BuildContext context,
|
||||
double shrinkOffset,
|
||||
bool overlapsContent,
|
||||
) {
|
||||
final range = expandedExtent - collapsedExtent;
|
||||
final t = range <= 0 ? 0.0 : (1 - shrinkOffset / range).clamp(0.0, 1.0);
|
||||
return headerBuilder(context, t);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRebuild(covariant MorphHeaderDelegate oldDelegate) => true;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
|
||||
import '../../core/config/app_animations.dart';
|
||||
|
||||
class SendingClockIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
final double size;
|
||||
|
||||
const SendingClockIcon({super.key, required this.color, this.size = 14});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.square(
|
||||
dimension: size,
|
||||
child: Lottie.asset(
|
||||
AppAnimations.clock,
|
||||
repeat: true,
|
||||
fit: BoxFit.contain,
|
||||
delegates: LottieDelegates(
|
||||
values: [
|
||||
ValueDelegate.color(const ['**'], value: color),
|
||||
ValueDelegate.strokeColor(const ['**'], value: color),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool isSendingStatus(String? status) =>
|
||||
status == 'sending' || status == 'pending';
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class UploadProgressRing extends StatelessWidget {
|
||||
final ValueListenable<List<double>> progress;
|
||||
final Color color;
|
||||
final Color? trackColor;
|
||||
final double size;
|
||||
final double strokeWidth;
|
||||
final double iconSize;
|
||||
final EdgeInsets padding;
|
||||
|
||||
const UploadProgressRing({
|
||||
super.key,
|
||||
required this.progress,
|
||||
required this.color,
|
||||
this.trackColor,
|
||||
this.size = 54,
|
||||
this.strokeWidth = 3,
|
||||
this.iconSize = 22,
|
||||
this.padding = EdgeInsets.zero,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<List<double>>(
|
||||
valueListenable: progress,
|
||||
builder: (context, values, _) {
|
||||
final value = values.isEmpty
|
||||
? 0.0
|
||||
: values.reduce((a, b) => a + b) / values.length;
|
||||
final done = value >= 1.0;
|
||||
return SizedBox.square(
|
||||
dimension: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: padding,
|
||||
child: SizedBox.expand(
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(end: value.clamp(0.0, 1.0)),
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOut,
|
||||
builder: (context, shown, _) => CircularProgressIndicator(
|
||||
value: done ? null : shown,
|
||||
strokeWidth: strokeWidth,
|
||||
backgroundColor:
|
||||
trackColor ?? color.withValues(alpha: 0.25),
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(Symbols.close, size: iconSize, color: color),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1029,6 +1029,8 @@
|
||||
"contactBubbleNew": "New contact",
|
||||
"contactBubbleAlreadyAdded": "Already in your contacts",
|
||||
"contactBubbleOpenProfile": "Open profile",
|
||||
"miniAppOpen": "Open",
|
||||
"miniAppFailed": "Couldn't open the app",
|
||||
"editContactMenu": "Edit contact",
|
||||
"editContactTitle": "Edit contact",
|
||||
"editContactFirstName": "First name",
|
||||
|
||||
@@ -4502,6 +4502,18 @@ abstract class AppLocalizations {
|
||||
/// **'Open profile'**
|
||||
String get contactBubbleOpenProfile;
|
||||
|
||||
/// No description provided for @miniAppOpen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Open'**
|
||||
String get miniAppOpen;
|
||||
|
||||
/// No description provided for @miniAppFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Couldn\'t open the app'**
|
||||
String get miniAppFailed;
|
||||
|
||||
/// No description provided for @editContactMenu.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -2351,6 +2351,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get contactBubbleOpenProfile => 'Open profile';
|
||||
|
||||
@override
|
||||
String get miniAppOpen => 'Open';
|
||||
|
||||
@override
|
||||
String get miniAppFailed => 'Couldn\'t open the app';
|
||||
|
||||
@override
|
||||
String get editContactMenu => 'Edit contact';
|
||||
|
||||
|
||||
@@ -2365,6 +2365,12 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get contactBubbleOpenProfile => 'Открыть профиль';
|
||||
|
||||
@override
|
||||
String get miniAppOpen => 'Открыть';
|
||||
|
||||
@override
|
||||
String get miniAppFailed => 'Не удалось открыть приложение';
|
||||
|
||||
@override
|
||||
String get editContactMenu => 'Редактировать контакт';
|
||||
|
||||
|
||||
@@ -773,6 +773,8 @@
|
||||
"contactBubbleNew": "Новый контакт",
|
||||
"contactBubbleAlreadyAdded": "Уже твой контакт",
|
||||
"contactBubbleOpenProfile": "Открыть профиль",
|
||||
"miniAppOpen": "Открыть",
|
||||
"miniAppFailed": "Не удалось открыть приложение",
|
||||
"editContactMenu": "Редактировать контакт",
|
||||
"editContactTitle": "Редактировать контакт",
|
||||
"editContactFirstName": "Имя",
|
||||
|
||||
@@ -132,6 +132,7 @@ class VideoAttachment extends MessageAttachment {
|
||||
final int? height;
|
||||
final int? duration;
|
||||
final int? size;
|
||||
final String? localPath;
|
||||
|
||||
final int? videoType;
|
||||
|
||||
@@ -149,6 +150,7 @@ class VideoAttachment extends MessageAttachment {
|
||||
this.duration,
|
||||
this.size,
|
||||
this.videoType,
|
||||
this.localPath,
|
||||
}) : super(type: AttachmentType.video);
|
||||
|
||||
factory VideoAttachment.fromMap(Map<String, dynamic> map) {
|
||||
|
||||
Reference in New Issue
Block a user