diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index 11af736..39e3418 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -85,7 +85,7 @@ class MainActivity : FlutterActivity() { const val NFC_PHASE_MIN_MS = 350L const val NFC_PHASE_JITTER_MS = 400 const val BLE_PERMS_REQUEST = 7711 - const val CAMERA_PERM_REQUEST = 7712 + const val NOTE_PERMS_REQUEST = 7712 val NFC_READER_FLAGS = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK @@ -243,7 +243,7 @@ class MainActivity : FlutterActivity() { "ru.komet.app/video_note", ).setMethodCallHandler { call, result -> when (call.method) { - "permission" -> requestCameraPermission(result) + "permission" -> requestNotePermissions(result) "init" -> { val front = call.argument("front") ?: true val size = call.argument("size") ?: 480 @@ -708,24 +708,27 @@ class MainActivity : FlutterActivity() { } } - private var cameraPermResult: MethodChannel.Result? = null + private var notePermResult: MethodChannel.Result? = null - private fun requestCameraPermission(result: MethodChannel.Result) { - val granted = ContextCompat.checkSelfPermission( - this, - Manifest.permission.CAMERA, - ) == PackageManager.PERMISSION_GRANTED - if (granted) { - result.success(true); return + private fun isGranted(permission: String): Boolean = + ContextCompat.checkSelfPermission(this, permission) == + PackageManager.PERMISSION_GRANTED + + private fun notePermissionState(): Map = mapOf( + "camera" to isGranted(Manifest.permission.CAMERA), + "microphone" to isGranted(Manifest.permission.RECORD_AUDIO), + ) + + private fun requestNotePermissions(result: MethodChannel.Result) { + val state = notePermissionState() + if (state.values.all { it } || notePermResult != null) { + result.success(state); return } - if (cameraPermResult != null) { - result.success(false); return - } - cameraPermResult = result + notePermResult = result ActivityCompat.requestPermissions( this, - arrayOf(Manifest.permission.CAMERA), - CAMERA_PERM_REQUEST, + arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO), + NOTE_PERMS_REQUEST, ) } @@ -735,13 +738,10 @@ class MainActivity : FlutterActivity() { grantResults: IntArray, ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (requestCode == CAMERA_PERM_REQUEST) { - val pending = cameraPermResult - cameraPermResult = null - pending?.success( - grantResults.isNotEmpty() && - grantResults.all { it == PackageManager.PERMISSION_GRANTED }, - ) + if (requestCode == NOTE_PERMS_REQUEST) { + val pending = notePermResult + notePermResult = null + pending?.success(notePermissionState()) return } if (requestCode != BLE_PERMS_REQUEST) return diff --git a/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt b/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt index 78af2a0..00c9fc7 100644 --- a/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt +++ b/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt @@ -172,7 +172,11 @@ class VideoNoteRecorder( if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED ) { - result.error("NO_PERMISSION", "camera permission required", null) + result.error("NO_CAMERA_PERMISSION", "camera permission required", null) + return + } + if (!hasMicPermission()) { + result.error("NO_MIC_PERMISSION", "microphone permission required", null) return } try { @@ -453,10 +457,18 @@ class VideoNoteRecorder( openCamera(result, entry.id()) } + private fun hasMicPermission(): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + fun start(result: MethodChannel.Result) { if (cameraDevice == null || !glReady) { result.error("NOT_READY", "camera not initialized", null); return } + if (!hasMicPermission()) { + result.error("NO_MIC_PERMISSION", "microphone permission required", null) + return + } try { val path = File(context.cacheDir, "note_${System.nanoTime()}.mp4").absolutePath outputPath = path diff --git a/ios/Runner/KometVideoNote.swift b/ios/Runner/KometVideoNote.swift index 94d8a23..af1f2b0 100644 --- a/ios/Runner/KometVideoNote.swift +++ b/ios/Runner/KometVideoNote.swift @@ -54,12 +54,10 @@ final class KometVideoNote: NSObject { static func requestPermission(_ result: @escaping FlutterResult) { AVCaptureDevice.requestAccess(for: .video) { video in - guard video else { - DispatchQueue.main.async { result(NSNumber(value: false)) } - return - } AVCaptureDevice.requestAccess(for: .audio) { audio in - DispatchQueue.main.async { result(NSNumber(value: audio)) } + DispatchQueue.main.async { + result(["camera": NSNumber(value: video), "microphone": NSNumber(value: audio)]) + } } } } @@ -71,7 +69,11 @@ final class KometVideoNote: NSObject { queue.async { guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized else { - Self.fail(result, "NO_PERMISSION", "camera permission required") + Self.fail(result, "NO_CAMERA_PERMISSION", "camera permission required") + return + } + guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { + Self.fail(result, "NO_MIC_PERMISSION", "microphone permission required") return } do { @@ -126,6 +128,10 @@ final class KometVideoNote: NSObject { DispatchQueue.main.async { result(nil) } return } + guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { + Self.fail(result, "NO_MIC_PERMISSION", "microphone permission required") + return + } do { try self.prepareWriter() } catch { diff --git a/lib/core/config/app_chat_chrome.dart b/lib/core/config/app_chat_chrome.dart index 5e55fcf..677380c 100644 --- a/lib/core/config/app_chat_chrome.dart +++ b/lib/core/config/app_chat_chrome.dart @@ -15,7 +15,7 @@ class AppChatChrome { static final _setting = PersistedEnum( prefKey: prefKey, - defaultValue: ChatChromeStyle.color, + defaultValue: ChatChromeStyle.none, encode: _encode, decode: _parse, ); @@ -23,7 +23,7 @@ class AppChatChrome { static ValueNotifier get current => _setting.current; static ChatChromeStyle _parse(String? value) => - enumFromName(ChatChromeStyle.values, value, ChatChromeStyle.color); + enumFromName(ChatChromeStyle.values, value, ChatChromeStyle.none); static String _encode(ChatChromeStyle value) => value.name; diff --git a/lib/core/config/app_composer_style.dart b/lib/core/config/app_composer_style.dart index 69a4e82..39ed03c 100644 --- a/lib/core/config/app_composer_style.dart +++ b/lib/core/config/app_composer_style.dart @@ -18,7 +18,7 @@ class AppComposerStyle { static final _setting = PersistedEnum( prefKey: prefKey, - defaultValue: ComposerStyle.auto, + defaultValue: ComposerStyle.glossy, encode: (value) => value.name, decode: _parse, ); @@ -30,5 +30,5 @@ class AppComposerStyle { static Future save(ComposerStyle value) => _setting.save(value); static ComposerStyle _parse(String? val) => - enumFromName(ComposerStyle.values, val, ComposerStyle.auto); + enumFromName(ComposerStyle.values, val, ComposerStyle.glossy); } diff --git a/lib/core/config/app_nav_pill_style.dart b/lib/core/config/app_nav_pill_style.dart index 0a76c44..4423e13 100644 --- a/lib/core/config/app_nav_pill_style.dart +++ b/lib/core/config/app_nav_pill_style.dart @@ -29,7 +29,7 @@ class AppNavPillStyle { static final _setting = PersistedEnum( prefKey: prefKey, - defaultValue: NavPillStyle.auto, + defaultValue: NavPillStyle.glossy, encode: (value) => value.name, decode: _parse, ); @@ -41,5 +41,5 @@ class AppNavPillStyle { static Future save(NavPillStyle value) => _setting.save(value); static NavPillStyle _parse(String? val) => - enumFromName(NavPillStyle.values, val, NavPillStyle.auto); + enumFromName(NavPillStyle.values, val, NavPillStyle.glossy); } diff --git a/lib/core/config/app_visual_style.dart b/lib/core/config/app_visual_style.dart index 8c8c786..e102b35 100644 --- a/lib/core/config/app_visual_style.dart +++ b/lib/core/config/app_visual_style.dart @@ -13,7 +13,7 @@ class AppVisualStyle { static final _setting = PersistedEnum( prefKey: prefKey, - defaultValue: VisualStyle.materialYou, + defaultValue: VisualStyle.glossy, encode: _encode, decode: _parse, ); @@ -27,5 +27,5 @@ class AppVisualStyle { static String _encode(VisualStyle value) => value.name; static VisualStyle _parse(String? val) => - enumFromName(VisualStyle.values, val, VisualStyle.materialYou); + enumFromName(VisualStyle.values, val, VisualStyle.glossy); } diff --git a/lib/core/media/native_video_note_recorder.dart b/lib/core/media/native_video_note_recorder.dart index 54dca5f..ffe1e67 100644 --- a/lib/core/media/native_video_note_recorder.dart +++ b/lib/core/media/native_video_note_recorder.dart @@ -4,12 +4,17 @@ import 'package:flutter/services.dart'; import '../utils/logger.dart'; -/// Нативная запись видео-кружка: пишет квадрат сразу при съёмке — как -/// официальный клиент (по умолчанию 480×480@30, размер и fps настраиваются -/// в дев-меню). На Android — Camera2 + MediaRecorder, на iOS — -/// AVCaptureSession + AVAssetWriter. Превью отдаётся через Flutter -/// [Texture] по [textureId]. Перекодирование не используется (серверный -/// валидатор принимает только нативно записанный MP4). +class VideoNoteAccess { + const VideoNoteAccess({required this.camera, required this.microphone}); + + static const denied = VideoNoteAccess(camera: false, microphone: false); + + final bool camera; + final bool microphone; + + bool get granted => camera && microphone; +} + class NativeVideoNoteRecorder { static const _channel = MethodChannel('ru.komet.app/video_note'); @@ -17,31 +22,30 @@ class NativeVideoNoteRecorder { bool hasFlash = false; bool get isAvailable => Platform.isAndroid || Platform.isIOS; - Future requestPermission() async { - if (!isAvailable) return false; + Future requestAccess() async { + if (!isAvailable) return VideoNoteAccess.denied; try { - return await _channel.invokeMethod('permission') ?? false; + final res = await _channel.invokeMapMethod('permission'); + return VideoNoteAccess( + camera: res?['camera'] as bool? ?? false, + microphone: res?['microphone'] as bool? ?? false, + ); } catch (e) { - logger.w('NativeVideoNoteRecorder.requestPermission: $e'); - return false; + logger.w('NativeVideoNoteRecorder.requestAccess: $e'); + return VideoNoteAccess.denied; } } Future init({bool front = true, int size = 480, int fps = 30}) async { if (!isAvailable) return false; - try { - final res = await _channel.invokeMapMethod('init', { - 'front': front, - 'size': size, - 'fps': fps, - }); - textureId = res?['textureId'] as int?; - hasFlash = res?['hasFlash'] as bool? ?? false; - return textureId != null; - } catch (e) { - logger.w('NativeVideoNoteRecorder.init: $e'); - return false; - } + final res = await _channel.invokeMapMethod('init', { + 'front': front, + 'size': size, + 'fps': fps, + }); + textureId = res?['textureId'] as int?; + hasFlash = res?['hasFlash'] as bool? ?? false; + return textureId != null; } Future switchCamera() async { @@ -65,25 +69,19 @@ class NativeVideoNoteRecorder { } } - Future start() async { - if (!isAvailable) return false; - try { - await _channel.invokeMethod('start'); - return true; - } catch (e) { - logger.w('NativeVideoNoteRecorder.start: $e'); - return false; + Future start() async { + if (!isAvailable) { + throw PlatformException( + code: 'UNSUPPORTED', + message: 'video notes are not supported on this platform', + ); } + await _channel.invokeMethod('start'); } Future stop() async { if (!isAvailable) return null; - try { - return await _channel.invokeMethod('stop'); - } catch (e) { - logger.w('NativeVideoNoteRecorder.stop: $e'); - return null; - } + return _channel.invokeMethod('stop'); } Future dispose() async { diff --git a/lib/core/utils/route_settle.dart b/lib/core/utils/route_settle.dart new file mode 100644 index 0000000..a1b727f --- /dev/null +++ b/lib/core/utils/route_settle.dart @@ -0,0 +1,81 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +class RouteSettle { + RouteSettle({required this.isMounted}); + + static const Duration _safetyMargin = Duration(milliseconds: 250); + + final bool Function() isMounted; + final List _queued = []; + Animation? _animation; + Timer? _timer; + bool _bindScheduled = false; + bool _settled = false; + bool _disposed = false; + + bool get settled => _settled; + + void bind(BuildContext context) { + if (_disposed || _settled || _bindScheduled || _animation != null) return; + _bindScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _bindScheduled = false; + if (_disposed || _settled) return; + if (!isMounted()) { + settleNow(); + return; + } + _attach(context); + }); + } + + void run(VoidCallback action) { + if (_settled) { + action(); + return; + } + _queued.add(action); + } + + void settleNow() { + if (_disposed || _settled) return; + _settled = true; + _detach(); + final pending = List.of(_queued); + _queued.clear(); + if (!isMounted()) return; + for (final action in pending) { + action(); + } + } + + void dispose() { + _disposed = true; + _detach(); + _queued.clear(); + } + + void _attach(BuildContext context) { + final route = ModalRoute.of(context); + final animation = route?.animation; + if (animation == null || animation.status == AnimationStatus.completed) { + settleNow(); + return; + } + _animation = animation..addStatusListener(_onStatus); + _timer = Timer(route!.transitionDuration + _safetyMargin, settleNow); + } + + void _onStatus(AnimationStatus status) { + if (status == AnimationStatus.completed) settleNow(); + } + + void _detach() { + _animation?.removeStatusListener(_onStatus); + _animation = null; + _timer?.cancel(); + _timer = null; + } +} diff --git a/lib/frontend/debug/fps_overlay_layer.dart b/lib/frontend/debug/fps_overlay_layer.dart index c9af7b0..fc94a5f 100644 --- a/lib/frontend/debug/fps_overlay_layer.dart +++ b/lib/frontend/debug/fps_overlay_layer.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -10,15 +11,34 @@ class FpsOverlayLayer extends StatefulWidget { State createState() => _FpsOverlayLayerState(); } +class _FrameSample { + const _FrameSample({ + required this.endMicros, + required this.costMicros, + required this.rasterBound, + }); + + final int endMicros; + final int costMicros; + final bool rasterBound; +} + class _FpsOverlayLayerState extends State { - static const int _maxSamples = 90; - static const int _minUiRefreshMs = 160; - static const double _initialWidthGuess = 96; + static const int _fpsWindowMicros = 1000000; + static const int _jankWindowMicros = 3000000; + static const int _minUiRefreshMs = 100; + static const double _initialWidthGuess = 132; static const double _initialHeightGuess = 36; - final List _frameMicros = []; + final List<_FrameSample> _recent = <_FrameSample>[]; + final List<_FrameSample> _janky = <_FrameSample>[]; final GlobalKey _badgeKey = GlobalKey(); + double _refreshRate = 60; + double _budgetMicros = 1000000 / 60; double _fps = 0; + int _worstMicros = 0; + int _jankCount = 0; + bool _worstRasterBound = false; DateTime _lastUiUpdate = DateTime.fromMillisecondsSinceEpoch(0); double? _left; double? _top; @@ -38,6 +58,8 @@ class _FpsOverlayLayerState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); + _refreshRate = _resolveRefreshRate(); + _budgetMicros = 1000000 / _refreshRate; if (_left != null && _top != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -47,6 +69,11 @@ class _FpsOverlayLayerState extends State { } } + double _resolveRefreshRate() { + final hz = View.of(context).display.refreshRate; + return hz.isFinite && hz >= 30 ? hz : 60; + } + void _ensureInitialPosition() { if (_left != null) return; final mq = MediaQuery.of(context); @@ -63,12 +90,8 @@ class _FpsOverlayLayerState extends State { final bottomMax = screen.height - mq.padding.bottom; final box = _badgeKey.currentContext?.findRenderObject() as RenderBox?; - final bw = box?.hasSize == true - ? box!.size.width - : _initialWidthGuess; - final bh = box?.hasSize == true - ? box!.size.height - : _initialHeightGuess; + final bw = box?.hasSize == true ? box!.size.width : _initialWidthGuess; + final bh = box?.hasSize == true ? box!.size.height : _initialHeightGuess; _left = _left!.clamp(0.0, math.max(0.0, screen.width - bw)); _top = _top!.clamp(topMin, math.max(topMin, bottomMax - bh)); @@ -76,23 +99,54 @@ class _FpsOverlayLayerState extends State { void _onTimings(List timings) { for (final t in timings) { - final us = t.totalSpan.inMicroseconds; - if (us <= 0) continue; - _frameMicros.add(us); - while (_frameMicros.length > _maxSamples) { - _frameMicros.removeAt(0); + final build = t.buildDuration.inMicroseconds; + final raster = t.rasterDuration.inMicroseconds; + final sample = _FrameSample( + endMicros: t.timestampInMicroseconds(ui.FramePhase.rasterFinish), + costMicros: build > raster ? build : raster, + rasterBound: raster >= build, + ); + _recent.add(sample); + if (sample.costMicros > _budgetMicros) _janky.add(sample); + } + if (_recent.isEmpty) return; + + final newest = _recent.last.endMicros; + _recent.removeWhere((s) => newest - s.endMicros > _fpsWindowMicros); + _janky.removeWhere((s) => newest - s.endMicros > _jankWindowMicros); + + var sum = 0; + for (final s in _recent) { + sum += s.costMicros; + } + var worst = 0; + var worstRasterBound = false; + for (final s in _janky) { + if (s.costMicros > worst) { + worst = s.costMicros; + worstRasterBound = s.rasterBound; } } + + final mean = sum / _recent.length; + final fps = 1000000 / math.max(mean, _budgetMicros); + final now = DateTime.now(); - if (now.difference(_lastUiUpdate).inMilliseconds < _minUiRefreshMs) { - return; - } + if (now.difference(_lastUiUpdate).inMilliseconds < _minUiRefreshMs) return; _lastUiUpdate = now; - if (!mounted || _frameMicros.isEmpty) return; - final sum = _frameMicros.fold(0, (a, b) => a + b); - final avg = sum / _frameMicros.length; - final fps = avg > 0 ? (1000000.0 / avg).clamp(0.0, 999.0) : 0.0; - setState(() => _fps = fps); + if (!mounted) return; + setState(() { + _fps = fps; + _worstMicros = worst; + _jankCount = _janky.length; + _worstRasterBound = worstRasterBound; + }); + } + + Color get _tint { + if (_jankCount == 0) return const Color(0xFFB8F5C6); + if (_worstMicros > _budgetMicros * 3) return const Color(0xFFFFAB91); + return const Color(0xFFFFE082); } @override @@ -100,6 +154,7 @@ class _FpsOverlayLayerState extends State { _ensureInitialPosition(); _clampPositionToScreen(); + final tint = _tint; return Positioned( left: _left, top: _top, @@ -123,18 +178,31 @@ class _FpsOverlayLayerState extends State { color: const Color(0xCC000000), borderRadius: BorderRadius.circular(8), ), - child: Text( - '${_fps.round()} FPS', - style: TextStyle( - color: _fps >= 55 - ? const Color(0xFFB8F5C6) - : _fps >= 30 - ? const Color(0xFFFFE082) - : const Color(0xFFFFAB91), - fontSize: 13, - fontWeight: FontWeight.w600, - fontFeatures: const [FontFeature.tabularFigures()], - ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${_fps.round()} FPS · ${_refreshRate.round()} Hz', + style: TextStyle( + color: tint, + fontSize: 13, + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + if (_jankCount > 0) + Text( + '${(_worstMicros / 1000).round()} ms ×$_jankCount ' + '${_worstRasterBound ? 'gpu' : 'ui'}', + style: TextStyle( + color: tint, + fontSize: 11, + fontWeight: FontWeight.w500, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], ), ), ), diff --git a/lib/frontend/screens/chats/chat/video_note_controller.dart b/lib/frontend/screens/chats/chat/video_note_controller.dart index eec9646..2606004 100644 --- a/lib/frontend/screens/chats/chat/video_note_controller.dart +++ b/lib/frontend/screens/chats/chat/video_note_controller.dart @@ -5,7 +5,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:flutter/services.dart' show PlatformException, rootBundle; import 'package:lottie/lottie.dart' show AssetLottie; import 'package:path_provider/path_provider.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -101,11 +101,10 @@ class VideoNoteController { return; } if (_rec.textureId != null) return; - if (!await _rec.requestPermission()) { + final access = await _rec.requestAccess(); + if (!access.granted) { _videoNoteMode.value = false; - if (isMounted()) { - showCustomNotification(contextOf(), 'Нет доступа к камере'); - } + _notify(_accessMessage(access)); return; } try { @@ -115,9 +114,8 @@ class VideoNoteController { fps: AppVideoNoteFps.current.value, ); if (!ok) { - if (isMounted()) { - showCustomNotification(contextOf(), 'Камера недоступна'); - } + _videoNoteMode.value = false; + _notify('Камера недоступна'); return; } if (!isMounted() || !_videoNoteMode.value) { @@ -128,10 +126,34 @@ class VideoNoteController { _camReady.value = true; } catch (e) { logger.w('initNoteCamera: $e'); - if (isMounted()) showCustomNotification(contextOf(), 'Камера недоступна'); + await _disposeCamera(); + _videoNoteMode.value = false; + _notify(_failureMessage(e, 'Камера недоступна')); } } + void _notify(String message) { + if (isMounted()) showCustomNotification(contextOf(), message); + } + + String _accessMessage(VideoNoteAccess access) { + if (!access.camera && !access.microphone) { + return 'Для кружков нужен доступ к камере и микрофону'; + } + return access.camera ? 'Нет доступа к микрофону' : 'Нет доступа к камере'; + } + + String _failureMessage(Object error, String fallback) { + if (error is! PlatformException) return fallback; + return switch (error.code) { + 'NO_CAMERA_PERMISSION' => 'Нет доступа к камере', + 'NO_MIC_PERMISSION' => 'Нет доступа к микрофону', + 'NO_CAMERA' => 'Камера недоступна', + 'NOT_READY' => 'Камера ещё не готова', + _ => fallback, + }; + } + Future _disposeCamera() async { _camReady.value = false; _textureId.value = null; @@ -143,14 +165,10 @@ class VideoNoteController { _stopRequested = false; if (!_stub && _rec.textureId == null) { await _initCamera(); - return; + if (_rec.textureId == null) return; } try { - final ok = _stub || await _rec.start(); - if (!ok) { - _isRecording.value = false; - return; - } + if (!_stub) await _rec.start(); if (!isMounted()) { if (!_stub) await _rec.stop(); return; @@ -178,6 +196,7 @@ class VideoNoteController { } catch (e) { logger.w('startNoteRecording: $e'); _isRecording.value = false; + _notify(_failureMessage(e, 'Не удалось начать запись кружка')); } } @@ -260,20 +279,27 @@ class VideoNoteController { unawaited(_rec.setTorch(false)); } - final path = _stub ? await _stubClip() : await _rec.stop(); - final shouldCancel = cancel || _cancelled || elapsed < VoiceRecordController.minMs; + + String? path; + try { + path = _stub ? await _stubClip() : await _rec.stop(); + } catch (e) { + logger.w('stopNoteRecording: $e'); + } + if (shouldCancel || path == null) { if (path != null) { try { await File(path).delete(); } catch (_) {} + } else if (!shouldCancel) { + _notify('Не удалось сохранить кружок'); } return; } - // Файл уже квадратный (нативная запись) — шлём как есть. await onRecorded(File(path), elapsed); } diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index ab5e264..aa87935 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -22,6 +22,7 @@ import '../../../core/storage/app_database.dart'; import '../../../core/storage/chat_members_store.dart'; import '../../../core/utils/format.dart'; import '../../../core/utils/logger.dart'; +import '../../../core/utils/route_settle.dart'; import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../models/chat_info.dart'; @@ -170,6 +171,9 @@ class _ChatInfoScreenState extends State bool _avatarHistoryBusy = false; bool _avatarHistoryLoaded = false; + late final RouteSettle _routeSettle = RouteSettle(isMounted: () => mounted); + bool _rebuildQueued = false; + double _headerDelta = 0; bool _expandArmed = false; bool _headerDragging = false; @@ -185,19 +189,42 @@ class _ChatInfoScreenState extends State _load(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _routeSettle.bind(context); + } + int? get _memberCount => ChatMembersStore.instance.count(widget.chatId); - void _onMemberCountChanged() { - if (mounted) setState(() {}); - } + void _onMemberCountChanged() => _loadedRebuild(); void _onStoriesChanged() { if (!mounted) return; - setState(_refreshUnreadStories); + _loadedUpdate(_refreshUnreadStories); + } + + void _loadedRebuild() { + if (_routeSettle.settled) { + if (mounted) setState(() {}); + return; + } + if (_rebuildQueued) return; + _rebuildQueued = true; + _routeSettle.run(() { + _rebuildQueued = false; + if (mounted) setState(() {}); + }); + } + + void _loadedUpdate(VoidCallback mutation) { + mutation(); + _loadedRebuild(); } @override void dispose() { + _routeSettle.dispose(); storiesModule.storiesChanged.removeListener(_onStoriesChanged); ChatMembersStore.instance .listenable(widget.chatId) @@ -323,7 +350,7 @@ class _ChatInfoScreenState extends State unawaited(_loadAvatarHistory(_otherId!)); } } else if (info == null) { - setState(() => _isLoading = false); + _loadedUpdate(() => _isLoading = false); return; } else if (widget.chatType == 'CHAT') { _contactIds = (await AppDatabase.loadContactIds(_myId)).toSet(); @@ -332,7 +359,7 @@ class _ChatInfoScreenState extends State } if (mounted) { - setState(() { + _loadedUpdate(() { _isLoading = false; if (_selectedTab.isEmpty && _tabs.isNotEmpty) { _selectedTab = _initialTabLabel() ?? _tabs.first; @@ -344,7 +371,7 @@ class _ChatInfoScreenState extends State Future _loadBlockedState(int peerId) async { final blocked = await ContactsModule.isBlocked(api, peerId); if (!mounted || blocked == _blocked) return; - setState(() => _blocked = blocked); + _loadedUpdate(() => _blocked = blocked); } String? _initialTabLabel() { @@ -438,7 +465,7 @@ class _ChatInfoScreenState extends State Future _fetchMembersPage({bool initial = false}) async { if (_membersLoading || _membersEnd) return; _membersLoading = true; - if (!initial && mounted) setState(() {}); + if (!initial) _loadedRebuild(); final page = await chats.getChatMembers( api, @@ -449,7 +476,7 @@ class _ChatInfoScreenState extends State if (!mounted) return; if (page == null) { - if (!initial) setState(() {}); + if (!initial) _loadedRebuild(); return; } @@ -479,7 +506,7 @@ class _ChatInfoScreenState extends State } _memberMarker = page.marker; - if (!initial) setState(() {}); + if (!initial) _loadedRebuild(); } bool _revealMoreMembers() { @@ -555,9 +582,9 @@ class _ChatInfoScreenState extends State _membersLoading = false; _memberRenderLimit = _memberRenderChunk; _rebuildMembers(); - if (mounted) setState(() {}); + _loadedRebuild(); await _fetchMembersPage(initial: true); - if (mounted) setState(() {}); + _loadedRebuild(); } @override diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 0f46899..43f1f91 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -58,6 +58,7 @@ import '../../../core/cache/message_session_cache.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/utils/emoji_keyword_index.dart'; import '../../../core/utils/logger.dart'; +import '../../../core/utils/route_settle.dart'; import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_colors.dart'; import '../../../core/config/app_message_actions_style.dart'; @@ -156,12 +157,21 @@ class _FrostedPanel extends StatelessWidget { @override Widget build(BuildContext context) { - return GlassSurface( - frostTint: tint, - frostSigma: sigma, - border: border, - backdropKey: backdropKey, - child: child, + return Stack( + fit: StackFit.passthrough, + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: GlassSurface( + frostTint: tint, + frostSigma: sigma, + border: border, + backdropKey: backdropKey, + child: const SizedBox.expand(), + ), + ), + child, + ], ); } } @@ -522,6 +532,8 @@ class _ChatScreenState extends State Timer? _goToMessageSettleTimer; static const double _jumpCacheExtentPx = 800.0; + late final RouteSettle _routeSettle = RouteSettle(isMounted: () => mounted); + late final ChatSearchController _search; late final AnimationController _searchAnim; final FocusNode _searchFocusNode = FocusNode(); @@ -606,12 +618,12 @@ class _ChatScreenState extends State if (chrome == ChatChromeStyle.liquidGlass) { return ChatChromeStyle.transparent; } - if (_wallpaper != null && chrome == ChatChromeStyle.none) { - return ChatChromeStyle.blur; - } return chrome; } + bool get _chromeVignette => + _effectiveChrome == ChatChromeStyle.none && _wallpaper == null; + final ValueNotifier _composerHeight = ValueNotifier(96); final ValueNotifier _pinnedBannerHeight = ValueNotifier(0); @@ -969,29 +981,11 @@ class _ChatScreenState extends State void _onFirstFrameRendered(Duration _) { if (!mounted) return; if (widget.embedded) { - _kickoffHistory(); - return; + _routeSettle.settleNow(); + } else { + _routeSettle.bind(context); } - final anim = ModalRoute.of(context)?.animation; - if (anim == null || anim.status == AnimationStatus.completed) { - _kickoffHistory(); - return; - } - Timer? safety; - void onStatus(AnimationStatus status) { - if (status != AnimationStatus.completed) return; - anim.removeStatusListener(onStatus); - safety?.cancel(); - if (!mounted) return; - _kickoffHistory(); - } - - anim.addStatusListener(onStatus); - safety = Timer(const Duration(milliseconds: 400), () { - anim.removeStatusListener(onStatus); - if (!mounted) return; - _kickoffHistory(); - }); + _routeSettle.run(_kickoffHistory); } void _kickoffHistory() { @@ -2192,6 +2186,7 @@ class _ChatScreenState extends State _highlightMessageId.dispose(); _goToMessageSettleTimer?.cancel(); _jumpCacheExtent.dispose(); + _routeSettle.dispose(); _messageKeys.clear(); super.dispose(); } @@ -3113,7 +3108,7 @@ class _ChatScreenState extends State backdropKey: _barBackdrop, child: const SizedBox.expand(), ) - : (chrome == ChatChromeStyle.none && !glossy) + : (_chromeVignette && !glossy) ? IgnorePointer( child: DecoratedBox( decoration: BoxDecoration( @@ -5463,7 +5458,7 @@ class _ChatScreenState extends State Widget _buildUnderlapBody() { final cs = Theme.of(context).colorScheme; - final vignette = _effectiveChrome == ChatChromeStyle.none; + final vignette = _chromeVignette; final bannerTop = _pinnedBannerTop(); return Stack( fit: StackFit.expand, diff --git a/lib/frontend/widgets/chat_wallpaper_sheet.dart b/lib/frontend/widgets/chat_wallpaper_sheet.dart index e51ba8e..e04471e 100644 --- a/lib/frontend/widgets/chat_wallpaper_sheet.dart +++ b/lib/frontend/widgets/chat_wallpaper_sheet.dart @@ -1,9 +1,12 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/config/chat_wallpaper_themes.dart'; import 'package:komet/core/config/app_colors.dart'; import 'package:komet/core/storage/chat_wallpaper_store.dart'; +import 'chat_wallpaper_view.dart'; import '../../core/config/app_fonts.dart'; enum WallpaperPickType { none, theme, gallery } @@ -44,20 +47,21 @@ class ChatWallpaperGalleryScreen extends StatefulWidget { class _ChatWallpaperGalleryScreenState extends State { ChatWallpaperTheme? _selected; - bool _isImage = false; + bool _keepsImage = false; @override void initState() { super.initState(); final current = widget.current; - _isImage = current?.isImage ?? false; + _keepsImage = current?.isImage ?? false; _selected = current == null || current.isImage ? null : chatWallpaperThemeById(current.themeId); } bool get _changed { - if (_isImage) return _selected != null; + if (_keepsImage) return false; + if (widget.current?.isImage == true) return true; return _selected?.id != chatWallpaperThemeById(widget.current?.themeId)?.id; } @@ -101,6 +105,7 @@ class _ChatWallpaperGalleryScreenState Widget _preview(ColorScheme cs) { final theme = _selected; + final image = _keepsImage ? widget.current : null; return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), child: ClipRRect( @@ -110,6 +115,8 @@ class _ChatWallpaperGalleryScreenState children: [ if (theme != null) theme.buildBackground() + else if (image != null) + ChatWallpaperView(wallpaper: image) else ColoredBox(color: cs.surfaceContainerHighest), const IgnorePointer(child: _PreviewScrim()), @@ -120,7 +127,21 @@ class _ChatWallpaperGalleryScreenState ); } + Widget? _currentImageTile() { + final current = widget.current; + if (current == null || !current.isImage) return null; + return _CurrentImageTile( + wallpaper: current, + selected: _keepsImage, + onTap: () => setState(() { + _keepsImage = true; + _selected = null; + }), + ); + } + Widget _panel(ColorScheme cs) { + final currentImage = _currentImageTile(); return Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, @@ -138,11 +159,12 @@ class _ChatWallpaperGalleryScreenState scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), children: [ + ?currentImage, _NoneTile( - selected: _selected == null && !_isImage, + selected: _selected == null && !_keepsImage, onTap: () => setState(() { _selected = null; - _isImage = false; + _keepsImage = false; }), ), for (final theme in kChatWallpaperThemes) @@ -151,7 +173,7 @@ class _ChatWallpaperGalleryScreenState selected: _selected?.id == theme.id, onTap: () => setState(() { _selected = theme; - _isImage = false; + _keepsImage = false; }), ), ], @@ -375,6 +397,32 @@ class _NoneTile extends StatelessWidget { } } +class _CurrentImageTile extends StatelessWidget { + final ChatWallpaper wallpaper; + final bool selected; + final VoidCallback onTap; + + const _CurrentImageTile({ + required this.wallpaper, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final path = wallpaper.imagePath; + return _TileFrame( + selected: selected, + onTap: onTap, + label: 'Ваше фото', + child: path == null + ? ColoredBox(color: cs.surfaceContainerHighest) + : Image.file(File(path), fit: BoxFit.cover), + ); + } +} + class _ThemeTile extends StatelessWidget { final ChatWallpaperTheme theme; final bool selected; diff --git a/test/route_settle_test.dart b/test/route_settle_test.dart new file mode 100644 index 0000000..105f6af --- /dev/null +++ b/test/route_settle_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/utils/route_settle.dart'; + +class _GatedPage extends StatefulWidget { + const _GatedPage({required this.onSettled}); + + final VoidCallback onSettled; + + @override + State<_GatedPage> createState() => _GatedPageState(); +} + +class _GatedPageState extends State<_GatedPage> { + late final RouteSettle _settle = RouteSettle(isMounted: () => mounted); + bool _queued = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _settle.bind(context); + if (!_queued) { + _queued = true; + _settle.run(widget.onSettled); + } + } + + @override + void dispose() { + _settle.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +void main() { + testWidgets('queued work waits for the push transition to finish', ( + tester, + ) async { + final navigator = GlobalKey(); + var ran = 0; + + await tester.pumpWidget( + MaterialApp(navigatorKey: navigator, home: const SizedBox.shrink()), + ); + navigator.currentState!.push( + MaterialPageRoute( + builder: (_) => _GatedPage(onSettled: () => ran++), + ), + ); + + await tester.pump(); + expect(ran, 0); + + await tester.pump(const Duration(milliseconds: 100)); + expect(ran, 0); + + await tester.pumpAndSettle(); + expect(ran, 1); + }); + + testWidgets('a route that is already in place does not hold work back', ( + tester, + ) async { + var ran = 0; + await tester.pumpWidget( + MaterialApp(home: _GatedPage(onSettled: () => ran++)), + ); + expect(ran, 1); + }); + + testWidgets('work runs immediately once the gate is open', (tester) async { + final settle = RouteSettle(isMounted: () => true); + settle.settleNow(); + + var ran = 0; + settle.run(() => ran++); + expect(ran, 1); + + settle.dispose(); + }); + + testWidgets('queued work is dropped when the owner is gone', (tester) async { + var alive = true; + final settle = RouteSettle(isMounted: () => alive); + + var ran = 0; + settle.run(() => ran++); + expect(ran, 0); + + alive = false; + settle.settleNow(); + expect(ran, 0); + + settle.dispose(); + }); +}