fix: хм

This commit is contained in:
Jganenokk
2026-08-20 21:46:19 +07:00
parent 3cfe3a3965
commit 2e8abe7e47
15 changed files with 540 additions and 180 deletions
@@ -85,7 +85,7 @@ class MainActivity : FlutterActivity() {
const val NFC_PHASE_MIN_MS = 350L const val NFC_PHASE_MIN_MS = 350L
const val NFC_PHASE_JITTER_MS = 400 const val NFC_PHASE_JITTER_MS = 400
const val BLE_PERMS_REQUEST = 7711 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 val NFC_READER_FLAGS = NfcAdapter.FLAG_READER_NFC_A or
NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
@@ -243,7 +243,7 @@ class MainActivity : FlutterActivity() {
"ru.komet.app/video_note", "ru.komet.app/video_note",
).setMethodCallHandler { call, result -> ).setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
"permission" -> requestCameraPermission(result) "permission" -> requestNotePermissions(result)
"init" -> { "init" -> {
val front = call.argument<Boolean>("front") ?: true val front = call.argument<Boolean>("front") ?: true
val size = call.argument<Int>("size") ?: 480 val size = call.argument<Int>("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) { private fun isGranted(permission: String): Boolean =
val granted = ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(this, permission) ==
this, PackageManager.PERMISSION_GRANTED
Manifest.permission.CAMERA,
) == PackageManager.PERMISSION_GRANTED private fun notePermissionState(): Map<String, Boolean> = mapOf(
if (granted) { "camera" to isGranted(Manifest.permission.CAMERA),
result.success(true); return "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) { notePermResult = result
result.success(false); return
}
cameraPermResult = result
ActivityCompat.requestPermissions( ActivityCompat.requestPermissions(
this, this,
arrayOf(Manifest.permission.CAMERA), arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO),
CAMERA_PERM_REQUEST, NOTE_PERMS_REQUEST,
) )
} }
@@ -735,13 +738,10 @@ class MainActivity : FlutterActivity() {
grantResults: IntArray, grantResults: IntArray,
) { ) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults) super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == CAMERA_PERM_REQUEST) { if (requestCode == NOTE_PERMS_REQUEST) {
val pending = cameraPermResult val pending = notePermResult
cameraPermResult = null notePermResult = null
pending?.success( pending?.success(notePermissionState())
grantResults.isNotEmpty() &&
grantResults.all { it == PackageManager.PERMISSION_GRANTED },
)
return return
} }
if (requestCode != BLE_PERMS_REQUEST) return if (requestCode != BLE_PERMS_REQUEST) return
@@ -172,7 +172,11 @@ class VideoNoteRecorder(
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED != 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 return
} }
try { try {
@@ -453,10 +457,18 @@ class VideoNoteRecorder(
openCamera(result, entry.id()) openCamera(result, entry.id())
} }
private fun hasMicPermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
fun start(result: MethodChannel.Result) { fun start(result: MethodChannel.Result) {
if (cameraDevice == null || !glReady) { if (cameraDevice == null || !glReady) {
result.error("NOT_READY", "camera not initialized", null); return result.error("NOT_READY", "camera not initialized", null); return
} }
if (!hasMicPermission()) {
result.error("NO_MIC_PERMISSION", "microphone permission required", null)
return
}
try { try {
val path = File(context.cacheDir, "note_${System.nanoTime()}.mp4").absolutePath val path = File(context.cacheDir, "note_${System.nanoTime()}.mp4").absolutePath
outputPath = path outputPath = path
+12 -6
View File
@@ -54,12 +54,10 @@ final class KometVideoNote: NSObject {
static func requestPermission(_ result: @escaping FlutterResult) { static func requestPermission(_ result: @escaping FlutterResult) {
AVCaptureDevice.requestAccess(for: .video) { video in AVCaptureDevice.requestAccess(for: .video) { video in
guard video else {
DispatchQueue.main.async { result(NSNumber(value: false)) }
return
}
AVCaptureDevice.requestAccess(for: .audio) { audio in 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 { queue.async {
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized else { 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 return
} }
do { do {
@@ -126,6 +128,10 @@ final class KometVideoNote: NSObject {
DispatchQueue.main.async { result(nil) } DispatchQueue.main.async { result(nil) }
return return
} }
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
Self.fail(result, "NO_MIC_PERMISSION", "microphone permission required")
return
}
do { do {
try self.prepareWriter() try self.prepareWriter()
} catch { } catch {
+2 -2
View File
@@ -15,7 +15,7 @@ class AppChatChrome {
static final _setting = PersistedEnum<ChatChromeStyle>( static final _setting = PersistedEnum<ChatChromeStyle>(
prefKey: prefKey, prefKey: prefKey,
defaultValue: ChatChromeStyle.color, defaultValue: ChatChromeStyle.none,
encode: _encode, encode: _encode,
decode: _parse, decode: _parse,
); );
@@ -23,7 +23,7 @@ class AppChatChrome {
static ValueNotifier<ChatChromeStyle> get current => _setting.current; static ValueNotifier<ChatChromeStyle> get current => _setting.current;
static ChatChromeStyle _parse(String? value) => static ChatChromeStyle _parse(String? value) =>
enumFromName(ChatChromeStyle.values, value, ChatChromeStyle.color); enumFromName(ChatChromeStyle.values, value, ChatChromeStyle.none);
static String _encode(ChatChromeStyle value) => value.name; static String _encode(ChatChromeStyle value) => value.name;
+2 -2
View File
@@ -18,7 +18,7 @@ class AppComposerStyle {
static final _setting = PersistedEnum<ComposerStyle>( static final _setting = PersistedEnum<ComposerStyle>(
prefKey: prefKey, prefKey: prefKey,
defaultValue: ComposerStyle.auto, defaultValue: ComposerStyle.glossy,
encode: (value) => value.name, encode: (value) => value.name,
decode: _parse, decode: _parse,
); );
@@ -30,5 +30,5 @@ class AppComposerStyle {
static Future<void> save(ComposerStyle value) => _setting.save(value); static Future<void> save(ComposerStyle value) => _setting.save(value);
static ComposerStyle _parse(String? val) => static ComposerStyle _parse(String? val) =>
enumFromName(ComposerStyle.values, val, ComposerStyle.auto); enumFromName(ComposerStyle.values, val, ComposerStyle.glossy);
} }
+2 -2
View File
@@ -29,7 +29,7 @@ class AppNavPillStyle {
static final _setting = PersistedEnum<NavPillStyle>( static final _setting = PersistedEnum<NavPillStyle>(
prefKey: prefKey, prefKey: prefKey,
defaultValue: NavPillStyle.auto, defaultValue: NavPillStyle.glossy,
encode: (value) => value.name, encode: (value) => value.name,
decode: _parse, decode: _parse,
); );
@@ -41,5 +41,5 @@ class AppNavPillStyle {
static Future<void> save(NavPillStyle value) => _setting.save(value); static Future<void> save(NavPillStyle value) => _setting.save(value);
static NavPillStyle _parse(String? val) => static NavPillStyle _parse(String? val) =>
enumFromName(NavPillStyle.values, val, NavPillStyle.auto); enumFromName(NavPillStyle.values, val, NavPillStyle.glossy);
} }
+2 -2
View File
@@ -13,7 +13,7 @@ class AppVisualStyle {
static final _setting = PersistedEnum<VisualStyle>( static final _setting = PersistedEnum<VisualStyle>(
prefKey: prefKey, prefKey: prefKey,
defaultValue: VisualStyle.materialYou, defaultValue: VisualStyle.glossy,
encode: _encode, encode: _encode,
decode: _parse, decode: _parse,
); );
@@ -27,5 +27,5 @@ class AppVisualStyle {
static String _encode(VisualStyle value) => value.name; static String _encode(VisualStyle value) => value.name;
static VisualStyle _parse(String? val) => static VisualStyle _parse(String? val) =>
enumFromName(VisualStyle.values, val, VisualStyle.materialYou); enumFromName(VisualStyle.values, val, VisualStyle.glossy);
} }
+36 -38
View File
@@ -4,12 +4,17 @@ import 'package:flutter/services.dart';
import '../utils/logger.dart'; import '../utils/logger.dart';
/// Нативная запись видео-кружка: пишет квадрат сразу при съёмке — как class VideoNoteAccess {
/// официальный клиент (по умолчанию 480×480@30, размер и fps настраиваются const VideoNoteAccess({required this.camera, required this.microphone});
/// в дев-меню). На Android — Camera2 + MediaRecorder, на iOS —
/// AVCaptureSession + AVAssetWriter. Превью отдаётся через Flutter static const denied = VideoNoteAccess(camera: false, microphone: false);
/// [Texture] по [textureId]. Перекодирование не используется (серверный
/// валидатор принимает только нативно записанный MP4). final bool camera;
final bool microphone;
bool get granted => camera && microphone;
}
class NativeVideoNoteRecorder { class NativeVideoNoteRecorder {
static const _channel = MethodChannel('ru.komet.app/video_note'); static const _channel = MethodChannel('ru.komet.app/video_note');
@@ -17,31 +22,30 @@ class NativeVideoNoteRecorder {
bool hasFlash = false; bool hasFlash = false;
bool get isAvailable => Platform.isAndroid || Platform.isIOS; bool get isAvailable => Platform.isAndroid || Platform.isIOS;
Future<bool> requestPermission() async { Future<VideoNoteAccess> requestAccess() async {
if (!isAvailable) return false; if (!isAvailable) return VideoNoteAccess.denied;
try { try {
return await _channel.invokeMethod<bool>('permission') ?? false; final res = await _channel.invokeMapMethod<String, dynamic>('permission');
return VideoNoteAccess(
camera: res?['camera'] as bool? ?? false,
microphone: res?['microphone'] as bool? ?? false,
);
} catch (e) { } catch (e) {
logger.w('NativeVideoNoteRecorder.requestPermission: $e'); logger.w('NativeVideoNoteRecorder.requestAccess: $e');
return false; return VideoNoteAccess.denied;
} }
} }
Future<bool> init({bool front = true, int size = 480, int fps = 30}) async { Future<bool> init({bool front = true, int size = 480, int fps = 30}) async {
if (!isAvailable) return false; if (!isAvailable) return false;
try { final res = await _channel.invokeMapMethod<String, dynamic>('init', {
final res = await _channel.invokeMapMethod<String, dynamic>('init', { 'front': front,
'front': front, 'size': size,
'size': size, 'fps': fps,
'fps': fps, });
}); textureId = res?['textureId'] as int?;
textureId = res?['textureId'] as int?; hasFlash = res?['hasFlash'] as bool? ?? false;
hasFlash = res?['hasFlash'] as bool? ?? false; return textureId != null;
return textureId != null;
} catch (e) {
logger.w('NativeVideoNoteRecorder.init: $e');
return false;
}
} }
Future<bool> switchCamera() async { Future<bool> switchCamera() async {
@@ -65,25 +69,19 @@ class NativeVideoNoteRecorder {
} }
} }
Future<bool> start() async { Future<void> start() async {
if (!isAvailable) return false; if (!isAvailable) {
try { throw PlatformException(
await _channel.invokeMethod('start'); code: 'UNSUPPORTED',
return true; message: 'video notes are not supported on this platform',
} catch (e) { );
logger.w('NativeVideoNoteRecorder.start: $e');
return false;
} }
await _channel.invokeMethod('start');
} }
Future<String?> stop() async { Future<String?> stop() async {
if (!isAvailable) return null; if (!isAvailable) return null;
try { return _channel.invokeMethod<String>('stop');
return await _channel.invokeMethod<String>('stop');
} catch (e) {
logger.w('NativeVideoNoteRecorder.stop: $e');
return null;
}
} }
Future<void> dispose() async { Future<void> dispose() async {
+81
View File
@@ -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<VoidCallback> _queued = <VoidCallback>[];
Animation<double>? _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<VoidCallback>.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;
}
}
+103 -35
View File
@@ -1,4 +1,5 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
@@ -10,15 +11,34 @@ class FpsOverlayLayer extends StatefulWidget {
State<FpsOverlayLayer> createState() => _FpsOverlayLayerState(); State<FpsOverlayLayer> 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<FpsOverlayLayer> { class _FpsOverlayLayerState extends State<FpsOverlayLayer> {
static const int _maxSamples = 90; static const int _fpsWindowMicros = 1000000;
static const int _minUiRefreshMs = 160; static const int _jankWindowMicros = 3000000;
static const double _initialWidthGuess = 96; static const int _minUiRefreshMs = 100;
static const double _initialWidthGuess = 132;
static const double _initialHeightGuess = 36; static const double _initialHeightGuess = 36;
final List<int> _frameMicros = <int>[]; final List<_FrameSample> _recent = <_FrameSample>[];
final List<_FrameSample> _janky = <_FrameSample>[];
final GlobalKey _badgeKey = GlobalKey(); final GlobalKey _badgeKey = GlobalKey();
double _refreshRate = 60;
double _budgetMicros = 1000000 / 60;
double _fps = 0; double _fps = 0;
int _worstMicros = 0;
int _jankCount = 0;
bool _worstRasterBound = false;
DateTime _lastUiUpdate = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastUiUpdate = DateTime.fromMillisecondsSinceEpoch(0);
double? _left; double? _left;
double? _top; double? _top;
@@ -38,6 +58,8 @@ class _FpsOverlayLayerState extends State<FpsOverlayLayer> {
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
_refreshRate = _resolveRefreshRate();
_budgetMicros = 1000000 / _refreshRate;
if (_left != null && _top != null) { if (_left != null && _top != null) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted) {
@@ -47,6 +69,11 @@ class _FpsOverlayLayerState extends State<FpsOverlayLayer> {
} }
} }
double _resolveRefreshRate() {
final hz = View.of(context).display.refreshRate;
return hz.isFinite && hz >= 30 ? hz : 60;
}
void _ensureInitialPosition() { void _ensureInitialPosition() {
if (_left != null) return; if (_left != null) return;
final mq = MediaQuery.of(context); final mq = MediaQuery.of(context);
@@ -63,12 +90,8 @@ class _FpsOverlayLayerState extends State<FpsOverlayLayer> {
final bottomMax = screen.height - mq.padding.bottom; final bottomMax = screen.height - mq.padding.bottom;
final box = _badgeKey.currentContext?.findRenderObject() as RenderBox?; final box = _badgeKey.currentContext?.findRenderObject() as RenderBox?;
final bw = box?.hasSize == true final bw = box?.hasSize == true ? box!.size.width : _initialWidthGuess;
? box!.size.width final bh = box?.hasSize == true ? box!.size.height : _initialHeightGuess;
: _initialWidthGuess;
final bh = box?.hasSize == true
? box!.size.height
: _initialHeightGuess;
_left = _left!.clamp(0.0, math.max(0.0, screen.width - bw)); _left = _left!.clamp(0.0, math.max(0.0, screen.width - bw));
_top = _top!.clamp(topMin, math.max(topMin, bottomMax - bh)); _top = _top!.clamp(topMin, math.max(topMin, bottomMax - bh));
@@ -76,23 +99,54 @@ class _FpsOverlayLayerState extends State<FpsOverlayLayer> {
void _onTimings(List<FrameTiming> timings) { void _onTimings(List<FrameTiming> timings) {
for (final t in timings) { for (final t in timings) {
final us = t.totalSpan.inMicroseconds; final build = t.buildDuration.inMicroseconds;
if (us <= 0) continue; final raster = t.rasterDuration.inMicroseconds;
_frameMicros.add(us); final sample = _FrameSample(
while (_frameMicros.length > _maxSamples) { endMicros: t.timestampInMicroseconds(ui.FramePhase.rasterFinish),
_frameMicros.removeAt(0); 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(); final now = DateTime.now();
if (now.difference(_lastUiUpdate).inMilliseconds < _minUiRefreshMs) { if (now.difference(_lastUiUpdate).inMilliseconds < _minUiRefreshMs) return;
return;
}
_lastUiUpdate = now; _lastUiUpdate = now;
if (!mounted || _frameMicros.isEmpty) return; if (!mounted) return;
final sum = _frameMicros.fold<int>(0, (a, b) => a + b); setState(() {
final avg = sum / _frameMicros.length; _fps = fps;
final fps = avg > 0 ? (1000000.0 / avg).clamp(0.0, 999.0) : 0.0; _worstMicros = worst;
setState(() => _fps = fps); _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 @override
@@ -100,6 +154,7 @@ class _FpsOverlayLayerState extends State<FpsOverlayLayer> {
_ensureInitialPosition(); _ensureInitialPosition();
_clampPositionToScreen(); _clampPositionToScreen();
final tint = _tint;
return Positioned( return Positioned(
left: _left, left: _left,
top: _top, top: _top,
@@ -123,18 +178,31 @@ class _FpsOverlayLayerState extends State<FpsOverlayLayer> {
color: const Color(0xCC000000), color: const Color(0xCC000000),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: Column(
'${_fps.round()} FPS', mainAxisSize: MainAxisSize.min,
style: TextStyle( crossAxisAlignment: CrossAxisAlignment.end,
color: _fps >= 55 children: [
? const Color(0xFFB8F5C6) Text(
: _fps >= 30 '${_fps.round()} FPS · ${_refreshRate.round()} Hz',
? const Color(0xFFFFE082) style: TextStyle(
: const Color(0xFFFFAB91), color: tint,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontFeatures: const [FontFeature.tabularFigures()], 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()],
),
),
],
), ),
), ),
), ),
@@ -5,7 +5,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 PlatformException, rootBundle;
import 'package:lottie/lottie.dart' show AssetLottie; 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';
@@ -101,11 +101,10 @@ class VideoNoteController {
return; return;
} }
if (_rec.textureId != null) return; if (_rec.textureId != null) return;
if (!await _rec.requestPermission()) { final access = await _rec.requestAccess();
if (!access.granted) {
_videoNoteMode.value = false; _videoNoteMode.value = false;
if (isMounted()) { _notify(_accessMessage(access));
showCustomNotification(contextOf(), 'Нет доступа к камере');
}
return; return;
} }
try { try {
@@ -115,9 +114,8 @@ class VideoNoteController {
fps: AppVideoNoteFps.current.value, fps: AppVideoNoteFps.current.value,
); );
if (!ok) { if (!ok) {
if (isMounted()) { _videoNoteMode.value = false;
showCustomNotification(contextOf(), 'Камера недоступна'); _notify('Камера недоступна');
}
return; return;
} }
if (!isMounted() || !_videoNoteMode.value) { if (!isMounted() || !_videoNoteMode.value) {
@@ -128,10 +126,34 @@ class VideoNoteController {
_camReady.value = true; _camReady.value = true;
} catch (e) { } catch (e) {
logger.w('initNoteCamera: $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<void> _disposeCamera() async { Future<void> _disposeCamera() async {
_camReady.value = false; _camReady.value = false;
_textureId.value = null; _textureId.value = null;
@@ -143,14 +165,10 @@ class VideoNoteController {
_stopRequested = false; _stopRequested = false;
if (!_stub && _rec.textureId == null) { if (!_stub && _rec.textureId == null) {
await _initCamera(); await _initCamera();
return; if (_rec.textureId == null) return;
} }
try { try {
final ok = _stub || await _rec.start(); if (!_stub) await _rec.start();
if (!ok) {
_isRecording.value = false;
return;
}
if (!isMounted()) { if (!isMounted()) {
if (!_stub) await _rec.stop(); if (!_stub) await _rec.stop();
return; return;
@@ -178,6 +196,7 @@ class VideoNoteController {
} catch (e) { } catch (e) {
logger.w('startNoteRecording: $e'); logger.w('startNoteRecording: $e');
_isRecording.value = false; _isRecording.value = false;
_notify(_failureMessage(e, 'Не удалось начать запись кружка'));
} }
} }
@@ -260,20 +279,27 @@ class VideoNoteController {
unawaited(_rec.setTorch(false)); unawaited(_rec.setTorch(false));
} }
final path = _stub ? await _stubClip() : await _rec.stop();
final shouldCancel = final shouldCancel =
cancel || _cancelled || elapsed < VoiceRecordController.minMs; 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 (shouldCancel || path == null) {
if (path != null) { if (path != null) {
try { try {
await File(path).delete(); await File(path).delete();
} catch (_) {} } catch (_) {}
} else if (!shouldCancel) {
_notify('Не удалось сохранить кружок');
} }
return; return;
} }
// Файл уже квадратный (нативная запись) — шлём как есть.
await onRecorded(File(path), elapsed); await onRecorded(File(path), elapsed);
} }
@@ -22,6 +22,7 @@ import '../../../core/storage/app_database.dart';
import '../../../core/storage/chat_members_store.dart'; import '../../../core/storage/chat_members_store.dart';
import '../../../core/utils/format.dart'; import '../../../core/utils/format.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import '../../../core/utils/route_settle.dart';
import '../../../core/utils/haptics.dart'; import '../../../core/utils/haptics.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../../models/chat_info.dart'; import '../../../models/chat_info.dart';
@@ -170,6 +171,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
bool _avatarHistoryBusy = false; bool _avatarHistoryBusy = false;
bool _avatarHistoryLoaded = false; bool _avatarHistoryLoaded = false;
late final RouteSettle _routeSettle = RouteSettle(isMounted: () => mounted);
bool _rebuildQueued = false;
double _headerDelta = 0; double _headerDelta = 0;
bool _expandArmed = false; bool _expandArmed = false;
bool _headerDragging = false; bool _headerDragging = false;
@@ -185,19 +189,42 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
_load(); _load();
} }
@override
void didChangeDependencies() {
super.didChangeDependencies();
_routeSettle.bind(context);
}
int? get _memberCount => ChatMembersStore.instance.count(widget.chatId); int? get _memberCount => ChatMembersStore.instance.count(widget.chatId);
void _onMemberCountChanged() { void _onMemberCountChanged() => _loadedRebuild();
if (mounted) setState(() {});
}
void _onStoriesChanged() { void _onStoriesChanged() {
if (!mounted) return; 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 @override
void dispose() { void dispose() {
_routeSettle.dispose();
storiesModule.storiesChanged.removeListener(_onStoriesChanged); storiesModule.storiesChanged.removeListener(_onStoriesChanged);
ChatMembersStore.instance ChatMembersStore.instance
.listenable(widget.chatId) .listenable(widget.chatId)
@@ -323,7 +350,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
unawaited(_loadAvatarHistory(_otherId!)); unawaited(_loadAvatarHistory(_otherId!));
} }
} else if (info == null) { } else if (info == null) {
setState(() => _isLoading = false); _loadedUpdate(() => _isLoading = false);
return; return;
} else if (widget.chatType == 'CHAT') { } else if (widget.chatType == 'CHAT') {
_contactIds = (await AppDatabase.loadContactIds(_myId)).toSet(); _contactIds = (await AppDatabase.loadContactIds(_myId)).toSet();
@@ -332,7 +359,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
} }
if (mounted) { if (mounted) {
setState(() { _loadedUpdate(() {
_isLoading = false; _isLoading = false;
if (_selectedTab.isEmpty && _tabs.isNotEmpty) { if (_selectedTab.isEmpty && _tabs.isNotEmpty) {
_selectedTab = _initialTabLabel() ?? _tabs.first; _selectedTab = _initialTabLabel() ?? _tabs.first;
@@ -344,7 +371,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
Future<void> _loadBlockedState(int peerId) async { Future<void> _loadBlockedState(int peerId) async {
final blocked = await ContactsModule.isBlocked(api, peerId); final blocked = await ContactsModule.isBlocked(api, peerId);
if (!mounted || blocked == _blocked) return; if (!mounted || blocked == _blocked) return;
setState(() => _blocked = blocked); _loadedUpdate(() => _blocked = blocked);
} }
String? _initialTabLabel() { String? _initialTabLabel() {
@@ -438,7 +465,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
Future<void> _fetchMembersPage({bool initial = false}) async { Future<void> _fetchMembersPage({bool initial = false}) async {
if (_membersLoading || _membersEnd) return; if (_membersLoading || _membersEnd) return;
_membersLoading = true; _membersLoading = true;
if (!initial && mounted) setState(() {}); if (!initial) _loadedRebuild();
final page = await chats.getChatMembers( final page = await chats.getChatMembers(
api, api,
@@ -449,7 +476,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
if (!mounted) return; if (!mounted) return;
if (page == null) { if (page == null) {
if (!initial) setState(() {}); if (!initial) _loadedRebuild();
return; return;
} }
@@ -479,7 +506,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
} }
_memberMarker = page.marker; _memberMarker = page.marker;
if (!initial) setState(() {}); if (!initial) _loadedRebuild();
} }
bool _revealMoreMembers() { bool _revealMoreMembers() {
@@ -555,9 +582,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
_membersLoading = false; _membersLoading = false;
_memberRenderLimit = _memberRenderChunk; _memberRenderLimit = _memberRenderChunk;
_rebuildMembers(); _rebuildMembers();
if (mounted) setState(() {}); _loadedRebuild();
await _fetchMembersPage(initial: true); await _fetchMembersPage(initial: true);
if (mounted) setState(() {}); _loadedRebuild();
} }
@override @override
+28 -33
View File
@@ -58,6 +58,7 @@ import '../../../core/cache/message_session_cache.dart';
import '../../../core/utils/haptics.dart'; import '../../../core/utils/haptics.dart';
import '../../../core/utils/emoji_keyword_index.dart'; import '../../../core/utils/emoji_keyword_index.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import '../../../core/utils/route_settle.dart';
import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_cache_extent.dart';
import '../../../core/config/app_colors.dart'; import '../../../core/config/app_colors.dart';
import '../../../core/config/app_message_actions_style.dart'; import '../../../core/config/app_message_actions_style.dart';
@@ -156,12 +157,21 @@ class _FrostedPanel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GlassSurface( return Stack(
frostTint: tint, fit: StackFit.passthrough,
frostSigma: sigma, clipBehavior: Clip.none,
border: border, children: [
backdropKey: backdropKey, Positioned.fill(
child: child, child: GlassSurface(
frostTint: tint,
frostSigma: sigma,
border: border,
backdropKey: backdropKey,
child: const SizedBox.expand(),
),
),
child,
],
); );
} }
} }
@@ -522,6 +532,8 @@ class _ChatScreenState extends State<ChatScreen>
Timer? _goToMessageSettleTimer; Timer? _goToMessageSettleTimer;
static const double _jumpCacheExtentPx = 800.0; static const double _jumpCacheExtentPx = 800.0;
late final RouteSettle _routeSettle = RouteSettle(isMounted: () => mounted);
late final ChatSearchController _search; late final ChatSearchController _search;
late final AnimationController _searchAnim; late final AnimationController _searchAnim;
final FocusNode _searchFocusNode = FocusNode(); final FocusNode _searchFocusNode = FocusNode();
@@ -606,12 +618,12 @@ class _ChatScreenState extends State<ChatScreen>
if (chrome == ChatChromeStyle.liquidGlass) { if (chrome == ChatChromeStyle.liquidGlass) {
return ChatChromeStyle.transparent; return ChatChromeStyle.transparent;
} }
if (_wallpaper != null && chrome == ChatChromeStyle.none) {
return ChatChromeStyle.blur;
}
return chrome; return chrome;
} }
bool get _chromeVignette =>
_effectiveChrome == ChatChromeStyle.none && _wallpaper == null;
final ValueNotifier<double> _composerHeight = ValueNotifier(96); final ValueNotifier<double> _composerHeight = ValueNotifier(96);
final ValueNotifier<double> _pinnedBannerHeight = ValueNotifier(0); final ValueNotifier<double> _pinnedBannerHeight = ValueNotifier(0);
@@ -969,29 +981,11 @@ class _ChatScreenState extends State<ChatScreen>
void _onFirstFrameRendered(Duration _) { void _onFirstFrameRendered(Duration _) {
if (!mounted) return; if (!mounted) return;
if (widget.embedded) { if (widget.embedded) {
_kickoffHistory(); _routeSettle.settleNow();
return; } else {
_routeSettle.bind(context);
} }
final anim = ModalRoute.of(context)?.animation; _routeSettle.run(_kickoffHistory);
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();
});
} }
void _kickoffHistory() { void _kickoffHistory() {
@@ -2192,6 +2186,7 @@ class _ChatScreenState extends State<ChatScreen>
_highlightMessageId.dispose(); _highlightMessageId.dispose();
_goToMessageSettleTimer?.cancel(); _goToMessageSettleTimer?.cancel();
_jumpCacheExtent.dispose(); _jumpCacheExtent.dispose();
_routeSettle.dispose();
_messageKeys.clear(); _messageKeys.clear();
super.dispose(); super.dispose();
} }
@@ -3113,7 +3108,7 @@ class _ChatScreenState extends State<ChatScreen>
backdropKey: _barBackdrop, backdropKey: _barBackdrop,
child: const SizedBox.expand(), child: const SizedBox.expand(),
) )
: (chrome == ChatChromeStyle.none && !glossy) : (_chromeVignette && !glossy)
? IgnorePointer( ? IgnorePointer(
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -5463,7 +5458,7 @@ class _ChatScreenState extends State<ChatScreen>
Widget _buildUnderlapBody() { Widget _buildUnderlapBody() {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final vignette = _effectiveChrome == ChatChromeStyle.none; final vignette = _chromeVignette;
final bannerTop = _pinnedBannerTop(); final bannerTop = _pinnedBannerTop();
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
+54 -6
View File
@@ -1,9 +1,12 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/core/config/chat_wallpaper_themes.dart'; import 'package:komet/core/config/chat_wallpaper_themes.dart';
import 'package:komet/core/config/app_colors.dart'; import 'package:komet/core/config/app_colors.dart';
import 'package:komet/core/storage/chat_wallpaper_store.dart'; import 'package:komet/core/storage/chat_wallpaper_store.dart';
import 'chat_wallpaper_view.dart';
import '../../core/config/app_fonts.dart'; import '../../core/config/app_fonts.dart';
enum WallpaperPickType { none, theme, gallery } enum WallpaperPickType { none, theme, gallery }
@@ -44,20 +47,21 @@ class ChatWallpaperGalleryScreen extends StatefulWidget {
class _ChatWallpaperGalleryScreenState class _ChatWallpaperGalleryScreenState
extends State<ChatWallpaperGalleryScreen> { extends State<ChatWallpaperGalleryScreen> {
ChatWallpaperTheme? _selected; ChatWallpaperTheme? _selected;
bool _isImage = false; bool _keepsImage = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final current = widget.current; final current = widget.current;
_isImage = current?.isImage ?? false; _keepsImage = current?.isImage ?? false;
_selected = current == null || current.isImage _selected = current == null || current.isImage
? null ? null
: chatWallpaperThemeById(current.themeId); : chatWallpaperThemeById(current.themeId);
} }
bool get _changed { 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; return _selected?.id != chatWallpaperThemeById(widget.current?.themeId)?.id;
} }
@@ -101,6 +105,7 @@ class _ChatWallpaperGalleryScreenState
Widget _preview(ColorScheme cs) { Widget _preview(ColorScheme cs) {
final theme = _selected; final theme = _selected;
final image = _keepsImage ? widget.current : null;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: ClipRRect( child: ClipRRect(
@@ -110,6 +115,8 @@ class _ChatWallpaperGalleryScreenState
children: [ children: [
if (theme != null) if (theme != null)
theme.buildBackground() theme.buildBackground()
else if (image != null)
ChatWallpaperView(wallpaper: image)
else else
ColoredBox(color: cs.surfaceContainerHighest), ColoredBox(color: cs.surfaceContainerHighest),
const IgnorePointer(child: _PreviewScrim()), 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) { Widget _panel(ColorScheme cs) {
final currentImage = _currentImageTile();
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surfaceContainerHigh, color: cs.surfaceContainerHigh,
@@ -138,11 +159,12 @@ class _ChatWallpaperGalleryScreenState
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
children: [ children: [
?currentImage,
_NoneTile( _NoneTile(
selected: _selected == null && !_isImage, selected: _selected == null && !_keepsImage,
onTap: () => setState(() { onTap: () => setState(() {
_selected = null; _selected = null;
_isImage = false; _keepsImage = false;
}), }),
), ),
for (final theme in kChatWallpaperThemes) for (final theme in kChatWallpaperThemes)
@@ -151,7 +173,7 @@ class _ChatWallpaperGalleryScreenState
selected: _selected?.id == theme.id, selected: _selected?.id == theme.id,
onTap: () => setState(() { onTap: () => setState(() {
_selected = theme; _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 { class _ThemeTile extends StatelessWidget {
final ChatWallpaperTheme theme; final ChatWallpaperTheme theme;
final bool selected; final bool selected;
+99
View File
@@ -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<NavigatorState>();
var ran = 0;
await tester.pumpWidget(
MaterialApp(navigatorKey: navigator, home: const SizedBox.shrink()),
);
navigator.currentState!.push(
MaterialPageRoute<void>(
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();
});
}