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