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, 'текст')
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
Если делаешь кнопку, где иконка переключается перечёркнутая/неперечёркнутая (вспышка, микрофон, звук, уведомления) — она должна анимироваться 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**.
- Quality over quantity.
- **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
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/services.dart';
import 'package:flutter/widgets.dart';
@@ -5,7 +7,7 @@ import 'package:flutter/widgets.dart';
import '../../l10n/app_localizations.dart';
import '../../main.dart' show KometApp;
enum UploadKind { photo, video, file }
enum UploadKind { photo, video, videoNote, voice, file }
class _NotificationJob {
_NotificationJob({required this.kind, required this.count, this.filename});
@@ -41,6 +43,8 @@ class _NotificationJob {
return switch (kind) {
UploadKind.photo => l10n.uploadNotificationPhotos(count),
UploadKind.video => l10n.uploadNotificationVideo,
UploadKind.videoNote => l10n.uploadNotificationVideoNote,
UploadKind.voice => l10n.uploadNotificationVoice,
UploadKind.file =>
name == null || name.isEmpty ? l10n.uploadNotificationFile : name,
};
@@ -52,8 +56,10 @@ class UploadNotificationService {
'ru.komet.app/upload_service',
);
static const int _minIntervalMs = 350;
static const Duration _startDelay = Duration(milliseconds: 700);
static final Map<String, _NotificationJob> _jobs = {};
static Timer? _startTimer;
static bool _running = false;
static String? _lastTitle;
static String? _lastBody;
@@ -75,7 +81,14 @@ class UploadNotificationService {
count: count < 1 ? 1 : count,
filename: filename,
);
_push(force: true);
if (_running) {
_push(force: true);
return;
}
_startTimer ??= Timer(_startDelay, () {
_startTimer = null;
_push(force: true);
});
}
static void report(
@@ -102,16 +115,20 @@ class UploadNotificationService {
}
static void _stop() {
_startTimer?.cancel();
_startTimer = null;
final wasRunning = _running;
_running = false;
_lastTitle = null;
_lastBody = null;
_lastPercent = -1;
_lastPushAt = 0;
_invoke('stop', const <String, dynamic>{});
if (wasRunning) _invoke('stop', const <String, dynamic>{});
}
static void _push({bool force = false}) {
if (_jobs.isEmpty) return;
if (!_running && _startTimer != null) return;
var sumSent = 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({
required int accountId,
required int chatId,
@@ -6,6 +6,7 @@ import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:lottie/lottie.dart' show AssetLottie;
import 'package:path_provider/path_provider.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/logger.dart';
import '../../../widgets/custom_notification.dart';
import '../../../widgets/lottie_slash_icon.dart';
import 'voice_record_controller.dart';
const String _flashIcon = 'assets/lottie/ic_flash_on_to_off.json';
class VideoNoteController {
VideoNoteController({
required this.contextOf,
@@ -89,6 +93,7 @@ class VideoNoteController {
bool get _stub => !_rec.isAvailable;
Future<void> _initCamera() async {
unawaited(AssetLottie(_flashIcon).load());
if (_stub) {
_camReady.value = true;
_textureId.value = null;
@@ -457,17 +462,24 @@ class _CameraControls extends StatelessWidget {
children: [
if (controller.cameraControlsAvailable)
_ControlButton(
icon: Symbols.flip_camera_ios,
color: cs.onSurface,
onTap: controller.flipCamera,
child: Icon(
Symbols.flip_camera_ios,
size: 24,
color: cs.onSurface,
fill: 1,
),
),
if (controller.flashAvailable)
ValueListenableBuilder<bool>(
valueListenable: controller.flashOn,
builder: (context, on, _) => _ControlButton(
icon: on ? Symbols.flash_on : Symbols.flash_off,
color: on ? cs.primary : cs.onSurface,
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 {
const _ControlButton({
required this.icon,
required this.color,
required this.onTap,
});
const _ControlButton({required this.child, required this.onTap});
final IconData icon;
final Color color;
final Widget child;
final VoidCallback onTap;
@override
@@ -492,10 +499,7 @@ class _ControlButton extends StatelessWidget {
return InkResponse(
onTap: onTap,
radius: 26,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(icon, size: 24, color: color, fill: 1),
),
child: Padding(padding: const EdgeInsets.all(8), child: child),
);
}
}
+46 -158
View File
@@ -337,7 +337,6 @@ class _ChatScreenState extends State<ChatScreen>
final ValueNotifier<ReactionAnimationEvent?> _reactionAnimation =
ValueNotifier(null);
int _reactionAnimationToken = 0;
final Map<String, ValueNotifier<List<double>>> _photoUploadProgress = {};
final ValueNotifier<int> _scheduledCount = ValueNotifier(0);
late final VoiceRecordController _voiceRec = VoiceRecordController(
@@ -358,7 +357,7 @@ class _ChatScreenState extends State<ChatScreen>
StreamSubscription<UploadJobEvent>? _uploadEventSub;
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) {
final existing = _reactionNotifiers[m.id];
@@ -2088,10 +2087,6 @@ class _ChatScreenState extends State<ChatScreen>
}
_reactionNotifiers.clear();
_reactionAnimation.dispose();
for (final n in _photoUploadProgress.values) {
n.dispose();
}
_photoUploadProgress.clear();
ChatActivityStore.instance
.listenable(widget.chatId)
.removeListener(_recomputeHeaderStatus);
@@ -6022,77 +6017,24 @@ class _ChatScreenState extends State<ChatScreen>
return;
}
final wave = _buildWave(amps);
final tempId = _nextTempId();
final progress = ValueNotifier<List<double>>(const [0]);
_photoUploadProgress[tempId] = progress;
_messages.add(
CachedMessage(
id: tempId,
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
time: DateTime.now().millisecondsSinceEpoch,
status: 'sending',
attachments: [AudioAttachment(duration: durationMs)],
final placeholder = _addOptimisticMediaMessage(
AudioAttachment(
duration: durationMs,
waveform: String.fromCharCodes(wave),
),
);
_lastSentId = tempId;
_bumpMessages();
Haptics.send();
_scrollToBottom();
try {
final info = await messagesModule.requestAudioUploadUrl();
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.sendAudioMessage(
widget.chatId,
info.token,
duration: durationMs,
unawaited(
UploadService.instance.sendVoice(
accountId: _myId,
chatId: widget.chatId,
tempId: placeholder.id,
file: file,
durationMs: durationMs,
wave: wave,
);
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 (_) {}
}
placeholder: placeholder,
),
);
}
Future<void> _sendVideoNote(File file, int durationMs) async {
@@ -6102,85 +6044,23 @@ class _ChatScreenState extends State<ChatScreen>
} catch (_) {}
return;
}
final tempId = _nextTempId();
final progress = ValueNotifier<List<double>>(const [0]);
_photoUploadProgress[tempId] = progress;
_messages.add(
CachedMessage(
id: tempId,
final placeholder = _addOptimisticMediaMessage(
VideoAttachment(duration: durationMs, videoType: 1, localPath: file.path),
);
unawaited(
UploadService.instance.sendVideoNote(
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
time: DateTime.now().millisecondsSinceEpoch,
status: 'sending',
attachments: [VideoAttachment(duration: durationMs, videoType: 1)],
tempId: placeholder.id,
file: file,
durationMs: durationMs,
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 tempId = _nextTempId();
final msg = CachedMessage(
@@ -6225,7 +6105,7 @@ class _ChatScreenState extends State<ChatScreen>
}
Future<void> _sendHistoryFile(FileHistoryEntry entry) async {
final tempId = _addOptimisticFileMessage(
final tempId = _addOptimisticMediaMessage(
FileAttachment(
fileId: entry.fileId,
fileToken: entry.token,
@@ -6251,7 +6131,7 @@ class _ChatScreenState extends State<ChatScreen>
}
Future<bool> _sendFileById(int fileId) async {
final tempId = _addOptimisticFileMessage(
final tempId = _addOptimisticMediaMessage(
FileAttachment(fileId: fileId),
).id;
try {
@@ -6478,12 +6358,26 @@ class _ChatScreenState extends State<ChatScreen>
return;
}
_failPhotoMessage(event.tempId);
if (event.kind == UploadKind.file) {
showCustomNotification(context, 'Ошибка: ${event.reason}');
}
final text = _uploadFailureText(event.kind, 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() {
final job = UploadService.instance.activeFileJob(widget.chatId);
if (job?.id == _uploadStatusJobId) return;
@@ -6735,21 +6629,15 @@ class _ChatScreenState extends State<ChatScreen>
);
}
void _failPhotoMessage(String tempId) {
final idx = _messages.indexWhere((m) => m.id == tempId);
if (idx != -1) {
_messages[idx] = _messages[idx].copyWith(status: 'error');
_bumpMessages();
}
_disposePhotoProgress(tempId);
Haptics.error();
}
void _disposePhotoProgress(String tempId) {
_photoUploadProgress.remove(tempId)?.dispose();
}
void _refuseUnencrypted(String what) {
if (!mounted) return;
_showAttachmentPanel.value = false;
@@ -6836,7 +6724,7 @@ class _ChatScreenState extends State<ChatScreen>
final placeholder = scheduledTime != null
? null
: _addOptimisticFileMessage(
: _addOptimisticMediaMessage(
FileAttachment(name: filename, size: size),
);
@@ -33,6 +33,7 @@ class VideoBubble extends StatelessWidget {
cs: ctx.cs,
textColor: ctx.text,
meta: ctx.meta(),
uploadProgress: ctx.uploadProgress,
);
}
final hasCaption = message.text != null && message.text!.isNotEmpty;
@@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -15,6 +17,7 @@ import '../../../../core/utils/haptics.dart';
import '../../../../core/utils/logger.dart';
import '../../../../models/attachment.dart';
import '../../small_spinner.dart';
import '../../upload_progress_ring.dart';
class VideoNoteBubble extends StatefulWidget {
final VideoAttachment attachment;
@@ -26,6 +29,7 @@ class VideoNoteBubble extends StatefulWidget {
final ColorScheme cs;
final Color textColor;
final Widget meta;
final ValueListenable<List<double>>? uploadProgress;
const VideoNoteBubble({
super.key,
@@ -38,6 +42,7 @@ class VideoNoteBubble extends StatefulWidget {
required this.cs,
required this.textColor,
required this.meta,
this.uploadProgress,
});
@override
@@ -57,6 +62,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
final ValueNotifier<double> _ringProgress = ValueNotifier(0);
Uint8List? _preview;
VideoPlayerController? _controller;
VideoPlayerController? _local;
Future<void>? _initializing;
Duration? _pendingSeek;
double? _lastAngle;
@@ -70,6 +76,19 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
int? get _videoId => widget.attachment.videoId;
String get _cacheName => 'videonote_$_videoId.mp4';
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 {
final controller = _controller;
@@ -81,7 +100,14 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
super.initState();
_expand = AnimationController(vsync: this, duration: _expandDuration);
_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
@@ -90,6 +116,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
if (old.attachment.previewData != widget.attachment.previewData) {
_preview = _previewBytes(widget.attachment.previewData);
}
if (old.attachment.localPath != _localPath) _dropLocalPreview();
}
@override
@@ -98,6 +125,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
_PreviewPool.unregister(this);
_expand.dispose();
_ringProgress.dispose();
_dropLocalPreview();
final controller = _controller;
_controller = null;
if (controller != null) {
@@ -107,6 +135,34 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
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() {
final controller = _controller;
if (controller == null) return;
@@ -152,11 +208,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
}
}
Future<void> _preload() async {
final file = await _fetch(priority: false);
if (file == null || !mounted) return;
await _ensureController(file);
}
Future<void> _warmCache() => _fetch(priority: false);
Future<File?> _fetch({required bool priority}) {
final videoId = _videoId;
@@ -176,6 +228,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
}
Future<VideoPlayerController?> _ensureController(File file) async {
if (!mounted) return null;
if (_controller != null) return _controller;
final live = MediaPlayback.instance.liveVideoNote(_cacheName);
if (live != null) {
@@ -230,6 +283,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
}
Future<void> _toggle() async {
if (_videoId == null) return;
if (_ready) {
if (_controller!.value.isPlaying) {
await _pause();
@@ -388,13 +442,14 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
}
Widget _buildCircle(double size) {
final controller = _controller;
final ready = _ready;
final playing = ready && _playing && !_scrubbing;
final preview = _preview;
final local = _local;
final uploading = widget.uploadProgress;
return GestureDetector(
onTap: _toggle,
onTap: uploading == null ? _toggle : null,
child: SizedBox(
width: size,
height: size,
@@ -409,63 +464,112 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
child: AnimatedSwitcher(
duration: _swapDuration,
child: ready
? SizedBox.expand(
key: const ValueKey('note-video'),
child: FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: controller!.value.size.width,
height: controller.value.size.height,
child: VideoPlayer(controller),
),
),
? _videoSurface(
_controller!,
const ValueKey('note-video'),
)
: preview != null
? SizedBox.expand(
key: const ValueKey('note-preview'),
child: Image.memory(
preview,
fit: BoxFit.cover,
gaplessPlayback: true,
),
)
: SizedBox.expand(
key: const ValueKey('note-empty'),
child: ColoredBox(
color: widget.cs.surfaceContainerHighest,
),
),
: local != null && local.value.isInitialized
? _videoSurface(local, const ValueKey('note-local'))
: _buildPoster(preview),
),
),
),
),
if (ready) _buildRing(size),
if (!playing)
Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Colors.black45,
shape: BoxShape.circle,
if (uploading != null) ...[
Positioned.fill(
child: ClipOval(
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.35),
),
),
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) {
return GestureDetector(
onTapUp: (details) => _ringTap(details.localPosition, size),
@@ -29,6 +29,7 @@ class VoiceMessageBubble extends StatefulWidget {
final int senderId;
final int? audioId;
final String? preloadedText;
final ValueListenable<List<double>>? uploadProgress;
const VoiceMessageBubble({
super.key,
@@ -47,6 +48,7 @@ class VoiceMessageBubble extends StatefulWidget {
required this.senderId,
this.audioId,
this.preloadedText,
this.uploadProgress,
});
@override
@@ -101,7 +103,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
);
}
bool get _uploading => widget.uploadProgress != null;
void _toggle() {
if (_uploading) return;
_claimPlayback();
_audio.toggle();
}
@@ -176,8 +181,9 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
widget.isMe ? widget.cs.onPrimaryContainer : widget.cs.primary;
Widget _buildPlayButton() {
final uploading = widget.uploadProgress;
return GestureDetector(
onTap: _toggle,
onTap: uploading == null ? _toggle : null,
child: Container(
width: 32,
height: 32,
@@ -187,46 +193,73 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
: widget.cs.primaryContainer,
shape: BoxShape.circle,
),
child: AnimatedBuilder(
animation: Listenable.merge([
_audio.downloaded,
_audio.downloadProgress,
_audio.playing,
]),
builder: (context, _) {
final progress = _audio.downloadProgress.value;
if (progress != null) {
return Padding(
padding: const EdgeInsets.all(4),
child: CircularProgressIndicator(
strokeWidth: 2,
value: progress > 0 ? progress : null,
color: _accent,
backgroundColor: _accent.withValues(alpha: 0.2),
),
);
}
final IconData icon;
if (_audio.playing.value) {
icon = Symbols.pause;
} else if (_audio.downloaded.value) {
icon = Symbols.play_arrow;
} else {
icon = Symbols.arrow_downward;
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 160),
transitionBuilder: (child, animation) =>
ScaleTransition(scale: animation, child: child),
child: Icon(
icon,
key: ValueKey(icon),
color: _accent,
size: 18,
child: uploading != null
? _buildUploadIndicator(uploading)
: AnimatedBuilder(
animation: Listenable.merge([
_audio.downloaded,
_audio.downloadProgress,
_audio.playing,
]),
builder: (context, _) {
final progress = _audio.downloadProgress.value;
if (progress != null) {
return Padding(
padding: const EdgeInsets.all(4),
child: CircularProgressIndicator(
strokeWidth: 2,
value: progress > 0 ? progress : null,
color: _accent,
backgroundColor: _accent.withValues(alpha: 0.2),
),
);
}
final IconData icon;
if (_audio.playing.value) {
icon = Symbols.pause;
} else if (_audio.downloaded.value) {
icon = Symbols.play_arrow;
} else {
icon = Symbols.arrow_downward;
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 160),
transitionBuilder: (child, animation) =>
ScaleTransition(scale: animation, child: child),
child: Icon(
icon,
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) {
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(
RRect.fromRectAndRadius(
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,
audioId: audioId,
preloadedText: cachedTranscription?.text,
uploadProgress: ctx.uploadProgress,
);
}
}
+2
View File
@@ -1070,6 +1070,8 @@
}
},
"uploadNotificationVideo": "Video",
"uploadNotificationVideoNote": "Video message",
"uploadNotificationVoice": "Voice message",
"uploadNotificationFile": "File",
"uploadNotificationMultiple": "{count, plural, other{Sending {count} files}}",
"@uploadNotificationMultiple": {
+12
View File
@@ -4706,6 +4706,18 @@ abstract class AppLocalizations {
/// **'Video'**
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.
///
/// In en, this message translates to:
+6
View File
@@ -2464,6 +2464,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get uploadNotificationVideo => 'Video';
@override
String get uploadNotificationVideoNote => 'Video message';
@override
String get uploadNotificationVoice => 'Voice message';
@override
String get uploadNotificationFile => 'File';
+6
View File
@@ -2478,6 +2478,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get uploadNotificationVideo => 'Видео';
@override
String get uploadNotificationVideoNote => 'Кружок';
@override
String get uploadNotificationVoice => 'Голосовое сообщение';
@override
String get uploadNotificationFile => 'Файл';
+2
View File
@@ -814,6 +814,8 @@
}
},
"uploadNotificationVideo": "Видео",
"uploadNotificationVideoNote": "Кружок",
"uploadNotificationVoice": "Голосовое сообщение",
"uploadNotificationFile": "Файл",
"uploadNotificationMultiple": "{count, plural, other{Отправка файлов: {count}}}",
"@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
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.5.17+17
version: 0.5.18+18
environment:
sdk: ^3.10.4
+1
View File
@@ -51,6 +51,7 @@ void main() {
isMounted: () => true,
onRecorded: (File file, int durationMs) async {},
formatElapsed: (milliseconds) => '$milliseconds',
bottomInset: () => 0,
);
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 (инстанс по умолчанию
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
@@ -36,6 +42,8 @@ class Font:
'>H', self.data[maxp_off + 4:maxp_off + 6])[0]
self._read_loca()
self._read_cmap()
self._read_fvar()
self._read_gvar()
def _read_loca(self):
off, _ = self.tables['loca']
@@ -90,9 +98,131 @@ class Font:
for c in range(s, e + 1):
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]
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):
goff, _ = self.tables['glyf']
@@ -197,6 +327,94 @@ def _f2dot14(d, p):
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):
"""TrueType quadratic contour -> list of cubic segments [(p0,c1,c2,p1), ...]."""
pts = []
@@ -367,9 +585,9 @@ def _area(path):
return area / 2
def glyph_paths(codepoint, count):
def glyph_paths(codepoint, count, fill=0.0):
out = []
for contour in _font.contours(codepoint):
for contour in _font.contours(codepoint, fill):
path = _exact_path(contour, count)
if not path:
continue
@@ -414,10 +632,10 @@ def outer_sign(shapes):
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."""
src = glyph_paths(from_cp, count)
dst = glyph_paths(to_cp, count)
src = glyph_paths(from_cp, count, fill)
dst = glyph_paths(to_cp, count, fill)
src_sign = outer_sign(src)
dst_sign = outer_sign(dst)
@@ -441,6 +659,8 @@ def pair_glyphs(from_cp, to_cp, count):
MIC = 0xE31D
CAM = 0xE04B
SEND = 0xE163
FLASH_ON = 0xE3E7
FLASH_OFF = 0xE3E6
POINTS = 56
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):
out = []
for i, (frame, value) in enumerate(stops):
@@ -539,30 +807,10 @@ def transform(rotation=None, scale=None, offset_x=None):
return ks
def build(name, from_cp, to_cp, rotation=None, scale=None, offset_x=None):
pairs = pair_glyphs(from_cp, to_cp, POINTS)
def build(name, from_cp, to_cp, rotation=None, scale=None, offset_x=None,
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.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 {
'v': '5.12.1',
@@ -582,17 +830,7 @@ def build(name, from_cp, to_cp, rotation=None, scale=None, offset_x=None):
'sr': 1,
'ks': transform(rotation, scale, offset_x),
'ao': 0,
'shapes': [{
'ty': 'gr',
'it': items,
'nm': 'Group 1',
'np': len(items),
'cix': 2,
'bm': 0,
'ix': 1,
'mn': 'ADBE Vector Group',
'hd': False,
}],
'shapes': [_group(items, 'Group 1')],
'ip': 0,
'op': DUR,
'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 = [
dict(
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():
os.makedirs(OUT_DIR, exist_ok=True)
for spec in SPECS:
data = build(**spec)
path = os.path.join(OUT_DIR, spec['name'] + '.json')
with open(path, 'w') as fh:
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}")
_write(spec['name'], build(**spec))
for spec in SLASH_SPECS:
_write(spec['name'], build_slash(**spec))
if __name__ == '__main__':