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
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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);
}
+2 -2
View File
@@ -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);
}
+2 -2
View File
@@ -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);
}
+36 -38
View File
@@ -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 {
+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: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
+28 -33
View File
@@ -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,
+54 -6
View File
@@ -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;