feat: норм отправка кружком как дфывйцждуозйшо / dev 18

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AEefQULnwjVZt2yzvK8CWn
This commit is contained in:
Jganenokk
2026-08-08 14:54:45 +07:00
co-authored by Claude Opus 5
parent 7bbc77192a
commit 08d29ef996
21 changed files with 1067 additions and 319 deletions
+1
View File
@@ -5,3 +5,4 @@
ВМЕСТО СНЕКБАРОВ ИСПОЛЬЗУЙ НАШИ КАСТОМНЫЕ УВЕДОМЛЕНИЕ showCustomNotification(context, 'текст') ВМЕСТО СНЕКБАРОВ ИСПОЛЬЗУЙ НАШИ КАСТОМНЫЕ УВЕДОМЛЕНИЕ showCustomNotification(context, 'текст')
Never leave real data in test files, including existing message contents or real IDs captured from requests. Use synthetic fixtures instead. Never leave real data in test files, including existing message contents or real IDs captured from requests. Use synthetic fixtures instead.
Не пытайся собирать APK/AAB (flutter build apk, flutter build appbundle, gradle assemble) — сборку запускает пользователь, тебе достаточно flutter analyze Не пытайся собирать APK/AAB (flutter build apk, flutter build appbundle, gradle assemble) — сборку запускает пользователь, тебе достаточно flutter analyze
Если делаешь кнопку, где иконка переключается перечёркнутая/неперечёркнутая (вспышка, микрофон, звук, уведомления) — она должна анимироваться lottie-иконкой, а не подменяться мгновенно: добавь спеку в SLASH_SPECS в tool/make_morph_icons.py (fill=1.0, если кнопка рисует Icon(..., fill: 1)), прогони python3 tool/make_morph_icons.py и выводи через LottieSlashIcon. Ассеты в assets/lottie/ руками не правь — только через генератор.
+14
View File
@@ -71,6 +71,20 @@ Incoming packets: transport → dispatcher → backend module → state → UI r
- When a fix can be done quickly with a hack or properly with a rewrite, **choose the proper rewrite**. - When a fix can be done quickly with a hack or properly with a rewrite, **choose the proper rewrite**.
- Quality over quantity. - Quality over quantity.
- **Never leave real data in test files**, including existing message contents or real IDs captured from requests. Use synthetic fixtures instead. - **Never leave real data in test files**, including existing message contents or real IDs captured from requests. Use synthetic fixtures instead.
- **A button whose icon toggles between plain and slashed** (flash on/off, mic muted, sound, notifications) **must animate with a Lottie icon** — never swap two `Icon`s instantly. See *Animated icons* below.
## Animated icons
Everything in `assets/lottie/` is generated from the Material Symbols font by `tool/make_morph_icons.py` (stdlib-only Python, no deps). Never hand-edit the JSON — add a spec and re-run `python3 tool/make_morph_icons.py`.
| Kind | Spec list | Widget |
|------|-----------|--------|
| Morph between two glyphs | `SPECS` | `ComposerMorphIcon` |
| Plain ↔ slashed toggle | `SLASH_SPECS` | `LottieSlashIcon` |
A slash spec takes the plain and slashed codepoints; the generator lays both glyphs out as static layers and sweeps a mask across the diagonal, so the slash looks drawn on top of the icon. Pass `fill=1.0` when the button renders `Icon(..., fill: 1)` — contours are then taken from the `FILL=1` instance of the variable font.
`LottieSlashIcon` plays the asset forward when `slashed` turns true and backward when it turns false, so a single asset covers both directions. The older `AnimatedSlashIcon` (clip wipe over two glyphs) stays where it is already used; new buttons use the Lottie one.
## Localization ## Localization
File diff suppressed because one or more lines are too long
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -5,7 +7,7 @@ import 'package:flutter/widgets.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../main.dart' show KometApp; import '../../main.dart' show KometApp;
enum UploadKind { photo, video, file } enum UploadKind { photo, video, videoNote, voice, file }
class _NotificationJob { class _NotificationJob {
_NotificationJob({required this.kind, required this.count, this.filename}); _NotificationJob({required this.kind, required this.count, this.filename});
@@ -41,6 +43,8 @@ class _NotificationJob {
return switch (kind) { return switch (kind) {
UploadKind.photo => l10n.uploadNotificationPhotos(count), UploadKind.photo => l10n.uploadNotificationPhotos(count),
UploadKind.video => l10n.uploadNotificationVideo, UploadKind.video => l10n.uploadNotificationVideo,
UploadKind.videoNote => l10n.uploadNotificationVideoNote,
UploadKind.voice => l10n.uploadNotificationVoice,
UploadKind.file => UploadKind.file =>
name == null || name.isEmpty ? l10n.uploadNotificationFile : name, name == null || name.isEmpty ? l10n.uploadNotificationFile : name,
}; };
@@ -52,8 +56,10 @@ class UploadNotificationService {
'ru.komet.app/upload_service', 'ru.komet.app/upload_service',
); );
static const int _minIntervalMs = 350; static const int _minIntervalMs = 350;
static const Duration _startDelay = Duration(milliseconds: 700);
static final Map<String, _NotificationJob> _jobs = {}; static final Map<String, _NotificationJob> _jobs = {};
static Timer? _startTimer;
static bool _running = false; static bool _running = false;
static String? _lastTitle; static String? _lastTitle;
static String? _lastBody; static String? _lastBody;
@@ -75,7 +81,14 @@ class UploadNotificationService {
count: count < 1 ? 1 : count, count: count < 1 ? 1 : count,
filename: filename, filename: filename,
); );
_push(force: true); if (_running) {
_push(force: true);
return;
}
_startTimer ??= Timer(_startDelay, () {
_startTimer = null;
_push(force: true);
});
} }
static void report( static void report(
@@ -102,16 +115,20 @@ class UploadNotificationService {
} }
static void _stop() { static void _stop() {
_startTimer?.cancel();
_startTimer = null;
final wasRunning = _running;
_running = false; _running = false;
_lastTitle = null; _lastTitle = null;
_lastBody = null; _lastBody = null;
_lastPercent = -1; _lastPercent = -1;
_lastPushAt = 0; _lastPushAt = 0;
_invoke('stop', const <String, dynamic>{}); if (wasRunning) _invoke('stop', const <String, dynamic>{});
} }
static void _push({bool force = false}) { static void _push({bool force = false}) {
if (_jobs.isEmpty) return; if (_jobs.isEmpty) return;
if (!_running && _startTimer != null) return;
var sumSent = 0; var sumSent = 0;
var sumTotal = 0; var sumTotal = 0;
+100
View File
@@ -291,6 +291,106 @@ class UploadService {
); );
} }
Future<void> sendVoice({
required int accountId,
required int chatId,
required String tempId,
required File file,
required int durationMs,
required Uint8List wave,
CachedMessage? placeholder,
}) {
return _sendRecording(
accountId: accountId,
chatId: chatId,
tempId: tempId,
file: file,
kind: UploadKind.voice,
placeholder: placeholder,
requestUpload: () async {
final info = await messagesModule.requestAudioUploadUrl();
return info == null ? null : (url: info.url, token: info.token);
},
send: (token) => messagesModule.sendAudioMessage(
chatId,
token,
duration: durationMs,
wave: wave,
),
);
}
Future<void> sendVideoNote({
required int accountId,
required int chatId,
required String tempId,
required File file,
required int durationMs,
CachedMessage? placeholder,
}) {
return _sendRecording(
accountId: accountId,
chatId: chatId,
tempId: tempId,
file: file,
kind: UploadKind.videoNote,
placeholder: placeholder,
requestUpload: () async {
final info = await messagesModule.requestVideoNoteUploadUrl();
return info == null ? null : (url: info.url, token: info.token);
},
send: (token) => messagesModule.sendVideoNoteMessage(
chatId,
token,
duration: durationMs,
),
);
}
Future<void> _sendRecording({
required int accountId,
required int chatId,
required String tempId,
required File file,
required UploadKind kind,
required Future<({String url, String token})?> Function() requestUpload,
required Future<Map<String, dynamic>?> Function(String token) send,
CachedMessage? placeholder,
}) {
return _run(
UploadJob._(
id: tempId,
accountId: accountId,
chatId: chatId,
kind: kind,
slots: 1,
placeholder: placeholder,
),
(job) async {
try {
final info = await requestUpload();
if (info == null || info.url.isEmpty) {
throw const UploadFailure('no_upload_url');
}
final ok = await fileUploader.uploadMediaFile(
Uri.parse(info.url),
file,
onProgress: (sent, total) => job.report(0, sent, total),
);
if (!ok) throw const UploadFailure('upload_failed');
job.markUploaded();
final sent = await send(info.token);
if (sent == null) return null;
return CachedMessage.fromPushPayload(accountId, chatId, sent);
} finally {
try {
await file.delete();
} catch (_) {}
}
},
);
}
Future<void> sendFile({ Future<void> sendFile({
required int accountId, required int accountId,
required int chatId, required int chatId,
@@ -6,6 +6,7 @@ import 'dart:ui' as ui;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/services.dart' show rootBundle;
import 'package:lottie/lottie.dart' show AssetLottie;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -16,8 +17,11 @@ import '../../../../core/media/native_video_note_recorder.dart';
import '../../../../core/utils/haptics.dart'; import '../../../../core/utils/haptics.dart';
import '../../../../core/utils/logger.dart'; import '../../../../core/utils/logger.dart';
import '../../../widgets/custom_notification.dart'; import '../../../widgets/custom_notification.dart';
import '../../../widgets/lottie_slash_icon.dart';
import 'voice_record_controller.dart'; import 'voice_record_controller.dart';
const String _flashIcon = 'assets/lottie/ic_flash_on_to_off.json';
class VideoNoteController { class VideoNoteController {
VideoNoteController({ VideoNoteController({
required this.contextOf, required this.contextOf,
@@ -89,6 +93,7 @@ class VideoNoteController {
bool get _stub => !_rec.isAvailable; bool get _stub => !_rec.isAvailable;
Future<void> _initCamera() async { Future<void> _initCamera() async {
unawaited(AssetLottie(_flashIcon).load());
if (_stub) { if (_stub) {
_camReady.value = true; _camReady.value = true;
_textureId.value = null; _textureId.value = null;
@@ -457,17 +462,24 @@ class _CameraControls extends StatelessWidget {
children: [ children: [
if (controller.cameraControlsAvailable) if (controller.cameraControlsAvailable)
_ControlButton( _ControlButton(
icon: Symbols.flip_camera_ios,
color: cs.onSurface,
onTap: controller.flipCamera, onTap: controller.flipCamera,
child: Icon(
Symbols.flip_camera_ios,
size: 24,
color: cs.onSurface,
fill: 1,
),
), ),
if (controller.flashAvailable) if (controller.flashAvailable)
ValueListenableBuilder<bool>( ValueListenableBuilder<bool>(
valueListenable: controller.flashOn, valueListenable: controller.flashOn,
builder: (context, on, _) => _ControlButton( builder: (context, on, _) => _ControlButton(
icon: on ? Symbols.flash_on : Symbols.flash_off,
color: on ? cs.primary : cs.onSurface,
onTap: controller.toggleFlash, onTap: controller.toggleFlash,
child: LottieSlashIcon(
asset: _flashIcon,
slashed: !on,
color: on ? cs.primary : cs.onSurface,
),
), ),
), ),
], ],
@@ -477,14 +489,9 @@ class _CameraControls extends StatelessWidget {
} }
class _ControlButton extends StatelessWidget { class _ControlButton extends StatelessWidget {
const _ControlButton({ const _ControlButton({required this.child, required this.onTap});
required this.icon,
required this.color,
required this.onTap,
});
final IconData icon; final Widget child;
final Color color;
final VoidCallback onTap; final VoidCallback onTap;
@override @override
@@ -492,10 +499,7 @@ class _ControlButton extends StatelessWidget {
return InkResponse( return InkResponse(
onTap: onTap, onTap: onTap,
radius: 26, radius: 26,
child: Padding( child: Padding(padding: const EdgeInsets.all(8), child: child),
padding: const EdgeInsets.all(8),
child: Icon(icon, size: 24, color: color, fill: 1),
),
); );
} }
} }
+46 -158
View File
@@ -337,7 +337,6 @@ class _ChatScreenState extends State<ChatScreen>
final ValueNotifier<ReactionAnimationEvent?> _reactionAnimation = final ValueNotifier<ReactionAnimationEvent?> _reactionAnimation =
ValueNotifier(null); ValueNotifier(null);
int _reactionAnimationToken = 0; int _reactionAnimationToken = 0;
final Map<String, ValueNotifier<List<double>>> _photoUploadProgress = {};
final ValueNotifier<int> _scheduledCount = ValueNotifier(0); final ValueNotifier<int> _scheduledCount = ValueNotifier(0);
late final VoiceRecordController _voiceRec = VoiceRecordController( late final VoiceRecordController _voiceRec = VoiceRecordController(
@@ -358,7 +357,7 @@ class _ChatScreenState extends State<ChatScreen>
StreamSubscription<UploadJobEvent>? _uploadEventSub; StreamSubscription<UploadJobEvent>? _uploadEventSub;
ValueListenable<List<double>>? _photoProgressFor(CachedMessage m) => ValueListenable<List<double>>? _photoProgressFor(CachedMessage m) =>
_photoUploadProgress[m.id] ?? UploadService.instance.progressFor(m.id); UploadService.instance.progressFor(m.id);
ValueNotifier<Map<String, dynamic>?> _reactionNotifierFor(CachedMessage m) { ValueNotifier<Map<String, dynamic>?> _reactionNotifierFor(CachedMessage m) {
final existing = _reactionNotifiers[m.id]; final existing = _reactionNotifiers[m.id];
@@ -2088,10 +2087,6 @@ class _ChatScreenState extends State<ChatScreen>
} }
_reactionNotifiers.clear(); _reactionNotifiers.clear();
_reactionAnimation.dispose(); _reactionAnimation.dispose();
for (final n in _photoUploadProgress.values) {
n.dispose();
}
_photoUploadProgress.clear();
ChatActivityStore.instance ChatActivityStore.instance
.listenable(widget.chatId) .listenable(widget.chatId)
.removeListener(_recomputeHeaderStatus); .removeListener(_recomputeHeaderStatus);
@@ -6022,77 +6017,24 @@ class _ChatScreenState extends State<ChatScreen>
return; return;
} }
final wave = _buildWave(amps); final wave = _buildWave(amps);
final tempId = _nextTempId(); final placeholder = _addOptimisticMediaMessage(
final progress = ValueNotifier<List<double>>(const [0]); AudioAttachment(
_photoUploadProgress[tempId] = progress; duration: durationMs,
_messages.add( waveform: String.fromCharCodes(wave),
CachedMessage(
id: tempId,
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
time: DateTime.now().millisecondsSinceEpoch,
status: 'sending',
attachments: [AudioAttachment(duration: durationMs)],
), ),
); );
_lastSentId = tempId;
_bumpMessages();
Haptics.send();
_scrollToBottom();
try { unawaited(
final info = await messagesModule.requestAudioUploadUrl(); UploadService.instance.sendVoice(
if (info == null || info.url.isEmpty) throw Exception('no_url'); accountId: _myId,
chatId: widget.chatId,
final ok = await fileUploader.uploadMediaFile( tempId: placeholder.id,
Uri.parse(info.url), file: file,
file, durationMs: durationMs,
onProgress: (sent, total) {
if (total > 0) progress.value = [(sent / total).clamp(0.0, 1.0)];
},
);
if (!ok) throw Exception('upload_failed');
if (!mounted) {
_disposePhotoProgress(tempId);
return;
}
final serverMsg = await messagesModule.sendAudioMessage(
widget.chatId,
info.token,
duration: durationMs,
wave: wave, wave: wave,
); placeholder: placeholder,
if (!mounted) { ),
_disposePhotoProgress(tempId); );
return;
}
if (serverMsg == null) throw Exception('send_failed');
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) {
_failPhotoMessage(tempId);
} else {
_disposePhotoProgress(tempId);
}
} finally {
try {
await file.delete();
} catch (_) {}
}
} }
Future<void> _sendVideoNote(File file, int durationMs) async { Future<void> _sendVideoNote(File file, int durationMs) async {
@@ -6102,85 +6044,23 @@ class _ChatScreenState extends State<ChatScreen>
} catch (_) {} } catch (_) {}
return; return;
} }
final tempId = _nextTempId(); final placeholder = _addOptimisticMediaMessage(
final progress = ValueNotifier<List<double>>(const [0]); VideoAttachment(duration: durationMs, videoType: 1, localPath: file.path),
_photoUploadProgress[tempId] = progress; );
_messages.add(
CachedMessage( unawaited(
id: tempId, UploadService.instance.sendVideoNote(
accountId: _myId, accountId: _myId,
chatId: widget.chatId, chatId: widget.chatId,
senderId: _myId, tempId: placeholder.id,
time: DateTime.now().millisecondsSinceEpoch, file: file,
status: 'sending', durationMs: durationMs,
attachments: [VideoAttachment(duration: durationMs, videoType: 1)], placeholder: placeholder,
), ),
); );
_lastSentId = tempId;
_bumpMessages();
Haptics.send();
_scrollToBottom();
try {
final info = await messagesModule.requestVideoNoteUploadUrl();
if (info == null || info.url.isEmpty) throw Exception('no_url');
final ok = await fileUploader.uploadMediaFile(
Uri.parse(info.url),
file,
onProgress: (sent, total) {
if (total > 0) progress.value = [(sent / total).clamp(0.0, 1.0)];
},
);
if (!ok) throw Exception('upload_failed');
if (!mounted) {
_disposePhotoProgress(tempId);
return;
}
final serverMsg = await messagesModule.sendVideoNoteMessage(
widget.chatId,
info.token,
duration: durationMs,
);
if (!mounted) {
_disposePhotoProgress(tempId);
return;
}
if (serverMsg == null) throw Exception('send_failed');
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 (e) {
logger.w('sendVideoNote failed: $e');
if (mounted) {
_failPhotoMessage(tempId);
final reason = e is PacketError
? '${e.errorKey ?? ''} ${e.message}'.trim()
: e is Exception && e.toString().contains('send_failed')
? 'сервер не обработал видео'
: e is Exception && e.toString().contains('upload_failed')
? 'загрузка отклонена'
: e.toString();
showCustomNotification(context, 'Кружок не отправлен: $reason');
} else {
_disposePhotoProgress(tempId);
}
} finally {
try {
await file.delete();
} catch (_) {}
}
} }
CachedMessage _addOptimisticFileMessage(FileAttachment attachment) { CachedMessage _addOptimisticMediaMessage(MessageAttachment attachment) {
final now = DateTime.now().millisecondsSinceEpoch; final now = DateTime.now().millisecondsSinceEpoch;
final tempId = _nextTempId(); final tempId = _nextTempId();
final msg = CachedMessage( final msg = CachedMessage(
@@ -6225,7 +6105,7 @@ class _ChatScreenState extends State<ChatScreen>
} }
Future<void> _sendHistoryFile(FileHistoryEntry entry) async { Future<void> _sendHistoryFile(FileHistoryEntry entry) async {
final tempId = _addOptimisticFileMessage( final tempId = _addOptimisticMediaMessage(
FileAttachment( FileAttachment(
fileId: entry.fileId, fileId: entry.fileId,
fileToken: entry.token, fileToken: entry.token,
@@ -6251,7 +6131,7 @@ class _ChatScreenState extends State<ChatScreen>
} }
Future<bool> _sendFileById(int fileId) async { Future<bool> _sendFileById(int fileId) async {
final tempId = _addOptimisticFileMessage( final tempId = _addOptimisticMediaMessage(
FileAttachment(fileId: fileId), FileAttachment(fileId: fileId),
).id; ).id;
try { try {
@@ -6478,12 +6358,26 @@ class _ChatScreenState extends State<ChatScreen>
return; return;
} }
_failPhotoMessage(event.tempId); _failPhotoMessage(event.tempId);
if (event.kind == UploadKind.file) { final text = _uploadFailureText(event.kind, event.reason);
showCustomNotification(context, 'Ошибка: ${event.reason}'); if (text != null) showCustomNotification(context, text);
}
} }
} }
String? _uploadFailureText(UploadKind kind, String reason) {
final detail = switch (reason) {
'no_upload_url' => 'сервер не выдал ссылку',
'upload_failed' => 'загрузка отклонена',
'send_failed' => 'сервер не принял сообщение',
_ => reason,
};
return switch (kind) {
UploadKind.file => 'Ошибка: $reason',
UploadKind.videoNote => 'Кружок не отправлен: $detail',
UploadKind.voice => 'Голосовое не отправлено: $detail',
UploadKind.photo || UploadKind.video => null,
};
}
void _syncUploadStatus() { void _syncUploadStatus() {
final job = UploadService.instance.activeFileJob(widget.chatId); final job = UploadService.instance.activeFileJob(widget.chatId);
if (job?.id == _uploadStatusJobId) return; if (job?.id == _uploadStatusJobId) return;
@@ -6735,21 +6629,15 @@ class _ChatScreenState extends State<ChatScreen>
); );
} }
void _failPhotoMessage(String tempId) { void _failPhotoMessage(String tempId) {
final idx = _messages.indexWhere((m) => m.id == tempId); final idx = _messages.indexWhere((m) => m.id == tempId);
if (idx != -1) { if (idx != -1) {
_messages[idx] = _messages[idx].copyWith(status: 'error'); _messages[idx] = _messages[idx].copyWith(status: 'error');
_bumpMessages(); _bumpMessages();
} }
_disposePhotoProgress(tempId);
Haptics.error(); Haptics.error();
} }
void _disposePhotoProgress(String tempId) {
_photoUploadProgress.remove(tempId)?.dispose();
}
void _refuseUnencrypted(String what) { void _refuseUnencrypted(String what) {
if (!mounted) return; if (!mounted) return;
_showAttachmentPanel.value = false; _showAttachmentPanel.value = false;
@@ -6836,7 +6724,7 @@ class _ChatScreenState extends State<ChatScreen>
final placeholder = scheduledTime != null final placeholder = scheduledTime != null
? null ? null
: _addOptimisticFileMessage( : _addOptimisticMediaMessage(
FileAttachment(name: filename, size: size), FileAttachment(name: filename, size: size),
); );
@@ -33,6 +33,7 @@ class VideoBubble extends StatelessWidget {
cs: ctx.cs, cs: ctx.cs,
textColor: ctx.text, textColor: ctx.text,
meta: ctx.meta(), meta: ctx.meta(),
uploadProgress: ctx.uploadProgress,
); );
} }
final hasCaption = message.text != null && message.text!.isNotEmpty; final hasCaption = message.text != null && message.text!.isNotEmpty;
@@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -15,6 +17,7 @@ import '../../../../core/utils/haptics.dart';
import '../../../../core/utils/logger.dart'; import '../../../../core/utils/logger.dart';
import '../../../../models/attachment.dart'; import '../../../../models/attachment.dart';
import '../../small_spinner.dart'; import '../../small_spinner.dart';
import '../../upload_progress_ring.dart';
class VideoNoteBubble extends StatefulWidget { class VideoNoteBubble extends StatefulWidget {
final VideoAttachment attachment; final VideoAttachment attachment;
@@ -26,6 +29,7 @@ class VideoNoteBubble extends StatefulWidget {
final ColorScheme cs; final ColorScheme cs;
final Color textColor; final Color textColor;
final Widget meta; final Widget meta;
final ValueListenable<List<double>>? uploadProgress;
const VideoNoteBubble({ const VideoNoteBubble({
super.key, super.key,
@@ -38,6 +42,7 @@ class VideoNoteBubble extends StatefulWidget {
required this.cs, required this.cs,
required this.textColor, required this.textColor,
required this.meta, required this.meta,
this.uploadProgress,
}); });
@override @override
@@ -57,6 +62,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
final ValueNotifier<double> _ringProgress = ValueNotifier(0); final ValueNotifier<double> _ringProgress = ValueNotifier(0);
Uint8List? _preview; Uint8List? _preview;
VideoPlayerController? _controller; VideoPlayerController? _controller;
VideoPlayerController? _local;
Future<void>? _initializing; Future<void>? _initializing;
Duration? _pendingSeek; Duration? _pendingSeek;
double? _lastAngle; double? _lastAngle;
@@ -70,6 +76,19 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
int? get _videoId => widget.attachment.videoId; int? get _videoId => widget.attachment.videoId;
String get _cacheName => 'videonote_$_videoId.mp4'; String get _cacheName => 'videonote_$_videoId.mp4';
int get _attachmentDurationMs => widget.attachment.duration ?? 0; int get _attachmentDurationMs => widget.attachment.duration ?? 0;
String? get _localPath => widget.attachment.localPath;
String? get _posterUrl {
for (final candidate in [
widget.attachment.thumbnail,
widget.attachment.baseUrl,
]) {
if (candidate == null || candidate.isEmpty) continue;
if (candidate.startsWith('data:')) continue;
return candidate;
}
return null;
}
bool get _ready { bool get _ready {
final controller = _controller; final controller = _controller;
@@ -81,7 +100,14 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
super.initState(); super.initState();
_expand = AnimationController(vsync: this, duration: _expandDuration); _expand = AnimationController(vsync: this, duration: _expandDuration);
_preview = _previewBytes(widget.attachment.previewData); _preview = _previewBytes(widget.attachment.previewData);
if (VideoNotePreloader.autoLoads(widget.attachment.duration)) _preload(); final local = _localPath;
if (local != null) {
unawaited(_openLocalPreview(File(local)));
return;
}
if (VideoNotePreloader.autoLoads(widget.attachment.duration)) {
unawaited(_warmCache());
}
} }
@override @override
@@ -90,6 +116,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
if (old.attachment.previewData != widget.attachment.previewData) { if (old.attachment.previewData != widget.attachment.previewData) {
_preview = _previewBytes(widget.attachment.previewData); _preview = _previewBytes(widget.attachment.previewData);
} }
if (old.attachment.localPath != _localPath) _dropLocalPreview();
} }
@override @override
@@ -98,6 +125,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
_PreviewPool.unregister(this); _PreviewPool.unregister(this);
_expand.dispose(); _expand.dispose();
_ringProgress.dispose(); _ringProgress.dispose();
_dropLocalPreview();
final controller = _controller; final controller = _controller;
_controller = null; _controller = null;
if (controller != null) { if (controller != null) {
@@ -107,6 +135,34 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
super.dispose(); super.dispose();
} }
Future<void> _openLocalPreview(File file) async {
final controller = VideoPlayerController.file(file);
try {
await controller.initialize();
} catch (e) {
logger.w('VideoNoteBubble: локальное превью не открылось: $e');
await controller.dispose();
return;
}
if (!mounted || file.path != _localPath) {
await controller.dispose();
return;
}
await controller.setVolume(0);
if (!mounted) {
await controller.dispose();
return;
}
setState(() => _local = controller);
}
void _dropLocalPreview() {
final local = _local;
if (local == null) return;
_local = null;
unawaited(local.dispose());
}
void _claimPlayback() { void _claimPlayback() {
final controller = _controller; final controller = _controller;
if (controller == null) return; if (controller == null) return;
@@ -152,11 +208,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
} }
} }
Future<void> _preload() async { Future<void> _warmCache() => _fetch(priority: false);
final file = await _fetch(priority: false);
if (file == null || !mounted) return;
await _ensureController(file);
}
Future<File?> _fetch({required bool priority}) { Future<File?> _fetch({required bool priority}) {
final videoId = _videoId; final videoId = _videoId;
@@ -176,6 +228,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
} }
Future<VideoPlayerController?> _ensureController(File file) async { Future<VideoPlayerController?> _ensureController(File file) async {
if (!mounted) return null;
if (_controller != null) return _controller; if (_controller != null) return _controller;
final live = MediaPlayback.instance.liveVideoNote(_cacheName); final live = MediaPlayback.instance.liveVideoNote(_cacheName);
if (live != null) { if (live != null) {
@@ -230,6 +283,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
} }
Future<void> _toggle() async { Future<void> _toggle() async {
if (_videoId == null) return;
if (_ready) { if (_ready) {
if (_controller!.value.isPlaying) { if (_controller!.value.isPlaying) {
await _pause(); await _pause();
@@ -388,13 +442,14 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
} }
Widget _buildCircle(double size) { Widget _buildCircle(double size) {
final controller = _controller;
final ready = _ready; final ready = _ready;
final playing = ready && _playing && !_scrubbing; final playing = ready && _playing && !_scrubbing;
final preview = _preview; final preview = _preview;
final local = _local;
final uploading = widget.uploadProgress;
return GestureDetector( return GestureDetector(
onTap: _toggle, onTap: uploading == null ? _toggle : null,
child: SizedBox( child: SizedBox(
width: size, width: size,
height: size, height: size,
@@ -409,63 +464,112 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: _swapDuration, duration: _swapDuration,
child: ready child: ready
? SizedBox.expand( ? _videoSurface(
key: const ValueKey('note-video'), _controller!,
child: FittedBox( const ValueKey('note-video'),
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: controller!.value.size.width,
height: controller.value.size.height,
child: VideoPlayer(controller),
),
),
) )
: preview != null : local != null && local.value.isInitialized
? SizedBox.expand( ? _videoSurface(local, const ValueKey('note-local'))
key: const ValueKey('note-preview'), : _buildPoster(preview),
child: Image.memory(
preview,
fit: BoxFit.cover,
gaplessPlayback: true,
),
)
: SizedBox.expand(
key: const ValueKey('note-empty'),
child: ColoredBox(
color: widget.cs.surfaceContainerHighest,
),
),
), ),
), ),
), ),
), ),
if (ready) _buildRing(size), if (uploading != null) ...[
if (!playing) Positioned.fill(
Container( child: ClipOval(
width: 52, child: ColoredBox(
height: 52, color: Colors.black.withValues(alpha: 0.35),
decoration: const BoxDecoration( ),
color: Colors.black45,
shape: BoxShape.circle,
), ),
child: _loading
? const Padding(
padding: EdgeInsets.all(14),
child: SmallSpinner(size: 36, color: Colors.white),
)
: Icon(
_error ? Symbols.error : Symbols.play_arrow,
color: Colors.white,
size: 30,
),
), ),
UploadProgressRing(
progress: uploading,
color: Colors.white,
trackColor: Colors.white24,
),
] else ...[
if (ready) _buildRing(size),
if (!playing)
Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Colors.black45,
shape: BoxShape.circle,
),
child: _loading
? const Padding(
padding: EdgeInsets.all(14),
child: SmallSpinner(size: 36, color: Colors.white),
)
: Icon(
_error ? Symbols.error : Symbols.play_arrow,
color: Colors.white,
size: 30,
),
),
],
], ],
), ),
), ),
); );
} }
Widget _buildPoster(Uint8List? preview) {
final url = _posterUrl;
if (url == null) {
return _inlinePreview(preview, const ValueKey('note-preview'));
}
final dpr = MediaQuery.devicePixelRatioOf(context);
return SizedBox.expand(
key: const ValueKey('note-poster'),
child: CachedNetworkImage(
imageUrl: url,
fit: BoxFit.cover,
memCacheWidth: (_baseSize * dpr).round(),
fadeInDuration: _swapDuration,
placeholderFadeInDuration: Duration.zero,
placeholder: (_, _) => _inlinePreview(preview, null),
errorWidget: (_, _, _) => _inlinePreview(preview, null),
),
);
}
Widget _inlinePreview(Uint8List? preview, Key? key) {
if (preview == null) {
return SizedBox.expand(
key: key,
child: ColoredBox(color: widget.cs.surfaceContainerHighest),
);
}
return SizedBox.expand(
key: key,
child: Image.memory(
preview,
fit: BoxFit.cover,
gaplessPlayback: true,
filterQuality: FilterQuality.medium,
),
);
}
Widget _videoSurface(VideoPlayerController controller, Key key) {
return SizedBox.expand(
key: key,
child: FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: controller.value.size.width,
height: controller.value.size.height,
child: VideoPlayer(controller),
),
),
);
}
Widget _buildRing(double size) { Widget _buildRing(double size) {
return GestureDetector( return GestureDetector(
onTapUp: (details) => _ringTap(details.localPosition, size), onTapUp: (details) => _ringTap(details.localPosition, size),
@@ -29,6 +29,7 @@ class VoiceMessageBubble extends StatefulWidget {
final int senderId; final int senderId;
final int? audioId; final int? audioId;
final String? preloadedText; final String? preloadedText;
final ValueListenable<List<double>>? uploadProgress;
const VoiceMessageBubble({ const VoiceMessageBubble({
super.key, super.key,
@@ -47,6 +48,7 @@ class VoiceMessageBubble extends StatefulWidget {
required this.senderId, required this.senderId,
this.audioId, this.audioId,
this.preloadedText, this.preloadedText,
this.uploadProgress,
}); });
@override @override
@@ -101,7 +103,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
); );
} }
bool get _uploading => widget.uploadProgress != null;
void _toggle() { void _toggle() {
if (_uploading) return;
_claimPlayback(); _claimPlayback();
_audio.toggle(); _audio.toggle();
} }
@@ -176,8 +181,9 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
widget.isMe ? widget.cs.onPrimaryContainer : widget.cs.primary; widget.isMe ? widget.cs.onPrimaryContainer : widget.cs.primary;
Widget _buildPlayButton() { Widget _buildPlayButton() {
final uploading = widget.uploadProgress;
return GestureDetector( return GestureDetector(
onTap: _toggle, onTap: uploading == null ? _toggle : null,
child: Container( child: Container(
width: 32, width: 32,
height: 32, height: 32,
@@ -187,46 +193,73 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
: widget.cs.primaryContainer, : widget.cs.primaryContainer,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: AnimatedBuilder( child: uploading != null
animation: Listenable.merge([ ? _buildUploadIndicator(uploading)
_audio.downloaded, : AnimatedBuilder(
_audio.downloadProgress, animation: Listenable.merge([
_audio.playing, _audio.downloaded,
]), _audio.downloadProgress,
builder: (context, _) { _audio.playing,
final progress = _audio.downloadProgress.value; ]),
if (progress != null) { builder: (context, _) {
return Padding( final progress = _audio.downloadProgress.value;
padding: const EdgeInsets.all(4), if (progress != null) {
child: CircularProgressIndicator( return Padding(
strokeWidth: 2, padding: const EdgeInsets.all(4),
value: progress > 0 ? progress : null, child: CircularProgressIndicator(
color: _accent, strokeWidth: 2,
backgroundColor: _accent.withValues(alpha: 0.2), value: progress > 0 ? progress : null,
), color: _accent,
); backgroundColor: _accent.withValues(alpha: 0.2),
} ),
final IconData icon; );
if (_audio.playing.value) { }
icon = Symbols.pause; final IconData icon;
} else if (_audio.downloaded.value) { if (_audio.playing.value) {
icon = Symbols.play_arrow; icon = Symbols.pause;
} else { } else if (_audio.downloaded.value) {
icon = Symbols.arrow_downward; icon = Symbols.play_arrow;
} } else {
return AnimatedSwitcher( icon = Symbols.arrow_downward;
duration: const Duration(milliseconds: 160), }
transitionBuilder: (child, animation) => return AnimatedSwitcher(
ScaleTransition(scale: animation, child: child), duration: const Duration(milliseconds: 160),
child: Icon( transitionBuilder: (child, animation) =>
icon, ScaleTransition(scale: animation, child: child),
key: ValueKey(icon), child: Icon(
color: _accent, icon,
size: 18, key: ValueKey(icon),
color: _accent,
size: 18,
),
);
},
), ),
); ),
}, );
), }
Widget _buildUploadIndicator(ValueListenable<List<double>> progress) {
return Padding(
padding: const EdgeInsets.all(4),
child: ValueListenableBuilder<List<double>>(
valueListenable: progress,
builder: (context, values, _) {
final value = values.isEmpty
? 0.0
: values.reduce((a, b) => a + b) / values.length;
return TweenAnimationBuilder<double>(
tween: Tween<double>(end: value.clamp(0.0, 1.0)),
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
builder: (context, shown, _) => CircularProgressIndicator(
strokeWidth: 2,
value: shown >= 1.0 ? null : shown,
color: _accent,
backgroundColor: _accent.withValues(alpha: 0.2),
),
);
},
), ),
); );
} }
@@ -626,7 +659,10 @@ class _WaveformPainter extends CustomPainter {
void _paintKnob(Canvas canvas, Size size, double center) { void _paintKnob(Canvas canvas, Size size, double center) {
if (!knob) return; if (!knob) return;
final x = (size.width * progress.clamp(0.0, 1.0)).clamp(1.5, size.width - 1.5); final x = (size.width * progress.clamp(0.0, 1.0)).clamp(
1.5,
size.width - 1.5,
);
canvas.drawRRect( canvas.drawRRect(
RRect.fromRectAndRadius( RRect.fromRectAndRadius(
Rect.fromLTWH(x - 1.5, 0, 3, size.height), Rect.fromLTWH(x - 1.5, 0, 3, size.height),
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';
class LottieSlashIcon extends StatefulWidget {
const LottieSlashIcon({
super.key,
required this.asset,
required this.slashed,
required this.color,
this.size = 24,
this.duration = const Duration(milliseconds: 320),
});
final String asset;
final bool slashed;
final Color color;
final double size;
final Duration duration;
@override
State<LottieSlashIcon> createState() => _LottieSlashIconState();
}
class _LottieSlashIconState extends State<LottieSlashIcon>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: widget.duration,
value: widget.slashed ? 1 : 0,
);
@override
void didUpdateWidget(LottieSlashIcon old) {
super.didUpdateWidget(old);
_controller.duration = widget.duration;
if (widget.slashed == old.slashed) return;
if (widget.slashed) {
_controller.forward();
} else {
_controller.reverse();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: widget.size,
child: Lottie.asset(
widget.asset,
controller: _controller,
fit: BoxFit.contain,
delegates: LottieDelegates(
values: [
ValueDelegate.color(const ['**'], value: widget.color),
],
),
),
);
}
}
+1
View File
@@ -1936,6 +1936,7 @@ class MessageBubble extends StatelessWidget {
senderId: message.senderId, senderId: message.senderId,
audioId: audioId, audioId: audioId,
preloadedText: cachedTranscription?.text, preloadedText: cachedTranscription?.text,
uploadProgress: ctx.uploadProgress,
); );
} }
} }
+2
View File
@@ -1070,6 +1070,8 @@
} }
}, },
"uploadNotificationVideo": "Video", "uploadNotificationVideo": "Video",
"uploadNotificationVideoNote": "Video message",
"uploadNotificationVoice": "Voice message",
"uploadNotificationFile": "File", "uploadNotificationFile": "File",
"uploadNotificationMultiple": "{count, plural, other{Sending {count} files}}", "uploadNotificationMultiple": "{count, plural, other{Sending {count} files}}",
"@uploadNotificationMultiple": { "@uploadNotificationMultiple": {
+12
View File
@@ -4706,6 +4706,18 @@ abstract class AppLocalizations {
/// **'Video'** /// **'Video'**
String get uploadNotificationVideo; String get uploadNotificationVideo;
/// No description provided for @uploadNotificationVideoNote.
///
/// In en, this message translates to:
/// **'Video message'**
String get uploadNotificationVideoNote;
/// No description provided for @uploadNotificationVoice.
///
/// In en, this message translates to:
/// **'Voice message'**
String get uploadNotificationVoice;
/// No description provided for @uploadNotificationFile. /// No description provided for @uploadNotificationFile.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+6
View File
@@ -2464,6 +2464,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get uploadNotificationVideo => 'Video'; String get uploadNotificationVideo => 'Video';
@override
String get uploadNotificationVideoNote => 'Video message';
@override
String get uploadNotificationVoice => 'Voice message';
@override @override
String get uploadNotificationFile => 'File'; String get uploadNotificationFile => 'File';
+6
View File
@@ -2478,6 +2478,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get uploadNotificationVideo => 'Видео'; String get uploadNotificationVideo => 'Видео';
@override
String get uploadNotificationVideoNote => 'Кружок';
@override
String get uploadNotificationVoice => 'Голосовое сообщение';
@override @override
String get uploadNotificationFile => 'Файл'; String get uploadNotificationFile => 'Файл';
+2
View File
@@ -814,6 +814,8 @@
} }
}, },
"uploadNotificationVideo": "Видео", "uploadNotificationVideo": "Видео",
"uploadNotificationVideoNote": "Кружок",
"uploadNotificationVoice": "Голосовое сообщение",
"uploadNotificationFile": "Файл", "uploadNotificationFile": "Файл",
"uploadNotificationMultiple": "{count, plural, other{Отправка файлов: {count}}}", "uploadNotificationMultiple": "{count, plural, other{Отправка файлов: {count}}}",
"@uploadNotificationMultiple": { "@uploadNotificationMultiple": {
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.5.17+17 version: 0.5.18+18
environment: environment:
sdk: ^3.10.4 sdk: ^3.10.4
+1
View File
@@ -51,6 +51,7 @@ void main() {
isMounted: () => true, isMounted: () => true,
onRecorded: (File file, int durationMs) async {}, onRecorded: (File file, int durationMs) async {},
formatElapsed: (milliseconds) => '$milliseconds', formatElapsed: (milliseconds) => '$milliseconds',
bottomInset: () => 0,
); );
await tester.pumpWidget( await tester.pumpWidget(
+138
View File
@@ -0,0 +1,138 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/frontend/widgets/lottie_slash_icon.dart';
import 'package:lottie/lottie.dart';
const _asset = 'assets/lottie/ic_flash_on_to_off.json';
Map<String, dynamic> _doc() =>
jsonDecode(File(_asset).readAsStringSync()) as Map<String, dynamic>;
List<Map<String, dynamic>> _layers(Map<String, dynamic> doc) =>
(doc['layers'] as List).cast<Map<String, dynamic>>();
List<Map<String, dynamic>> _maskFrames(Map<String, dynamic> layer) {
final masks = (layer['masksProperties'] as List).cast<Map<String, dynamic>>();
return ((masks.single['pt'] as Map)['k'] as List)
.cast<Map<String, dynamic>>();
}
List<List<num>> _quad(Map<String, dynamic> frame) =>
((((frame['s'] as List).first as Map)['v']) as List)
.map((point) => (point as List).cast<num>())
.toList();
Widget _host(bool slashed) => MaterialApp(
home: Scaffold(
body: Center(
child: LottieSlashIcon(
asset: _asset,
slashed: slashed,
color: const Color(0xFFFFFFFF),
),
),
),
);
void main() {
group('Ассет перечёркивания', () {
test('квадратный, из двух слоёв, каждый со своей маской', () {
final doc = _doc();
expect(doc['w'], doc['h']);
expect(doc['op'], greaterThan(0));
final layers = _layers(doc);
expect(layers.length, 2);
for (final layer in layers) {
expect(_maskFrames(layer).length, 2, reason: 'маска должна ехать');
final group = (layer['shapes'] as List).first as Map<String, dynamic>;
final paths = (group['it'] as List).cast<Map<String, dynamic>>().where(
(item) => item['ty'] == 'sh',
);
expect(paths, isNotEmpty);
for (final path in paths) {
expect(
(path['ks'] as Map)['a'],
0,
reason: 'глифы статичны — двигается только маска',
);
}
}
});
test('маски слоёв дополняют друг друга', () {
final layers = _layers(_doc());
final slashed = _maskFrames(layers.first);
final plain = _maskFrames(layers.last);
for (var frame = 0; frame < 2; frame++) {
final a = _quad(slashed[frame]);
final b = _quad(plain[frame]);
expect(
a.take(2),
b.take(2),
reason: 'обе маски должны делить одну и ту же диагональ',
);
expect(
a.skip(2),
isNot(b.skip(2)),
reason: 'маски должны смотреть в разные стороны от диагонали',
);
}
});
test('слои двигаются одинаково', () {
final layers = _layers(_doc());
expect(layers.first['ks'], layers.last['ks']);
});
testWidgets('композиция читается lottie без предупреждений', (
tester,
) async {
final composition = await AssetLottie(_asset).load();
expect(composition.warnings, isEmpty);
expect(composition.layers.length, 2);
});
});
group('LottieSlashIcon', () {
testWidgets('рисует lottie в обоих состояниях', (tester) async {
await tester.pumpWidget(_host(false));
await tester.pump();
expect(find.byType(Lottie), findsOneWidget);
await tester.pumpWidget(_host(true));
await tester.pump(const Duration(milliseconds: 100));
expect(find.byType(Lottie), findsOneWidget);
await tester.pump(const Duration(milliseconds: 400));
expect(find.byType(Lottie), findsOneWidget);
});
testWidgets('перечёркивание анимируется в обе стороны', (tester) async {
await tester.pumpWidget(_host(false));
final controller = tester
.widget<LottieBuilder>(find.byType(LottieBuilder))
.controller!;
expect(controller.value, 0);
await tester.pumpWidget(_host(true));
await tester.pump(const Duration(milliseconds: 100));
expect(controller.value, greaterThan(0));
expect(controller.value, lessThan(1));
await tester.pump(const Duration(milliseconds: 400));
expect(controller.value, 1);
await tester.pumpWidget(_host(false));
await tester.pump(const Duration(milliseconds: 100));
expect(controller.value, lessThan(1));
await tester.pump(const Duration(milliseconds: 400));
expect(controller.value, 0);
});
});
}
+397 -49
View File
@@ -1,9 +1,15 @@
"""Собирает lottie-морфы иконок композера прямо из шрифта Material Symbols. """Собирает lottie-морфы иконок прямо из шрифта Material Symbols.
Контуры глифов берутся из MaterialSymbolsOutlined.ttf (инстанс по умолчанию Контуры глифов берутся из MaterialSymbolsOutlined.ttf (инстанс по умолчанию
FILL 0, GRAD 0, opsz 24, wght 400, то есть ровно то, что рисует Icon в приложении), FILL 0, GRAD 0, opsz 24, wght 400, то есть ровно то, что рисует Icon в приложении),
разбиваются на равное число безье-сегментов и попарно сопоставляются, чтобы разбиваются на равное число безье-сегментов и попарно сопоставляются, чтобы
lottie мог интерполировать один глиф в другой. lottie мог интерполировать один глиф в другой. Спекам с fill=1 контуры считаются
по FILL=1 для кнопок, которые рисуют Icon(..., fill: 1).
SPECS морфы композера (ComposerMorphIcon), проигрываются вперёд.
SLASH_SPECS переключатели «обычная/перечёркнутая» (LottieSlashIcon): оба глифа
лежат статикой, а перечёркивание рисуется бегущей по диагонали маской, поэтому
одного ассета хватает на оба направления.
python3 tool/make_morph_icons.py python3 tool/make_morph_icons.py
@@ -36,6 +42,8 @@ class Font:
'>H', self.data[maxp_off + 4:maxp_off + 6])[0] '>H', self.data[maxp_off + 4:maxp_off + 6])[0]
self._read_loca() self._read_loca()
self._read_cmap() self._read_cmap()
self._read_fvar()
self._read_gvar()
def _read_loca(self): def _read_loca(self):
off, _ = self.tables['loca'] off, _ = self.tables['loca']
@@ -90,9 +98,131 @@ class Font:
for c in range(s, e + 1): for c in range(s, e + 1):
self.cmap[c] = g + (c - s) self.cmap[c] = g + (c - s)
def contours(self, codepoint): def _read_fvar(self):
off, _ = self.tables['fvar']
axes_off, _, axis_count, axis_size = struct.unpack(
'>HHHH', self.data[off + 4:off + 12])
self.axes = []
for i in range(axis_count):
p = off + axes_off + i * axis_size
self.axes.append(self.data[p:p + 4].decode('latin1'))
def _read_gvar(self):
off, _ = self.tables['gvar']
axis_count, shared_count, shared_off, glyph_count, flags, data_off = (
struct.unpack('>HHIHHI', self.data[off + 4:off + 20]))
base = off + 20
if flags & 1:
raw = struct.unpack(
'>%dI' % (glyph_count + 1), self.data[base:base + 4 * (glyph_count + 1)])
offsets = list(raw)
else:
raw = struct.unpack(
'>%dH' % (glyph_count + 1), self.data[base:base + 2 * (glyph_count + 1)])
offsets = [v * 2 for v in raw]
shared = []
p = off + shared_off
for i in range(shared_count):
step = 2 * axis_count
shared.append(struct.unpack('>%dh' % axis_count,
self.data[p + i * step:p + (i + 1) * step]))
self.gvar = {
'axis_count': axis_count,
'shared': shared,
'offsets': offsets,
'data': off + data_off,
}
def _axis_deltas(self, gid, axis, contours):
"""Deltas that move the glyph to the `axis`=1 instance.
Only tuples peaking on `axis` alone contribute: every other tuple is
multiplied by an axis coordinate that stays at its default zero.
"""
gvar = self.gvar
start = gvar['data'] + gvar['offsets'][gid]
end = gvar['data'] + gvar['offsets'][gid + 1]
if end <= start:
return None
d = self.data[start:end]
axis_count = gvar['axis_count']
index = self.axes.index(axis)
n_points = sum(len(c) for c in contours) + 4
tuple_count, cursor = struct.unpack('>HH', d[0:4])
shared_points = None
if tuple_count & 0x8000:
shared_points, cursor = _packed_points(d, cursor)
total = [(0.0, 0.0)] * n_points
applied = False
p = 4
for _ in range(tuple_count & 0x0FFF):
var_size, tuple_index = struct.unpack('>HH', d[p:p + 4])
p += 4
if tuple_index & 0x8000:
peak = struct.unpack('>%dh' % axis_count, d[p:p + 2 * axis_count])
p += 2 * axis_count
else:
peak = gvar['shared'][tuple_index & 0x0FFF]
if tuple_index & 0x4000:
p += 4 * axis_count
block, cursor = cursor, cursor + var_size
if peak[index] <= 0 or any(
v for i, v in enumerate(peak) if i != index):
continue
q = block
points = shared_points
if tuple_index & 0x2000:
points, q = _packed_points(d, q)
size = n_points if points is None else len(points)
xs, q = _packed_deltas(d, q, size)
ys, _ = _packed_deltas(d, q, size)
scale = 16384.0 / peak[index]
sparse = [None] * n_points
for k, point in enumerate(range(size) if points is None else points):
if point < n_points:
sparse[point] = (xs[k] * scale, ys[k] * scale)
_infer_deltas(contours, sparse)
total = [(a[0] + b[0], a[1] + b[1]) for a, b in zip(total, sparse)]
applied = True
return total if applied else None
def contours(self, codepoint, fill=0.0):
gid = self.cmap[codepoint] gid = self.cmap[codepoint]
return self._glyph_contours(gid) contours = self._glyph_contours(gid)
if fill <= 0:
return contours
if self._is_composite(gid):
raise SystemExit('fill=1 не поддержан для составного глифа %04X'
% codepoint)
deltas = self._axis_deltas(gid, 'FILL', contours)
if deltas is None:
return contours
out = []
index = 0
for contour in contours:
shifted = []
for x, y, on in contour:
dx, dy = deltas[index]
index += 1
shifted.append((x + dx * fill, y + dy * fill, on))
out.append(shifted)
return out
def _is_composite(self, gid):
goff, _ = self.tables['glyf']
start, end = self.loca[gid], self.loca[gid + 1]
if start == end:
return False
return struct.unpack('>h', self.data[goff + start:goff + start + 2])[0] < 0
def _glyph_contours(self, gid, depth=0): def _glyph_contours(self, gid, depth=0):
goff, _ = self.tables['glyf'] goff, _ = self.tables['glyf']
@@ -197,6 +327,94 @@ def _f2dot14(d, p):
return struct.unpack('>h', d[p:p + 2])[0] / 16384.0 return struct.unpack('>h', d[p:p + 2])[0] / 16384.0
def _packed_points(d, p):
"""gvar packed point numbers; None means «все точки глифа»."""
count = d[p]
p += 1
if count == 0:
return None, p
if count & 0x80:
count = ((count & 0x7F) << 8) | d[p]
p += 1
points, value = [], 0
while len(points) < count:
control = d[p]
p += 1
run = (control & 0x7F) + 1
for _ in range(run):
if control & 0x80:
value += struct.unpack('>H', d[p:p + 2])[0]
p += 2
else:
value += d[p]
p += 1
points.append(value)
return points[:count], p
def _packed_deltas(d, p, count):
out = []
while len(out) < count:
control = d[p]
p += 1
run = (control & 0x3F) + 1
if control & 0x80:
out.extend([0] * run)
elif control & 0x40:
for _ in range(run):
out.append(struct.unpack('>h', d[p:p + 2])[0])
p += 2
else:
for _ in range(run):
out.append(struct.unpack('>b', d[p:p + 1])[0])
p += 1
return out[:count], p
def _interpolate(v, v1, d1, v2, d2):
if v1 > v2:
v1, d1, v2, d2 = v2, d2, v1, d1
if v1 == v2:
return d1 if d1 == d2 else 0.0
if v <= v1:
return d1
if v >= v2:
return d2
return d1 + (d2 - d1) * (v - v1) / (v2 - v1)
def _infer_deltas(contours, deltas):
"""IUP: точки, которых нет в тапле, тянутся за соседними опорными."""
first = 0
for contour in contours:
last = first + len(contour) - 1
refs = [i for i in range(first, last + 1) if deltas[i] is not None]
if not refs:
for i in range(first, last + 1):
deltas[i] = (0.0, 0.0)
elif len(refs) == 1:
for i in range(first, last + 1):
deltas[i] = deltas[refs[0]]
else:
for k, a in enumerate(refs):
b = refs[(k + 1) % len(refs)]
i = first if a == last else a + 1
while i != b:
deltas[i] = (
_interpolate(contour[i - first][0],
contour[a - first][0], deltas[a][0],
contour[b - first][0], deltas[b][0]),
_interpolate(contour[i - first][1],
contour[a - first][1], deltas[a][1],
contour[b - first][1], deltas[b][1]),
)
i = first if i == last else i + 1
first = last + 1
for i, value in enumerate(deltas):
if value is None:
deltas[i] = (0.0, 0.0)
def to_cubic(contour): def to_cubic(contour):
"""TrueType quadratic contour -> list of cubic segments [(p0,c1,c2,p1), ...].""" """TrueType quadratic contour -> list of cubic segments [(p0,c1,c2,p1), ...]."""
pts = [] pts = []
@@ -367,9 +585,9 @@ def _area(path):
return area / 2 return area / 2
def glyph_paths(codepoint, count): def glyph_paths(codepoint, count, fill=0.0):
out = [] out = []
for contour in _font.contours(codepoint): for contour in _font.contours(codepoint, fill):
path = _exact_path(contour, count) path = _exact_path(contour, count)
if not path: if not path:
continue continue
@@ -414,10 +632,10 @@ def outer_sign(shapes):
return 1.0 if biggest[0] > 0 else -1.0 return 1.0 if biggest[0] > 0 else -1.0
def pair_glyphs(from_cp, to_cp, count): def pair_glyphs(from_cp, to_cp, count, fill=0.0):
"""[(path_from, path_to), ...] with matching vertex counts and winding.""" """[(path_from, path_to), ...] with matching vertex counts and winding."""
src = glyph_paths(from_cp, count) src = glyph_paths(from_cp, count, fill)
dst = glyph_paths(to_cp, count) dst = glyph_paths(to_cp, count, fill)
src_sign = outer_sign(src) src_sign = outer_sign(src)
dst_sign = outer_sign(dst) dst_sign = outer_sign(dst)
@@ -441,6 +659,8 @@ def pair_glyphs(from_cp, to_cp, count):
MIC = 0xE31D MIC = 0xE31D
CAM = 0xE04B CAM = 0xE04B
SEND = 0xE163 SEND = 0xE163
FLASH_ON = 0xE3E7
FLASH_OFF = 0xE3E6
POINTS = 56 POINTS = 56
FPS = 60 FPS = 60
@@ -504,6 +724,54 @@ def shape_item(index, path_from, path_to):
} }
def _group(items, name):
items = list(items)
items.append({
'ty': 'fl',
'c': {'a': 0, 'k': [1, 1, 1, 1], 'ix': 4},
'o': {'a': 0, 'k': 100, 'ix': 5},
'r': 1,
'bm': 0,
'nm': 'Fill',
'mn': 'ADBE Vector Graphic - Fill',
'hd': False,
})
items.append({
'ty': 'tr',
'p': {'a': 0, 'k': [0, 0], 'ix': 2},
'a': {'a': 0, 'k': [0, 0], 'ix': 1},
's': {'a': 0, 'k': [100, 100], 'ix': 3},
'r': {'a': 0, 'k': 0, 'ix': 6},
'o': {'a': 0, 'k': 100, 'ix': 7},
'sk': {'a': 0, 'k': 0, 'ix': 4},
'sa': {'a': 0, 'k': 0, 'ix': 5},
'nm': 'Transform',
})
return {
'ty': 'gr',
'it': items,
'nm': name,
'np': len(items),
'cix': 2,
'bm': 0,
'ix': 1,
'mn': 'ADBE Vector Group',
'hd': False,
}
def static_shape(index, path):
return {
'ind': index,
'ty': 'sh',
'ix': index + 1,
'ks': {'a': 0, 'k': path_value(path), 'ix': 2},
'nm': 'Path %d' % (index + 1),
'mn': 'ADBE Vector Shape - Group',
'hd': False,
}
def keyframes(stops, vector): def keyframes(stops, vector):
out = [] out = []
for i, (frame, value) in enumerate(stops): for i, (frame, value) in enumerate(stops):
@@ -539,30 +807,10 @@ def transform(rotation=None, scale=None, offset_x=None):
return ks return ks
def build(name, from_cp, to_cp, rotation=None, scale=None, offset_x=None): def build(name, from_cp, to_cp, rotation=None, scale=None, offset_x=None,
pairs = pair_glyphs(from_cp, to_cp, POINTS) fill=0.0):
pairs = pair_glyphs(from_cp, to_cp, POINTS, fill)
items = [shape_item(i, a, b) for i, (a, b) in enumerate(pairs)] items = [shape_item(i, a, b) for i, (a, b) in enumerate(pairs)]
items.append({
'ty': 'fl',
'c': {'a': 0, 'k': [1, 1, 1, 1], 'ix': 4},
'o': {'a': 0, 'k': 100, 'ix': 5},
'r': 1,
'bm': 0,
'nm': 'Fill',
'mn': 'ADBE Vector Graphic - Fill',
'hd': False,
})
items.append({
'ty': 'tr',
'p': {'a': 0, 'k': [0, 0], 'ix': 2},
'a': {'a': 0, 'k': [0, 0], 'ix': 1},
's': {'a': 0, 'k': [100, 100], 'ix': 3},
'r': {'a': 0, 'k': 0, 'ix': 6},
'o': {'a': 0, 'k': 100, 'ix': 7},
'sk': {'a': 0, 'k': 0, 'ix': 4},
'sa': {'a': 0, 'k': 0, 'ix': 5},
'nm': 'Transform',
})
return { return {
'v': '5.12.1', 'v': '5.12.1',
@@ -582,17 +830,7 @@ def build(name, from_cp, to_cp, rotation=None, scale=None, offset_x=None):
'sr': 1, 'sr': 1,
'ks': transform(rotation, scale, offset_x), 'ks': transform(rotation, scale, offset_x),
'ao': 0, 'ao': 0,
'shapes': [{ 'shapes': [_group(items, 'Group 1')],
'ty': 'gr',
'it': items,
'nm': 'Group 1',
'np': len(items),
'cix': 2,
'bm': 0,
'ix': 1,
'mn': 'ADBE Vector Group',
'hd': False,
}],
'ip': 0, 'ip': 0,
'op': DUR, 'op': DUR,
'st': 0, 'st': 0,
@@ -602,6 +840,101 @@ def build(name, from_cp, to_cp, rotation=None, scale=None, offset_x=None):
} }
def _wipe_quad(cut, ahead):
"""Половина плоскости по обе стороны от диагонали x + y = cut."""
reach = CANVAS * 1.5
mid = (cut / 2, cut / 2)
along = (reach / math.sqrt(2), -reach / math.sqrt(2))
depth = reach * math.sqrt(2) * (1 if ahead else -1)
corners = [
(mid[0] + along[0], mid[1] + along[1]),
(mid[0] - along[0], mid[1] - along[1]),
(mid[0] - along[0] + depth, mid[1] - along[1] + depth),
(mid[0] + along[0] + depth, mid[1] + along[1] + depth),
]
return {
'i': [[0, 0]] * 4,
'o': [[0, 0]] * 4,
'v': [[r2(x), r2(y)] for x, y in corners],
'c': True,
}
def wipe_mask(span, ahead):
start, end = span
return [{
'inv': False,
'mode': 'a',
'pt': {
'a': 1,
'k': [
{'i': EASE_IN, 'o': EASE_OUT, 't': 0,
's': [_wipe_quad(start, ahead)]},
{'t': DUR, 's': [_wipe_quad(end, ahead)]},
],
'ix': 1,
},
'o': {'a': 0, 'k': 100, 'ix': 3},
'x': {'a': 0, 'k': 0, 'ix': 4},
'nm': 'Wipe',
}]
def _diagonal_span(*glyphs):
values = [v[0][0] + v[0][1] for paths in glyphs for _, path in paths
for v in path]
margin = CANVAS * 0.04
return min(values) - margin, max(values) + margin
def build_slash(name, plain_cp, slashed_cp, fill=0.0, scale=None):
"""Кадр 0 — обычный глиф, последний — перечёркнутый.
Оба глифа лежат статичными слоями, а по диагонали (перпендикулярно самой
перечёркивающей линии) едет маска: перечёркнутый слой открывается ровно там,
где обычный закрывается, поэтому линия выглядит нарисованной поверх иконки.
"""
plain = glyph_paths(plain_cp, POINTS, fill)
slashed = glyph_paths(slashed_cp, POINTS, fill)
span = _diagonal_span(plain, slashed)
def layer(index, paths, ahead, title):
items = [static_shape(i, path) for i, (_, path) in enumerate(paths)]
return {
'ddd': 0,
'ind': index,
'ty': 4,
'nm': title,
'sr': 1,
'ks': transform(scale=scale),
'ao': 0,
'hasMask': True,
'masksProperties': wipe_mask(span, ahead),
'shapes': [_group(items, title)],
'ip': 0,
'op': DUR,
'st': 0,
'bm': 0,
}
return {
'v': '5.12.1',
'fr': FPS,
'ip': 0,
'op': DUR,
'w': int(CANVAS),
'h': int(CANVAS),
'nm': name,
'ddd': 0,
'assets': [],
'layers': [
layer(1, slashed, False, 'slashed'),
layer(2, plain, True, 'plain'),
],
'markers': [],
}
SPECS = [ SPECS = [
dict( dict(
name='ic_mic_to_videocam', name='ic_mic_to_videocam',
@@ -645,15 +978,30 @@ SPECS = [
] ]
SLASH_SPECS = [
dict(
name='ic_flash_on_to_off',
plain_cp=FLASH_ON, slashed_cp=FLASH_OFF,
fill=1.0,
scale=[(0, 100), (11, 92), (DUR, 100)],
),
]
def _write(name, data):
path = os.path.join(OUT_DIR, name + '.json')
with open(path, 'w') as fh:
json.dump(data, fh, separators=(',', ':'))
print(f'{name:24s} {os.path.getsize(path) // 1024:3d} KB '
f'layers={len(data["layers"])}')
def main(): def main():
os.makedirs(OUT_DIR, exist_ok=True) os.makedirs(OUT_DIR, exist_ok=True)
for spec in SPECS: for spec in SPECS:
data = build(**spec) _write(spec['name'], build(**spec))
path = os.path.join(OUT_DIR, spec['name'] + '.json') for spec in SLASH_SPECS:
with open(path, 'w') as fh: _write(spec['name'], build_slash(**spec))
json.dump(data, fh, separators=(',', ':'))
print(f"{spec['name']:24s} {os.path.getsize(path) // 1024:3d} KB "
f"paths={len(data['layers'][0]['shapes'][0]['it']) - 2}")
if __name__ == '__main__': if __name__ == '__main__':