fix: хм
This commit is contained in:
@@ -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<Boolean>("front") ?: true
|
||||
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) {
|
||||
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<String, Boolean> = 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,7 +15,7 @@ class AppChatChrome {
|
||||
|
||||
static final _setting = PersistedEnum<ChatChromeStyle>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: ChatChromeStyle.color,
|
||||
defaultValue: ChatChromeStyle.none,
|
||||
encode: _encode,
|
||||
decode: _parse,
|
||||
);
|
||||
@@ -23,7 +23,7 @@ class AppChatChrome {
|
||||
static ValueNotifier<ChatChromeStyle> 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;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ class AppComposerStyle {
|
||||
|
||||
static final _setting = PersistedEnum<ComposerStyle>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: ComposerStyle.auto,
|
||||
defaultValue: ComposerStyle.glossy,
|
||||
encode: (value) => value.name,
|
||||
decode: _parse,
|
||||
);
|
||||
@@ -30,5 +30,5 @@ class AppComposerStyle {
|
||||
static Future<void> save(ComposerStyle value) => _setting.save(value);
|
||||
|
||||
static ComposerStyle _parse(String? val) =>
|
||||
enumFromName(ComposerStyle.values, val, ComposerStyle.auto);
|
||||
enumFromName(ComposerStyle.values, val, ComposerStyle.glossy);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class AppNavPillStyle {
|
||||
|
||||
static final _setting = PersistedEnum<NavPillStyle>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: NavPillStyle.auto,
|
||||
defaultValue: NavPillStyle.glossy,
|
||||
encode: (value) => value.name,
|
||||
decode: _parse,
|
||||
);
|
||||
@@ -41,5 +41,5 @@ class AppNavPillStyle {
|
||||
static Future<void> save(NavPillStyle value) => _setting.save(value);
|
||||
|
||||
static NavPillStyle _parse(String? val) =>
|
||||
enumFromName(NavPillStyle.values, val, NavPillStyle.auto);
|
||||
enumFromName(NavPillStyle.values, val, NavPillStyle.glossy);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class AppVisualStyle {
|
||||
|
||||
static final _setting = PersistedEnum<VisualStyle>(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<bool> requestPermission() async {
|
||||
if (!isAvailable) return false;
|
||||
Future<VideoNoteAccess> requestAccess() async {
|
||||
if (!isAvailable) return VideoNoteAccess.denied;
|
||||
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) {
|
||||
logger.w('NativeVideoNoteRecorder.requestPermission: $e');
|
||||
return false;
|
||||
logger.w('NativeVideoNoteRecorder.requestAccess: $e');
|
||||
return VideoNoteAccess.denied;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> init({bool front = true, int size = 480, int fps = 30}) async {
|
||||
if (!isAvailable) return false;
|
||||
try {
|
||||
final res = await _channel.invokeMapMethod<String, dynamic>('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<String, dynamic>('init', {
|
||||
'front': front,
|
||||
'size': size,
|
||||
'fps': fps,
|
||||
});
|
||||
textureId = res?['textureId'] as int?;
|
||||
hasFlash = res?['hasFlash'] as bool? ?? false;
|
||||
return textureId != null;
|
||||
}
|
||||
|
||||
Future<bool> switchCamera() async {
|
||||
@@ -65,25 +69,19 @@ class NativeVideoNoteRecorder {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> start() async {
|
||||
if (!isAvailable) return false;
|
||||
try {
|
||||
await _channel.invokeMethod('start');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.w('NativeVideoNoteRecorder.start: $e');
|
||||
return false;
|
||||
Future<void> start() async {
|
||||
if (!isAvailable) {
|
||||
throw PlatformException(
|
||||
code: 'UNSUPPORTED',
|
||||
message: 'video notes are not supported on this platform',
|
||||
);
|
||||
}
|
||||
await _channel.invokeMethod('start');
|
||||
}
|
||||
|
||||
Future<String?> stop() async {
|
||||
if (!isAvailable) return null;
|
||||
try {
|
||||
return await _channel.invokeMethod<String>('stop');
|
||||
} catch (e) {
|
||||
logger.w('NativeVideoNoteRecorder.stop: $e');
|
||||
return null;
|
||||
}
|
||||
return _channel.invokeMethod<String>('stop');
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<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> {
|
||||
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<int> _frameMicros = <int>[];
|
||||
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<FpsOverlayLayer> {
|
||||
@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<FpsOverlayLayer> {
|
||||
}
|
||||
}
|
||||
|
||||
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<FpsOverlayLayer> {
|
||||
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<FpsOverlayLayer> {
|
||||
|
||||
void _onTimings(List<FrameTiming> 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<int>(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<FpsOverlayLayer> {
|
||||
_ensureInitialPosition();
|
||||
_clampPositionToScreen();
|
||||
|
||||
final tint = _tint;
|
||||
return Positioned(
|
||||
left: _left,
|
||||
top: _top,
|
||||
@@ -123,18 +178,31 @@ class _FpsOverlayLayerState extends State<FpsOverlayLayer> {
|
||||
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()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<void> _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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ChatInfoScreen>
|
||||
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<ChatInfoScreen>
|
||||
_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<ChatInfoScreen>
|
||||
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<ChatInfoScreen>
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loadedUpdate(() {
|
||||
_isLoading = false;
|
||||
if (_selectedTab.isEmpty && _tabs.isNotEmpty) {
|
||||
_selectedTab = _initialTabLabel() ?? _tabs.first;
|
||||
@@ -344,7 +371,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
Future<void> _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<ChatInfoScreen>
|
||||
Future<void> _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<ChatInfoScreen>
|
||||
if (!mounted) return;
|
||||
|
||||
if (page == null) {
|
||||
if (!initial) setState(() {});
|
||||
if (!initial) _loadedRebuild();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -479,7 +506,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
}
|
||||
_memberMarker = page.marker;
|
||||
|
||||
if (!initial) setState(() {});
|
||||
if (!initial) _loadedRebuild();
|
||||
}
|
||||
|
||||
bool _revealMoreMembers() {
|
||||
@@ -555,9 +582,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
||||
_membersLoading = false;
|
||||
_memberRenderLimit = _memberRenderChunk;
|
||||
_rebuildMembers();
|
||||
if (mounted) setState(() {});
|
||||
_loadedRebuild();
|
||||
await _fetchMembersPage(initial: true);
|
||||
if (mounted) setState(() {});
|
||||
_loadedRebuild();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -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<ChatScreen>
|
||||
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<ChatScreen>
|
||||
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<double> _composerHeight = ValueNotifier(96);
|
||||
final ValueNotifier<double> _pinnedBannerHeight = ValueNotifier(0);
|
||||
|
||||
@@ -969,29 +981,11 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
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<ChatScreen>
|
||||
_highlightMessageId.dispose();
|
||||
_goToMessageSettleTimer?.cancel();
|
||||
_jumpCacheExtent.dispose();
|
||||
_routeSettle.dispose();
|
||||
_messageKeys.clear();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -3113,7 +3108,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
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<ChatScreen>
|
||||
|
||||
Widget _buildUnderlapBody() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final vignette = _effectiveChrome == ChatChromeStyle.none;
|
||||
final vignette = _chromeVignette;
|
||||
final bannerTop = _pinnedBannerTop();
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
|
||||
@@ -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<ChatWallpaperGalleryScreen> {
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user