diff --git a/lib/frontend/debug/fps_overlay_layer.dart b/lib/frontend/debug/fps_overlay_layer.dart new file mode 100644 index 0000000..c9af7b0 --- /dev/null +++ b/lib/frontend/debug/fps_overlay_layer.dart @@ -0,0 +1,145 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +class FpsOverlayLayer extends StatefulWidget { + const FpsOverlayLayer({super.key}); + + @override + State createState() => _FpsOverlayLayerState(); +} + +class _FpsOverlayLayerState extends State { + static const int _maxSamples = 90; + static const int _minUiRefreshMs = 160; + static const double _initialWidthGuess = 96; + static const double _initialHeightGuess = 36; + + final List _frameMicros = []; + final GlobalKey _badgeKey = GlobalKey(); + double _fps = 0; + DateTime _lastUiUpdate = DateTime.fromMillisecondsSinceEpoch(0); + double? _left; + double? _top; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addTimingsCallback(_onTimings); + } + + @override + void dispose() { + WidgetsBinding.instance.removeTimingsCallback(_onTimings); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_left != null && _top != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(_clampPositionToScreen); + } + }); + } + } + + void _ensureInitialPosition() { + if (_left != null) return; + final mq = MediaQuery.of(context); + final w = mq.size.width; + _left = w - _initialWidthGuess - 8; + _top = mq.padding.top + 8; + } + + void _clampPositionToScreen() { + if (_left == null || _top == null) return; + final mq = MediaQuery.of(context); + final screen = mq.size; + final topMin = mq.padding.top; + 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; + + _left = _left!.clamp(0.0, math.max(0.0, screen.width - bw)); + _top = _top!.clamp(topMin, math.max(topMin, bottomMax - bh)); + } + + void _onTimings(List timings) { + for (final t in timings) { + final us = t.totalSpan.inMicroseconds; + if (us <= 0) continue; + _frameMicros.add(us); + while (_frameMicros.length > _maxSamples) { + _frameMicros.removeAt(0); + } + } + final now = DateTime.now(); + if (now.difference(_lastUiUpdate).inMilliseconds < _minUiRefreshMs) { + return; + } + _lastUiUpdate = now; + if (!mounted || _frameMicros.isEmpty) return; + final sum = _frameMicros.fold(0, (a, b) => a + b); + final avg = sum / _frameMicros.length; + final fps = avg > 0 ? (1000000.0 / avg).clamp(0.0, 999.0) : 0.0; + setState(() => _fps = fps); + } + + @override + Widget build(BuildContext context) { + _ensureInitialPosition(); + _clampPositionToScreen(); + + return Positioned( + left: _left, + top: _top, + child: MouseRegion( + cursor: SystemMouseCursors.move, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanUpdate: (details) { + setState(() { + _left = _left! + details.delta.dx; + _top = _top! + details.delta.dy; + _clampPositionToScreen(); + }); + }, + child: Material( + key: _badgeKey, + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + 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()], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index dc83d3f..5f7de26 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../main.dart'; + class DebugMenuScreen extends StatelessWidget { const DebugMenuScreen({super.key}); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final appState = KometApp.stateOf(context); return Scaffold( backgroundColor: cs.surface, @@ -47,6 +50,71 @@ class DebugMenuScreen extends StatelessWidget { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: appState == null + ? const SizedBox.shrink() + : ValueListenableBuilder( + valueListenable: appState.fpsOverlayEnabled, + builder: (context, fpsOn, _) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.speed, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'Оверлей FPS', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Показ текущего фреймрейта поверх интерфейса', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch( + value: fpsOn, + onChanged: (v) { + appState.setFpsOverlayEnabled(v); + }, + ), + ], + ), + ), + ); + }, + ), + ), + ), const SliverToBoxAdapter(child: SizedBox(height: 120)), ], ), diff --git a/lib/main.dart b/lib/main.dart index 691248e..82ae9b6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'backend/modules/messages.dart'; import 'core/storage/app_database.dart'; import 'core/storage/token_storage.dart'; import 'core/protocol/packet.dart'; +import 'frontend/debug/fps_overlay_layer.dart'; import 'frontend/screens/auth/login_screen.dart'; import 'frontend/screens/chats/chat_list_screen.dart'; import 'frontend/widgets/custom_notification.dart'; @@ -35,13 +36,25 @@ void main() async { await AppDatabase.init(); await api.connect(); final initialLocale = await _loadInitialLocale(); - runApp(KometApp(initialLocale: initialLocale)); + final prefs = await SharedPreferences.getInstance(); + final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false; + runApp( + KometApp( + initialLocale: initialLocale, + initialFpsOverlay: initialFpsOverlay, + ), + ); } class KometApp extends StatefulWidget { - const KometApp({super.key, required this.initialLocale}); + const KometApp({ + super.key, + required this.initialLocale, + this.initialFpsOverlay = false, + }); final Locale initialLocale; + final bool initialFpsOverlay; static final navigatorKey = GlobalKey(); static KometAppState? stateOf(BuildContext context) { @@ -57,6 +70,8 @@ class KometAppState extends State { late Locale _locale; bool _isLoggingOut = false; + late final ValueNotifier fpsOverlayEnabled = + ValueNotifier(widget.initialFpsOverlay); @override void initState() { @@ -95,6 +110,19 @@ class KometAppState extends State { }); } + @override + void dispose() { + fpsOverlayEnabled.dispose(); + super.dispose(); + } + + Future setFpsOverlayEnabled(bool value) async { + if (fpsOverlayEnabled.value == value) return; + fpsOverlayEnabled.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('dev_fps_overlay', value); + } + Future applyLocale(Locale locale) async { if (!AppLocalizations.supportedLocales.any( (l) => l.languageCode == locale.languageCode, @@ -184,6 +212,21 @@ class KometAppState extends State { ), ), navigatorKey: KometApp.navigatorKey, + builder: (context, child) { + return ValueListenableBuilder( + valueListenable: fpsOverlayEnabled, + builder: (context, fpsOn, _) { + return Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + child ?? const SizedBox.shrink(), + if (fpsOn) const FpsOverlayLayer(), + ], + ); + }, + ); + }, home: const _StartupScreen(), ); },