feat/fix/refactor: Кнопка добавления человека в контакты, возможность развертывания аватарки на экране профиля, включение сторисов по умолчанию, показ сторисов на экране чатов, чата, и при просмотре списка участников, миниапы у ботов. Фикс прогресса просмотра сториса, фикс предпросмотра медиав пикере, прогресс бар отправки фото/видео/файлов + фото продолжат отправку даже если выйти с чата. Вынес превью фото, добавил отдельный модуль отправки медиа

This commit is contained in:
Jganenokk
2026-08-05 20:04:12 +07:00
parent e87d3d57f7
commit 3915cb957e
33 changed files with 2522 additions and 680 deletions
+7 -1
View File
@@ -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 {
+338
View File
@@ -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,
);
}
}
+2
View File
@@ -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'];
+114 -1
View File
@@ -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;
+5
View File
@@ -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;
+1 -1
View File
@@ -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,
+127
View File
@@ -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;
}
}
+73 -21
View File
@@ -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);
}
+20
View 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;
+624 -130
View File
@@ -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);
+187 -273
View File
@@ -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();
+3 -123
View File
@@ -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;
}
+76 -37
View File
@@ -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),
),
),
),
],
),
);
+26 -1
View File
@@ -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),
],
),
);
},
);
}
}
+2
View File
@@ -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",
+12
View File
@@ -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:
+6
View File
@@ -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';
+6
View File
@@ -2365,6 +2365,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get contactBubbleOpenProfile => 'Открыть профиль';
@override
String get miniAppOpen => 'Открыть';
@override
String get miniAppFailed => 'Не удалось открыть приложение';
@override
String get editContactMenu => 'Редактировать контакт';
+2
View File
@@ -773,6 +773,8 @@
"contactBubbleNew": "Новый контакт",
"contactBubbleAlreadyAdded": "Уже твой контакт",
"contactBubbleOpenProfile": "Открыть профиль",
"miniAppOpen": "Открыть",
"miniAppFailed": "Не удалось открыть приложение",
"editContactMenu": "Редактировать контакт",
"editContactTitle": "Редактировать контакт",
"editContactFirstName": "Имя",
+2
View File
@@ -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) {