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:
co-authored by
Claude Opus 5
parent
7bbc77192a
commit
08d29ef996
@@ -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/ руками не правь — только через генератор.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1936,6 +1936,7 @@ class MessageBubble extends StatelessWidget {
|
||||
senderId: message.senderId,
|
||||
audioId: audioId,
|
||||
preloadedText: cachedTranscription?.text,
|
||||
uploadProgress: ctx.uploadProgress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1070,6 +1070,8 @@
|
||||
}
|
||||
},
|
||||
"uploadNotificationVideo": "Video",
|
||||
"uploadNotificationVideoNote": "Video message",
|
||||
"uploadNotificationVoice": "Voice message",
|
||||
"uploadNotificationFile": "File",
|
||||
"uploadNotificationMultiple": "{count, plural, other{Sending {count} files}}",
|
||||
"@uploadNotificationMultiple": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -2478,6 +2478,12 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get uploadNotificationVideo => 'Видео';
|
||||
|
||||
@override
|
||||
String get uploadNotificationVideoNote => 'Кружок';
|
||||
|
||||
@override
|
||||
String get uploadNotificationVoice => 'Голосовое сообщение';
|
||||
|
||||
@override
|
||||
String get uploadNotificationFile => 'Файл';
|
||||
|
||||
|
||||
@@ -814,6 +814,8 @@
|
||||
}
|
||||
},
|
||||
"uploadNotificationVideo": "Видео",
|
||||
"uploadNotificationVideoNote": "Кружок",
|
||||
"uploadNotificationVoice": "Голосовое сообщение",
|
||||
"uploadNotificationFile": "Файл",
|
||||
"uploadNotificationMultiple": "{count, plural, other{Отправка файлов: {count}}}",
|
||||
"@uploadNotificationMultiple": {
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.5.17+17
|
||||
version: 0.5.18+18
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.4
|
||||
|
||||
@@ -51,6 +51,7 @@ void main() {
|
||||
isMounted: () => true,
|
||||
onRecorded: (File file, int durationMs) async {},
|
||||
formatElapsed: (milliseconds) => '$milliseconds',
|
||||
bottomInset: () => 0,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
|
||||
@@ -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
@@ -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__':
|
||||
|
||||
Reference in New Issue
Block a user