diff --git a/lib/core/motion/ticker_gated_polling.dart b/lib/core/motion/ticker_gated_polling.dart new file mode 100644 index 00000000..0838fe55 --- /dev/null +++ b/lib/core/motion/ticker_gated_polling.dart @@ -0,0 +1,24 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +/// Starts/stops a [Timer.periodic] based on [TickerMode] (keep-alive morph). +/// +/// Call from [State.build] or [State.didChangeDependencies]. When the ancestor +/// [TickerMode] is disabled (inactive [QueryaSwitchingBody] / +/// [QueryaCrossFadeStack] layer), the timer is cancelled so network polls do +/// not run offscreen. Re-enabling restarts the timer when [shouldRun] is true. +Timer? syncTickerGatedPeriodicTimer({ + required BuildContext context, + required Timer? timer, + required bool shouldRun, + required Duration interval, + required void Function() onTick, +}) { + final active = TickerMode.valuesOf(context).enabled && shouldRun; + if (!active) { + timer?.cancel(); + return null; + } + return timer ?? Timer.periodic(interval, (_) => onTick()); +} diff --git a/lib/features/extensions/extension_stats_view.dart b/lib/features/extensions/extension_stats_view.dart index a3be0414..f7788a59 100644 --- a/lib/features/extensions/extension_stats_view.dart +++ b/lib/features/extensions/extension_stats_view.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/extensions/models/extension_server_stats.dart'; +import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -97,21 +98,31 @@ class _ExtensionStatsViewState extends material.State { } } + Future _onPollTick() async { + try { + final stats = await ExtensionDriverSession.instance + .getServerStats(widget.connectionRow); + if (!mounted) return; + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; + setState(() {}); + } catch (_) {} + } + void _startTimer() { _timer?.cancel(); - _timer = Timer.periodic(_pollInterval, (_) async { - try { - final stats = await ExtensionDriverSession.instance - .getServerStats(widget.connectionRow); - if (!mounted) return; - if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; - setState(() {}); - } catch (_) {} - }); + _timer = Timer.periodic(_pollInterval, (_) => unawaited(_onPollTick())); } @override material.Widget build(material.BuildContext context) { + _timer = syncTickerGatedPeriodicTimer( + context: context, + timer: _timer, + shouldRun: !_loading && _error == null, + interval: _pollInterval, + onTick: () => unawaited(_onPollTick()), + ); + final cs = Theme.of(context).colorScheme; final width = MediaQuery.sizeOf(context).width; diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart index 011cbe77..50a92872 100644 --- a/lib/features/mongodb/mongo_stats_view.dart +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; @@ -174,7 +175,7 @@ class _MongoStatsViewState extends material.State { _timer?.cancel(); final interval = _autoRefreshInterval; if (interval == null) return; - _timer = Timer.periodic(interval, (_) => _pollTick()); + _timer = Timer.periodic(interval, (_) => unawaited(_pollTick())); } String _formatClock(DateTime t) { @@ -184,6 +185,20 @@ class _MongoStatsViewState extends material.State { @override material.Widget build(material.BuildContext context) { + final interval = _autoRefreshInterval; + if (interval != null) { + _timer = syncTickerGatedPeriodicTimer( + context: context, + timer: _timer, + shouldRun: !_loading && _connection != null, + interval: interval, + onTick: () => unawaited(_pollTick()), + ); + } else { + _timer?.cancel(); + _timer = null; + } + final cs = Theme.of(context).colorScheme; final width = MediaQuery.sizeOf(context).width; diff --git a/lib/features/mysql/mysql_stats_view.dart b/lib/features/mysql/mysql_stats_view.dart index 6fc74221..a4040d49 100644 --- a/lib/features/mysql/mysql_stats_view.dart +++ b/lib/features/mysql/mysql_stats_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -108,22 +109,33 @@ class _MysqlStatsViewState extends material.State { } } + Future _onPollTick() async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) return; + try { + final stats = await conn.serverStats(); + if (!mounted) return; + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; + setState(() {}); + } catch (_) {} + } + void _startTimer() { _timer?.cancel(); - _timer = Timer.periodic(_pollInterval, (_) async { - final conn = _lease?.connection; - if (conn == null || !conn.isConnected) return; - try { - final stats = await conn.serverStats(); - if (!mounted) return; - if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; - setState(() {}); - } catch (_) {} - }); + _timer = Timer.periodic(_pollInterval, (_) => unawaited(_onPollTick())); } @override material.Widget build(material.BuildContext context) { + final conn = _lease?.connection; + _timer = syncTickerGatedPeriodicTimer( + context: context, + timer: _timer, + shouldRun: conn != null && conn.isConnected && !_loading, + interval: _pollInterval, + onTick: () => unawaited(_onPollTick()), + ); + final cs = Theme.of(context).colorScheme; final width = material.MediaQuery.sizeOf(context).width; diff --git a/lib/features/postgresql/postgres_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart index 9af3317b..07c0fe03 100644 --- a/lib/features/postgresql/postgres_stats_view.dart +++ b/lib/features/postgresql/postgres_stats_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; @@ -121,22 +122,33 @@ class _PostgresStatsViewState extends material.State { } } + Future _onPollTick() async { + final c = _connection; + if (c == null || !c.isConnected) return; + try { + final stats = await c.serverStats(); + if (!mounted) return; + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; + setState(() {}); + } catch (_) {} + } + void _startTimer() { _timer?.cancel(); - _timer = Timer.periodic(_pollInterval, (_) async { - final c = _connection; - if (c == null || !c.isConnected) return; - try { - final stats = await c.serverStats(); - if (!mounted) return; - if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; - setState(() {}); - } catch (_) {} - }); + _timer = Timer.periodic(_pollInterval, (_) => unawaited(_onPollTick())); } @override material.Widget build(material.BuildContext context) { + final c = _connection; + _timer = syncTickerGatedPeriodicTimer( + context: context, + timer: _timer, + shouldRun: c != null && c.isConnected && !_loading, + interval: _pollInterval, + onTick: () => unawaited(_onPollTick()), + ); + final cs = Theme.of(context).colorScheme; final width = MediaQuery.sizeOf(context).width; diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index 10cbbb13..08a9fa75 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_info.dart'; @@ -147,23 +148,34 @@ class _RedisViewState extends material.State { setState(() => _loading = false); } + Future _onPollTick() async { + final c = _connection; + if (c == null || !c.isConnected) return; + try { + final raw = await c.info(); + final info = parseRedisInfo(raw); + if (!mounted) return; + if (!replaceIfChanged(_info, info, (v) => _info = v)) return; + setState(() {}); + } catch (_) {} + } + void _startTimer() { _timer?.cancel(); - _timer = Timer.periodic(_pollInterval, (_) async { - final c = _connection; - if (c == null || !c.isConnected) return; - try { - final raw = await c.info(); - final info = parseRedisInfo(raw); - if (!mounted) return; - if (!replaceIfChanged(_info, info, (v) => _info = v)) return; - setState(() {}); - } catch (_) {} - }); + _timer = Timer.periodic(_pollInterval, (_) => unawaited(_onPollTick())); } @override material.Widget build(material.BuildContext context) { + final c = _connection; + _timer = syncTickerGatedPeriodicTimer( + context: context, + timer: _timer, + shouldRun: c != null && c.isConnected && !_loading, + interval: _pollInterval, + onTick: () => unawaited(_onPollTick()), + ); + final cs = Theme.of(context).colorScheme; final width = MediaQuery.sizeOf(context).width; diff --git a/test/core/motion/ticker_gated_polling_test.dart b/test/core/motion/ticker_gated_polling_test.dart new file mode 100644 index 00000000..2028efec --- /dev/null +++ b/test/core/motion/ticker_gated_polling_test.dart @@ -0,0 +1,55 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; + +void main() { + testWidgets('cancels timer when TickerMode is disabled', (tester) async { + Timer? timer; + var ticks = 0; + + await tester.pumpWidget( + TickerMode( + enabled: true, + child: Builder( + builder: (context) { + timer = syncTickerGatedPeriodicTimer( + context: context, + timer: timer, + shouldRun: true, + interval: const Duration(milliseconds: 20), + onTick: () => ticks++, + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + await tester.pump(const Duration(milliseconds: 50)); + expect(ticks, greaterThan(0)); + expect(timer, isNotNull); + + await tester.pumpWidget( + TickerMode( + enabled: false, + child: Builder( + builder: (context) { + timer = syncTickerGatedPeriodicTimer( + context: context, + timer: timer, + shouldRun: true, + interval: const Duration(milliseconds: 20), + onTick: () => ticks++, + ); + return const SizedBox.shrink(); + }, + ), + ), + ); + final afterDisable = ticks; + await tester.pump(const Duration(milliseconds: 60)); + expect(ticks, afterDisable); + expect(timer, isNull); + }); +}