From c0f98e41c8118079bd34d06515f40063186d095e Mon Sep 17 00:00:00 2001 From: GT610 <79314033+GT-610@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:14:07 +0800 Subject: [PATCH] feat: Bring back completely re-optimized "Connection Stats" feature (#1090) * feat (Connection Statistics): Restored the server connection statistics feature * perf(store): Optimize data storage performance and implement caching mechanisms - Implement caching mechanisms in SnippetStore and ServerStore to reduce redundant loading - Refactor ConnectionStatsStore to use indexes and optimize query performance - Adopt a more efficient approach when cleaning up expired records - Add a maximum record limit to prevent data bloat * perf(store): Optimize data storage performance and add a caching mechanism Add a caching mechanism to PrivateKeyStore to reduce redundant loading Make the cleanup and index rebuilding of ConnectionStatsStore asynchronous Add database compression and size statistics Display database size in the interface and optimize compression operations * fix (Cache): Fixed cache invalidation and join statistics issues - Added a cache invalidation call to the reload method - Fixed an error in the calculation of join statistics timestamps - Optimized the cache index rebuild logic - Added tooltips and click effects for join statistics * refactor(connection_stats): Convert file operations from synchronous to asynchronous and optimize record cleanup logic Convert the database size retrieval method from synchronous to asynchronous to prevent UI blocking Optimize server record cleanup logic by directly deleting redundant records instead of rebuilding indexes * fix(connection_stats): Fixed an initialization issue when the index database is empty During Stores initialization, the code now checks whether `connectionStats.indexDbKeys` is empty; if so, it calls `rebuildIndexAndCompact` to rebuild and compact the database. Additionally, the implementation of the `_pruneExcessRecords` method has been optimized to use tuples instead of temporary lists, thereby improving performance. A `mounted` check has been added at the UI layer to prevent state update issues during asynchronous operations. * fix(server): Improved error string matching logic to more accurately identify connection issues Error strings are now uniformly converted to lowercase for comparison, and matching criteria have been expanded to cover a wider range of error scenarios, including timeouts, authentication failures, and network errors * fix(PrivateKeyStore): Fixed an issue where the cache state was not updated when clearing the cache When clearing the private key store, ensure that the internal cache state is updated simultaneously to maintain consistency * refactor(store): Add close methods and clean up subscription logic Add close methods to PrivateKeyStore, SnippetStore, and ServerStore to unsubscribe Unify cache cleanup logic to prevent memory leaks * fix(store): Add a cache update suppression mechanism to prevent circular updates Add an _suppressWatch flag to multiple Store classes to suppress cache invalidation during internal operations Add a _putWithoutInvalidatingCache method to prevent recursive watchers from being triggered during data updates * refactor(store): Improve caching and state management using try-finally In PrivateKeyStore, ServerStore, and SnippetStore: 1. Remove redundant close methods 2. Use try-finally to ensure the _suppressWatch state is reset correctly 3. Optimize cache invalidation logic 4. Standardize transaction handling for update operations * refactor(store): Optimize data storage operations and fix potential issues - Ensure the safety and consistency of list operations in ConnectionStatsStore - Replace direct calls to `box.put` with the `set` method in SnippetStore and ServerStore - Extract decoding logic for PrivateKeyStore into a separate method - Add logic to update server-hopping relationships * fix: Fixed an issue where asynchronous operations were not being waited on and optimized storage operations Fixed several issues where asynchronous operations were not being waited on to ensure data consistency Added the _suppressWatch control to ServerStore and PrivateKeyStore Optimized index management in ConnectionStatsStore to maintain record order Added a new GitHub participant * fix: Fixed potential state issues and memory leaks in asynchronous operations Fixed potential state issues that could occur on the server edit page after a delete operation; added a mounted check Changed the statistics clearing operation in connection_stats to run asynchronously Optimized asynchronous operations in PrivateKeyStore and fixed potential memory leaks * refactor(store): Convert asynchronous methods to synchronous ones to simplify the code Fixed an issue where asynchronous operations were not handled correctly on the connection statistics page * fix: Added mounted check and error handling for connection logs Added a mounted check in _ConnectionStatsPageState to prevent the state from being updated after the component is unmounted Added a try-catch block for connection logs in ServerNotifier to catch and log potential storage exceptions --- lib/data/provider/private_key.dart | 1 + lib/data/provider/server/all.dart | 8 +- lib/data/provider/server/all.g.dart | 2 +- lib/data/provider/server/single.dart | 42 ++- lib/data/provider/server/single.g.dart | 2 +- lib/data/provider/snippet.dart | 1 + lib/data/res/github_id.dart | 3 +- lib/data/res/store.dart | 4 + lib/data/store/connection_stats.dart | 292 ++++++++++------- lib/data/store/private_key.dart | 106 +++++- lib/data/store/server.dart | 94 +++++- lib/data/store/snippet.dart | 75 ++++- lib/view/page/server/connection_stats.dart | 363 +++++++++++++++++++++ lib/view/page/server/edit/widget.dart | 3 +- lib/view/page/server/tab/tab.dart | 1 + lib/view/page/server/tab/top_bar.dart | 11 +- lib/view/page/setting/entries/server.dart | 13 + lib/view/page/setting/entry.dart | 1 + pubspec.lock | 32 +- 19 files changed, 880 insertions(+), 174 deletions(-) create mode 100644 lib/view/page/server/connection_stats.dart diff --git a/lib/data/provider/private_key.dart b/lib/data/provider/private_key.dart index 26f10d98..64ecbb44 100644 --- a/lib/data/provider/private_key.dart +++ b/lib/data/provider/private_key.dart @@ -20,6 +20,7 @@ class PrivateKeyNotifier extends _$PrivateKeyNotifier { } void reload() { + Stores.key.invalidateCache(); final newState = _load(); if (newState == state) return; state = newState; diff --git a/lib/data/provider/server/all.dart b/lib/data/provider/server/all.dart index 3475df93..1d83b8d1 100644 --- a/lib/data/provider/server/all.dart +++ b/lib/data/provider/server/all.dart @@ -33,6 +33,7 @@ class ServersNotifier extends _$ServersNotifier { } Future reload() async { + Stores.server.invalidateCache(); final newState = _load(); if (newState == state) return; state = newState; @@ -213,7 +214,7 @@ class ServersNotifier extends _$ServersNotifier { bakSync.sync(milliDelay: 1000); } - void delServer(String id) { + Future delServer(String id) async { final newServers = Map.from(state.servers); newServers.remove(id); @@ -225,6 +226,8 @@ class ServersNotifier extends _$ServersNotifier { Stores.setting.serverOrder.put(newOrder); Stores.server.delete(id); + await Stores.connectionStats.clearServerStats(id); + // Remove SSH session when server is deleted final sessionId = 'ssh_$id'; TermSessionManager.remove(sessionId); @@ -232,7 +235,7 @@ class ServersNotifier extends _$ServersNotifier { bakSync.sync(milliDelay: 1000); } - void deleteAll() { + Future deleteAll() async { // Remove all SSH sessions before clearing servers for (final id in state.servers.keys) { final sessionId = 'ssh_$id'; @@ -243,6 +246,7 @@ class ServersNotifier extends _$ServersNotifier { Stores.setting.serverOrder.put([]); Stores.server.clear(); + await Stores.connectionStats.clearAll(); bakSync.sync(milliDelay: 1000); } diff --git a/lib/data/provider/server/all.g.dart b/lib/data/provider/server/all.g.dart index 9fef9e9d..75189bbf 100644 --- a/lib/data/provider/server/all.g.dart +++ b/lib/data/provider/server/all.g.dart @@ -41,7 +41,7 @@ final class ServersNotifierProvider } } -String _$serversNotifierHash() => r'277d1b219235f14bcc1b82a1e16260c2f28decdb'; +String _$serversNotifierHash() => r'c90c2d8ce73a63f926bcf9679a84ae150c9d4808'; abstract class _$ServersNotifier extends $Notifier { ServersState build(); diff --git a/lib/data/provider/server/single.dart b/lib/data/provider/server/single.dart index 28ef3f9b..f96641f8 100644 --- a/lib/data/provider/server/single.dart +++ b/lib/data/provider/server/single.dart @@ -13,6 +13,7 @@ import 'package:server_box/data/helper/system_detector.dart'; import 'package:server_box/data/model/app/error.dart'; import 'package:server_box/data/model/app/scripts/script_consts.dart'; import 'package:server_box/data/model/app/scripts/shell_func.dart'; +import 'package:server_box/data/model/server/connection_stat.dart'; import 'package:server_box/data/model/server/server.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/server_status_update_req.dart'; @@ -123,8 +124,8 @@ class ServerNotifier extends _$ServerNotifier { } } + final time1 = DateTime.now(); try { - final time1 = DateTime.now(); final client = await genClient( spi, timeout: Duration(seconds: Stores.setting.timeout.fetch()), @@ -140,6 +141,18 @@ class ServerNotifier extends _$ServerNotifier { Loggers.app.info('Jump to ${spi.name} in $spentTime ms.'); } + try { + await Stores.connectionStats.recordConnection(ConnectionStat( + serverId: spi.id, + serverName: spi.name, + timestamp: time1, + result: ConnectionResult.success, + durationMs: spentTime, + )); + } catch (e) { + Loggers.app.warning('Failed to record connection success', e); + } + final sessionId = 'ssh_${spi.id}'; TermSessionManager.add( id: sessionId, @@ -152,6 +165,33 @@ class ServerNotifier extends _$ServerNotifier { } catch (e) { TryLimiter.inc(sid); + final durationMs = DateTime.now().difference(time1).inMilliseconds; + + ConnectionResult failureResult; + final errStr = e.toString().toLowerCase(); + if (errStr.contains('timed out') || errStr.contains('timeout')) { + failureResult = ConnectionResult.timeout; + } else if (errStr.contains('auth') || errStr.contains('authentication') || errStr.contains('permission denied') || errStr.contains('access denied')) { + failureResult = ConnectionResult.authFailed; + } else if (errStr.contains('connection refused') || errStr.contains('no route to host') || errStr.contains('network') || errStr.contains('socket')) { + failureResult = ConnectionResult.networkError; + } else { + failureResult = ConnectionResult.unknownError; + } + + try { + await Stores.connectionStats.recordConnection(ConnectionStat( + serverId: spi.id, + serverName: spi.name, + timestamp: time1, + result: failureResult, + errorMessage: e.toString(), + durationMs: durationMs, + )); + } catch (recErr) { + Loggers.app.warning('Failed to record connection failure', recErr); + } + final newStatus = state.status..err = SSHErr(type: SSHErrType.connect, message: e.toString()); updateStatus(newStatus); updateConnection(ServerConn.failed); diff --git a/lib/data/provider/server/single.g.dart b/lib/data/provider/server/single.g.dart index fb57c48f..da9e2a75 100644 --- a/lib/data/provider/server/single.g.dart +++ b/lib/data/provider/server/single.g.dart @@ -58,7 +58,7 @@ final class ServerNotifierProvider } } -String _$serverNotifierHash() => r'1bda6d0a9688ab843cf30803dafe3400379dc5c3'; +String _$serverNotifierHash() => r'04b1beef4d96242fd10d5b523c6f5f17eb774bae'; final class ServerNotifierFamily extends $Family with diff --git a/lib/data/provider/snippet.dart b/lib/data/provider/snippet.dart index 130e06aa..d6c978d8 100644 --- a/lib/data/provider/snippet.dart +++ b/lib/data/provider/snippet.dart @@ -24,6 +24,7 @@ class SnippetNotifier extends _$SnippetNotifier { } void reload() { + Stores.snippet.invalidateCache(); final newState = _load(); if (newState == state) return; state = newState; diff --git a/lib/data/res/github_id.dart b/lib/data/res/github_id.dart index 5edde434..5c15ff10 100644 --- a/lib/data/res/github_id.dart +++ b/lib/data/res/github_id.dart @@ -153,7 +153,8 @@ abstract final class GithubIds { 'aliferne', 'canronglan', 'nickgirga', - 'xxnuo' + 'xxnuo', + 'sunnysu0608', }; } diff --git a/lib/data/res/store.dart b/lib/data/res/store.dart index da2321a1..e5355ef7 100644 --- a/lib/data/res/store.dart +++ b/lib/data/res/store.dart @@ -45,6 +45,10 @@ abstract final class Stores { getIt.registerLazySingleton(() => PortForwardStore.instance); await Future.wait(_allBackup.map((store) => store.init())); + + if (connectionStats.indexDbKeys.isEmpty) { + await connectionStats.rebuildIndexAndCompact(); + } } static int get lastModTime { diff --git a/lib/data/store/connection_stats.dart b/lib/data/store/connection_stats.dart index cf60cfe8..324b2229 100644 --- a/lib/data/store/connection_stats.dart +++ b/lib/data/store/connection_stats.dart @@ -1,48 +1,131 @@ +import 'dart:io'; + import 'package:fl_lib/fl_lib.dart'; +import 'package:hive_ce/hive.dart'; import 'package:server_box/data/model/server/connection_stat.dart'; class ConnectionStatsStore extends HiveStore { ConnectionStatsStore._() : super('connection_stats'); - + static final instance = ConnectionStatsStore._(); - - // Record a connection attempt - void recordConnection(ConnectionStat stat) { - final key = '${stat.serverId}_${ShortId.generate()}'; - set(key, stat); - _cleanOldRecords(stat.serverId); + + static const _indexBoxName = 'conn_stats_index'; + static const _maxRecordsPerServer = 100; + + late final Box _indexBox; + + @override + Future init() async { + await super.init(); + _indexBox = await Hive.openBox( + _indexBoxName, + path: box.path?.substring(0, box.path!.lastIndexOf(Pfs.seperator)), + ); } - - // Clean records older than 30 days for a specific server - void _cleanOldRecords(String serverId) { + + Future rebuildIndexAndCompact() async { + await _cleanAllOldAndRebuildIndex(); + await _compactIfNeeded(); + } + + Future _rebuildIndexCore() async { final cutoffTime = DateTime.now().subtract(const Duration(days: 30)); - final allKeys = keys().toList(); - final keysToDelete = []; - - for (final key in allKeys) { - if (key.startsWith(serverId)) { - final parts = key.split('_'); - if (parts.length >= 2) { - final timestamp = int.tryParse(parts.last); - if (timestamp != null) { - final recordTime = DateTime.fromMillisecondsSinceEpoch(timestamp); - if (recordTime.isBefore(cutoffTime)) { - keysToDelete.add(key); - } - } + final serverIdToKeys = >{}; + + for (final key in keys().toList()) { + final stat = get(key); + if (stat == null) continue; + + if (stat.timestamp.isBefore(cutoffTime)) { + remove(key); + continue; + } + + final serverId = stat.serverId; + serverIdToKeys.putIfAbsent(serverId, () => []).add(key); + } + + final idxKeysToDelete = _indexBox.keys.where((k) => k.toString().startsWith('idx_')).toList(); + for (final k in idxKeysToDelete) { + await _indexBox.delete(k); + } + + for (final entry in serverIdToKeys.entries) { + final keys = entry.value; + if (keys.length > _maxRecordsPerServer) { + final keyStatPairs = <(String, ConnectionStat)>[]; + for (final key in keys) { + final stat = get(key); + if (stat != null) keyStatPairs.add((key, stat)); } + keyStatPairs.sort((a, b) => b.$2.timestamp.compareTo(a.$2.timestamp)); + final toKeep = keyStatPairs.take(_maxRecordsPerServer).map((p) => p.$1).toList().reversed.toList(); + final toRemove = keyStatPairs.skip(_maxRecordsPerServer); + for (final pair in toRemove) { + remove(pair.$1); + } + await _indexBox.put('idx_${entry.key}', toKeep); + } else { + await _indexBox.put('idx_${entry.key}', keys); } } - - for (final key in keysToDelete) { - remove(key); + } + + Future _cleanAllOldAndRebuildIndex() async { + await _rebuildIndexCore(); + } + + Future _compactIfNeeded() async { + try { + await box.compact(); + await _indexBox.compact(); + } catch (e, st) { + Loggers.app.warning('Auto compact failed during init', e, st); } } - - // Get connection stats for a specific server + + Future _updateIndex(String serverId, String recordKey) async { + final indexKey = 'idx_$serverId'; + final keys = (_indexBox.get(indexKey) as List?)?.cast().toList() ?? []; + + if (!keys.contains(recordKey)) { + keys.add(recordKey); + if (keys.length > _maxRecordsPerServer) { + await _pruneExcessRecords(serverId, keys); + } + await _indexBox.put(indexKey, keys); + } + } + + Future _pruneExcessRecords(String serverId, List keys) async { + if (keys.length <= _maxRecordsPerServer) return; + + final keyStatPairs = <(String, ConnectionStat)>[]; + for (final key in keys) { + final stat = get(key); + if (stat != null) { + keyStatPairs.add((key, stat)); + } + } + + keyStatPairs.sort((a, b) => b.$2.timestamp.compareTo(a.$2.timestamp)); + + final toRemove = keyStatPairs.skip(_maxRecordsPerServer); + for (final pair in toRemove) { + remove(pair.$1); + keys.remove(pair.$1); + } + } + + Future recordConnection(ConnectionStat stat) async { + final key = '${stat.serverId}_${stat.timestamp.millisecondsSinceEpoch}'; + set(key, stat); + await _updateIndex(stat.serverId, key); + } + ServerConnectionStats getServerStats(String serverId, String serverName) { final allStats = getConnectionHistory(serverId); - + if (allStats.isEmpty) { return ServerConnectionStats( serverId: serverId, @@ -54,12 +137,12 @@ class ConnectionStatsStore extends HiveStore { successRate: 0.0, ); } - + final totalAttempts = allStats.length; final successCount = allStats.where((s) => s.result.isSuccess).length; final failureCount = totalAttempts - successCount; final successRate = totalAttempts > 0 ? (successCount / totalAttempts) : 0.0; - + final successTimes = allStats .where((s) => s.result.isSuccess) .map((s) => s.timestamp) @@ -68,23 +151,22 @@ class ConnectionStatsStore extends HiveStore { .where((s) => !s.result.isSuccess) .map((s) => s.timestamp) .toList(); - + DateTime? lastSuccessTime; DateTime? lastFailureTime; - + if (successTimes.isNotEmpty) { successTimes.sort((a, b) => b.compareTo(a)); lastSuccessTime = successTimes.first; } - + if (failureTimes.isNotEmpty) { failureTimes.sort((a, b) => b.compareTo(a)); lastFailureTime = failureTimes.first; } - - // Get recent connections (last 20) + final recentConnections = allStats.take(20).toList(); - + return ServerConnectionStats( serverId: serverId, serverName: serverName, @@ -97,108 +179,98 @@ class ConnectionStatsStore extends HiveStore { successRate: successRate, ); } - - // Get connection history for a specific server + List getConnectionHistory(String serverId) { - final allKeys = keys().where((key) => key.startsWith(serverId)).toList(); + final indexKey = 'idx_$serverId'; + final keys = (_indexBox.get(indexKey) as List?)?.cast() ?? []; + final stats = []; - - for (final key in allKeys) { - final stat = get( - key, - fromObj: (val) { - if (val is ConnectionStat) return val; - if (val is Map) { - final map = val.toStrDynMap; - if (map == null) return null; - try { - return ConnectionStat.fromJson(map as Map); - } catch (e) { - dprint('Parsing ConnectionStat from JSON', e); - } - } - return null; - }, - ); + for (final key in keys) { + final stat = get(key); if (stat != null) { stats.add(stat); } } - - // Sort by timestamp, newest first + stats.sort((a, b) => b.timestamp.compareTo(a.timestamp)); return stats; } - - // Get all servers' stats + List getAllServerStats() { - final serverIds = {}; - final serverNames = {}; - - // Get all unique server IDs - for (final key in keys()) { - final parts = key.split('_'); - if (parts.length >= 2) { - final serverId = parts[0]; - serverIds.add(serverId); - - // Try to get server name from the stored stat - final stat = get( - key, - fromObj: (val) { - if (val is ConnectionStat) return val; - if (val is Map) { - final map = val.toStrDynMap; - if (map == null) return null; - try { - return ConnectionStat.fromJson(map as Map); - } catch (e) { - dprint('Parsing ConnectionStat from JSON', e); - } - } - return null; - }, - ); + final indexKeys = _indexBox.keys + .where((k) => k is String && k.startsWith('idx_')) + .cast() + .toList(); + + final allStats = []; + for (final indexKey in indexKeys) { + final serverId = indexKey.substring(4); + final keys = (_indexBox.get(indexKey) as List?)?.cast() ?? []; + + if (keys.isEmpty) continue; + + String? serverName; + for (final key in keys.reversed) { + final stat = get(key); if (stat != null) { - serverNames[serverId] = stat.serverName; + serverName = stat.serverName; + break; } } - } - - final allStats = []; - for (final serverId in serverIds) { - final serverName = serverNames[serverId] ?? serverId; + + if (serverName == null) continue; + final stats = getServerStats(serverId, serverName); allStats.add(stats); } - + return allStats; } - - // Clear all connection stats - void clearAll() { - box.clear(); + + Future clearAll() async { + await box.clear(); + await _indexBox.clear(); } - - // Clear stats for a specific server - void clearServerStats(String serverId) { - final keysToDelete = keys().where((key) { - if (key == serverId) return true; - return key.startsWith('${serverId}_'); - }).toList(); - for (final key in keysToDelete) { + + Future clearServerStats(String serverId) async { + final indexKey = 'idx_$serverId'; + final keys = (_indexBox.get(indexKey) as List?)?.cast() ?? []; + + for (final key in keys) { remove(key); } + await _indexBox.delete(indexKey); } Future compact() async { Loggers.app.info('Start compacting connection_stats database...'); try { await box.compact(); + await _indexBox.compact(); Loggers.app.info('Finished compacting connection_stats database'); } catch (e, st) { Loggers.app.warning('Failed compacting connection_stats database', e, st); rethrow; } } -} \ No newline at end of file + + String? get dbPath => box.path; + + String? get indexDbPath => _indexBox.path; + + Iterable get indexDbKeys => _indexBox.keys.where((k) => k.toString().startsWith('idx_')); + + Future dbSizeAsync() async { + final path = dbPath; + if (path == null) return 0; + final file = File(path); + return await file.exists() ? await file.length() : 0; + } + + Future indexDbSizeAsync() async { + final path = indexDbPath; + if (path == null) return 0; + final file = File(path); + return await file.exists() ? await file.length() : 0; + } +} diff --git a/lib/data/store/private_key.dart b/lib/data/store/private_key.dart index 32e7ea6a..4569a4ff 100644 --- a/lib/data/store/private_key.dart +++ b/lib/data/store/private_key.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:fl_lib/fl_lib.dart'; import 'package:server_box/data/model/server/private_key_info.dart'; @@ -7,44 +9,112 @@ class PrivateKeyStore extends HiveStore { static final instance = PrivateKeyStore._(); + List? _cache; + StreamSubscription? _boxWatchSub; + bool _suppressWatch = false; + + @override + Future init() async { + await super.init(); + await _boxWatchSub?.cancel(); + _boxWatchSub = box.watch().listen((_) { + if (!_suppressWatch) { + _cache = null; + } + }); + } + + @override + bool clear({bool? updateLastUpdateTsOnClear}) { + _suppressWatch = true; + try { + _cache = null; + return super.clear(updateLastUpdateTsOnClear: updateLastUpdateTsOnClear); + } finally { + _suppressWatch = false; + } + } + + void invalidateCache() { + _cache = null; + } + void put(PrivateKeyInfo info) { - set(info.id, info); + _suppressWatch = true; + try { + set(info.id, info); + _cache = null; + } finally { + _suppressWatch = false; + } + } + + void _putWithoutInvalidatingCache(PrivateKeyInfo info) { + _suppressWatch = true; + try { + box.put(info.id, info); + } finally { + _suppressWatch = false; + } } List fetch() { + return List.from(_cache ??= _loadAll()); + } + + List _loadAll() { final ps = []; + final toPersist = []; for (final key in keys()) { final s = get( key, - fromObj: (val) { - if (val is PrivateKeyInfo) return val; - if (val is Map) { - final map = val.toStrDynMap; - if (map == null) return null; - try { - final pki = PrivateKeyInfo.fromJson(map as Map); - put(pki); - return pki; - } catch (e) { - dprint('Parsing PrivateKeyInfo from JSON', e); - } - } - return null; - }, + fromObj: (val) => _decodePrivateKeyInfo(val, toPersist: toPersist), ); if (s != null) { ps.add(s); } } + for (final pki in toPersist) { + _putWithoutInvalidatingCache(pki); + } return ps; } + PrivateKeyInfo? _decodePrivateKeyInfo(dynamic val, {List? toPersist}) { + if (val is PrivateKeyInfo) return val; + if (val is Map) { + final map = val.toStrDynMap; + if (map == null) return null; + try { + final pki = PrivateKeyInfo.fromJson(map as Map); + if (toPersist != null) { + toPersist.add(pki); + } + return pki; + } catch (e) { + dprint('Parsing PrivateKeyInfo from JSON', e); + } + } + return null; + } + PrivateKeyInfo? fetchOne(String? id) { if (id == null) return null; - return box.get(id); + if (_cache != null) { + for (final pki in _cache!) { + if (pki.id == id) return pki; + } + } + return _decodePrivateKeyInfo(box.get(id)); } void delete(PrivateKeyInfo s) { - remove(s.id); + _suppressWatch = true; + try { + remove(s.id); + _cache = null; + } finally { + _suppressWatch = false; + } } } diff --git a/lib/data/store/server.dart b/lib/data/store/server.dart index 065495a8..ab3a0801 100644 --- a/lib/data/store/server.dart +++ b/lib/data/store/server.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:fl_lib/fl_lib.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; @@ -10,11 +12,60 @@ class ServerStore extends HiveStore { static final instance = ServerStore._(); + List? _cache; + StreamSubscription? _boxWatchSub; + bool _suppressWatch = false; + + @override + Future init() async { + await super.init(); + _boxWatchSub?.cancel(); + _boxWatchSub = box.watch().listen((_) { + if (!_suppressWatch) { + _cache = null; + } + }); + } + + @override + bool clear({bool? updateLastUpdateTsOnClear}) { + _suppressWatch = true; + try { + _cache = null; + return super.clear(updateLastUpdateTsOnClear: updateLastUpdateTsOnClear); + } finally { + _suppressWatch = false; + } + } + + void invalidateCache() { + _cache = null; + } + void put(Spi info) { - set(info.id, info); + _suppressWatch = true; + try { + set(info.id, info); + _cache = null; + } finally { + _suppressWatch = false; + } + } + + void _putWithoutInvalidatingCache(Spi info) { + _suppressWatch = true; + try { + box.put(info.id, info); + } finally { + _suppressWatch = false; + } } List fetch() { + return List.from(_cache ??= _loadAll()); + } + + List _loadAll() { final List ss = []; for (final id in keys()) { final s = get( @@ -26,7 +77,7 @@ class ServerStore extends HiveStore { if (map == null) return null; try { final spi = Spi.fromJson(map as Map); - put(spi); + _putWithoutInvalidatingCache(spi); return spi; } catch (e) { dprint('Parsing Spi from JSON', e); @@ -43,15 +94,27 @@ class ServerStore extends HiveStore { } void delete(String id) { - remove(id); + _suppressWatch = true; + try { + remove(id); + _cache = null; + } finally { + _suppressWatch = false; + } } void update(Spi old, Spi newInfo) { if (!have(old)) { throw Exception('Old spi: $old not found'); } - delete(old.id); - put(newInfo); + _suppressWatch = true; + try { + remove(old.id); + set(newInfo.id, newInfo); + _cache = null; + } finally { + _suppressWatch = false; + } } bool have(Spi s) => get(s.id) != null; @@ -60,12 +123,9 @@ class ServerStore extends HiveStore { final ss = fetch(); final idMap = {}; - // Collect all old to new ID mappings for (final s in ss) { final newId = s.migrateId(); if (newId == null) continue; - // Use s.oldId as the key, because s.id would be empty for a server being migrated. - // s.oldId represents the identifier used before migration. idMap[s.oldId] = newId; } @@ -74,23 +134,19 @@ class ServerStore extends HiveStore { final container = ContainerStore.instance; bool srvOrderChanged = false; - // Update all references to the servers for (final e in idMap.entries) { final oldId = e.key; final newId = e.value; - // Replace ids in ordering settings. final srvIdx = srvOrder.indexOf(oldId); if (srvIdx != -1) { srvOrder[srvIdx] = newId; srvOrderChanged = true; } - // Replace ids in jump server settings. final spi = get(newId); if (spi != null) { - final jumpId = spi.jumpId; // This could be an oldId. - // Check if this jumpId corresponds to a server that was also migrated. + final jumpId = spi.jumpId; if (jumpId != null && idMap.containsKey(jumpId)) { final newJumpId = idMap[jumpId]; if (spi.jumpId != newJumpId) { @@ -100,7 +156,6 @@ class ServerStore extends HiveStore { } } - // Replace ids in [Snippet] for (final snippet in snippets) { final autoRunsOn = snippet.autoRunOn; final idx = autoRunsOn?.indexOf(oldId); @@ -112,7 +167,6 @@ class ServerStore extends HiveStore { } } - // Replace ids in [Container] final dockerHost = container.fetch(oldId); if (dockerHost != null) { container.remove(oldId); @@ -120,8 +174,16 @@ class ServerStore extends HiveStore { } } + for (final spi in ss) { + if (spi.jumpId != null && idMap.containsKey(spi.jumpId)) { + final newJumpId = idMap[spi.jumpId]!; + final newSpi = spi.copyWith(jumpId: newJumpId); + update(spi, newSpi); + } + } + if (srvOrderChanged) { SettingStore.instance.serverOrder.put(srvOrder); } } -} +} \ No newline at end of file diff --git a/lib/data/store/snippet.dart b/lib/data/store/snippet.dart index 940c343e..cf5abb17 100644 --- a/lib/data/store/snippet.dart +++ b/lib/data/store/snippet.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:fl_lib/fl_lib.dart'; import 'package:server_box/data/model/server/snippet.dart'; @@ -7,11 +9,60 @@ class SnippetStore extends HiveStore { static final instance = SnippetStore._(); + List? _cache; + StreamSubscription? _boxWatchSub; + bool _suppressWatch = false; + + @override + Future init() async { + await super.init(); + _boxWatchSub?.cancel(); + _boxWatchSub = box.watch().listen((_) { + if (!_suppressWatch) { + _cache = null; + } + }); + } + + @override + bool clear({bool? updateLastUpdateTsOnClear}) { + _suppressWatch = true; + try { + _cache = null; + return super.clear(updateLastUpdateTsOnClear: updateLastUpdateTsOnClear); + } finally { + _suppressWatch = false; + } + } + + void invalidateCache() { + _cache = null; + } + void put(Snippet snippet) { - set(snippet.name, snippet); + _suppressWatch = true; + try { + set(snippet.name, snippet); + _cache = null; + } finally { + _suppressWatch = false; + } + } + + void _putWithoutInvalidatingCache(Snippet snippet) { + _suppressWatch = true; + try { + box.put(snippet.name, snippet); + } finally { + _suppressWatch = false; + } } List fetch() { + return List.from(_cache ??= _loadAll()); + } + + List _loadAll() { final ss = {}; for (final key in keys()) { final s = get( @@ -23,7 +74,7 @@ class SnippetStore extends HiveStore { if (map == null) return null; try { final snippet = Snippet.fromJson(map as Map); - put(snippet); + _putWithoutInvalidatingCache(snippet); return snippet; } catch (e) { dprint('Parsing Snippet from JSON', e); @@ -40,16 +91,28 @@ class SnippetStore extends HiveStore { } void delete(Snippet s) { - remove(s.name); + _suppressWatch = true; + try { + remove(s.name); + _cache = null; + } finally { + _suppressWatch = false; + } } void update(Snippet old, Snippet newInfo) { if (!have(old)) { throw Exception('Old snippet: $old not found'); } - delete(old); - put(newInfo); + _suppressWatch = true; + try { + remove(old.name); + set(newInfo.name, newInfo); + _cache = null; + } finally { + _suppressWatch = false; + } } bool have(Snippet s) => get(s.name) != null; -} +} \ No newline at end of file diff --git a/lib/view/page/server/connection_stats.dart b/lib/view/page/server/connection_stats.dart new file mode 100644 index 00000000..54365ff3 --- /dev/null +++ b/lib/view/page/server/connection_stats.dart @@ -0,0 +1,363 @@ +// ignore_for_file: invalid_use_of_protected_member + +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter/material.dart'; +import 'package:server_box/core/extension/context/locale.dart'; +import 'package:server_box/data/model/server/connection_stat.dart'; +import 'package:server_box/data/res/store.dart'; + +class ConnectionStatsPage extends StatefulWidget { + const ConnectionStatsPage({super.key}); + + static const route = AppRouteNoArg(page: ConnectionStatsPage.new, path: '/server/conn_stats'); + + @override + State createState() => _ConnectionStatsPageState(); +} + +class _ConnectionStatsPageState extends State { + List _serverStats = []; + bool _isLoading = true; + bool _isCompacting = false; + + @override + void initState() { + super.initState(); + _loadStats(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: CustomAppBar( + title: Text(l10n.connectionStats), + actions: [ + IconButton(onPressed: _loadStats, icon: const Icon(Icons.refresh), tooltip: libL10n.refresh), + IconButton( + onPressed: _showClearAllDialog, + icon: const Icon(Icons.clear_all, color: Colors.red), + tooltip: libL10n.clear, + ), + IconButton( + onPressed: _isCompacting ? null : _showCompactDialog, + icon: _isCompacting + ? SizedLoading.small + : const Icon(Icons.compress), + tooltip: l10n.compactDatabase, + ), + ], + ), + body: _buildBody(), + ); + } +} + +extension _Builds on _ConnectionStatsPageState { + Widget _buildBody() { + if (_isLoading) { + return const Center(child: SizedLoading.large); + } + if (_serverStats.isEmpty) { + return Center(child: Text(l10n.noConnectionStatsData)); + } + + return ListView.builder( + cacheExtent: 200, + itemCount: _serverStats.length, + itemBuilder: (context, index) { + final stats = _serverStats[index]; + return RepaintBoundary( + child: _buildServerStatsCard(stats), + ); + }, + ); + } + + Widget _buildServerStatsCard(ServerConnectionStats stats) { + final successRate = stats.totalAttempts == 0 ? libL10n.notAvailable : '${(stats.successRate * 100).toStringAsFixed(1)}%'; + final lastSuccessTime = stats.lastSuccessTime; + final lastFailureTime = stats.lastFailureTime; + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + stats.serverName, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + Text( + '${libL10n.success}: $successRate', + style: TextStyle( + fontSize: 16, + color: stats.successRate >= 0.8 + ? Colors.green + : stats.successRate >= 0.5 + ? Colors.orange + : Colors.red, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatItem(libL10n.totalAttempts, stats.totalAttempts.toString(), Icons.all_inclusive), + _buildStatItem( + libL10n.success, + stats.successCount.toString(), + Icons.check_circle, + Colors.green, + ), + _buildStatItem(libL10n.fail, stats.failureCount.toString(), Icons.error, Colors.red), + ], + ), + if (lastSuccessTime != null || lastFailureTime != null) ...[ + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + if (lastSuccessTime != null) + _buildTimeItem(l10n.lastSuccess, lastSuccessTime, Icons.check_circle, Colors.green), + if (lastFailureTime != null) + _buildTimeItem(l10n.lastFailure, lastFailureTime, Icons.error, Colors.red), + ], + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(l10n.recentConnections, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + TextButton(onPressed: () => _showServerDetailsDialog(stats), child: Text(l10n.viewDetails)), + ], + ), + const SizedBox(height: 8), + ...stats.recentConnections.take(3).map(_buildConnectionItem), + ], + ), + ).cardx; + } + + Widget _buildStatItem(String label, String value, IconData icon, [Color? color]) { + return Column( + children: [ + Icon(icon, size: 24, color: color ?? Colors.grey), + const SizedBox(height: 4), + Text( + value, + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: color), + ), + Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])), + ], + ); + } + + Widget _buildTimeItem(String label, DateTime time, IconData icon, Color color) { + final timeStr = time.simple(); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon(icon, size: 16, color: color), + UIs.width7, + Text('$label: ', style: TextStyle(fontSize: 12, color: Colors.grey[600])), + Text(timeStr, style: const TextStyle(fontSize: 12)), + ], + ), + ); + } + + Widget _buildConnectionItem(ConnectionStat stat) { + final timeStr = stat.timestamp.simple(); + final isSuccess = stat.result.isSuccess; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Icon( + isSuccess ? Icons.check_circle : Icons.error, + size: 16, + color: isSuccess ? Colors.green : Colors.red, + ), + UIs.width7, + Text(timeStr, style: const TextStyle(fontSize: 12)), + UIs.width7, + Expanded( + child: Text( + isSuccess ? '${libL10n.success} (${stat.durationMs}ms)' : stat.result.displayName, + style: TextStyle(fontSize: 12, color: isSuccess ? Colors.green : Colors.red), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + +extension _Actions on _ConnectionStatsPageState { + Future _loadStats() async { + if (!mounted) return; + setState(() { + _isLoading = true; + }); + await Future.delayed(Duration.zero); + if (!mounted) return; + + final stats = Stores.connectionStats.getAllServerStats(); + if (!mounted) return; + setState(() { + _serverStats = stats; + _isLoading = false; + }); + } + + Future _showCompactDialog() async { + final oldSize = await Stores.connectionStats.dbSizeAsync(); + if (!mounted) return; + final oldIndexSize = await Stores.connectionStats.indexDbSizeAsync(); + if (!mounted) return; + final totalSize = oldSize + oldIndexSize; + + final sizeStr = _formatSize(totalSize); + + context.showRoundDialog( + title: l10n.compactDatabase, + child: Text(l10n.compactDatabaseContent(sizeStr)), + actions: [ + TextButton(onPressed: context.pop, child: Text(libL10n.cancel)), + TextButton( + onPressed: () async { + context.pop(); + setState(() => _isCompacting = true); + try { + await Stores.connectionStats.compact(); + final newSize = await Stores.connectionStats.dbSizeAsync(); + final newIndexSize = await Stores.connectionStats.indexDbSizeAsync(); + final newTotalSize = newSize + newIndexSize; + final newSizeStr = _formatSize(newTotalSize); + if (mounted) { + setState(() => _isCompacting = false); + context.showSnackBar('${libL10n.success}: $sizeStr -> $newSizeStr'); + } + } catch (e) { + if (mounted) { + setState(() => _isCompacting = false); + context.showSnackBar('${libL10n.error}: $e'); + } + } + }, + child: Text(libL10n.confirm), + ), + ], + ); + } + + void _showServerDetailsDialog(ServerConnectionStats stats) { + context.showRoundDialog( + title: '${stats.serverName} - ${l10n.connectionDetails}', + child: SizedBox( + width: double.maxFinite, + height: MediaQuery.sizeOf(context).height * 0.7, + child: ListView.separated( + itemCount: stats.recentConnections.length, + separatorBuilder: (context, index) => const Divider(), + itemBuilder: (context, index) { + final stat = stats.recentConnections[index]; + final timeStr = stat.timestamp.simple(); + final isSuccess = stat.result.isSuccess; + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + isSuccess ? Icons.check_circle : Icons.error, + color: isSuccess ? Colors.green : Colors.red, + ), + title: Text(timeStr), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isSuccess + ? '${libL10n.success} (${stat.durationMs}ms)' + : '${libL10n.fail}: ${stat.result.displayName}', + style: TextStyle(color: isSuccess ? Colors.green : Colors.red), + ), + if (!isSuccess && stat.errorMessage.isNotEmpty) + Text( + stat.errorMessage, + style: const TextStyle(fontSize: 11, color: Colors.grey), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + }, + ), + ), + actions: [ + TextButton(onPressed: context.pop, child: Text(libL10n.close)), + TextButton( + onPressed: () { + context.pop(); + _showClearServerStatsDialog(stats); + }, + child: Text(l10n.clearThisServerStats, style: TextStyle(color: Colors.red)), + ), + ], + ); + } + + void _showClearAllDialog() { + context.showRoundDialog( + title: l10n.clearAllStatsTitle, + child: Text(l10n.clearAllStatsContent), + actions: [ + TextButton(onPressed: context.pop, child: Text(libL10n.cancel)), + CountDownBtn( + onTap: () async { + context.pop(); + await Stores.connectionStats.clearAll(); + _loadStats(); + }, + text: libL10n.ok, + afterColor: Colors.red, + ), + ], + ); + } + + void _showClearServerStatsDialog(ServerConnectionStats stats) { + context.showRoundDialog( + title: l10n.clearServerStatsTitle(stats.serverName), + child: Text(l10n.clearServerStatsContent(stats.serverName)), + actions: [ + TextButton(onPressed: context.pop, child: Text(libL10n.cancel)), + CountDownBtn( + onTap: () async { + context.pop(); + await Stores.connectionStats.clearServerStats(stats.serverId); + _loadStats(); + }, + text: libL10n.ok, + afterColor: Colors.red, + ), + ], + ); + } +} + +extension _Utils on _ConnectionStatsPageState { + String _formatSize(int bytes) { + if (bytes < 1000) return '$bytes B'; + if (bytes < 1000 * 1000) return '${(bytes / 1000).toStringAsFixed(1)} KB'; + return '${(bytes / (1000 * 1000)).toStringAsFixed(1)} MB'; + } +} \ No newline at end of file diff --git a/lib/view/page/server/edit/widget.dart b/lib/view/page/server/edit/widget.dart index 58769ff5..91ee8ab8 100644 --- a/lib/view/page/server/edit/widget.dart +++ b/lib/view/page/server/edit/widget.dart @@ -434,7 +434,8 @@ extension _Widgets on _ServerEditPageState { actions: Btn.ok( onTap: () async { context.pop(); - ref.read(serversProvider.notifier).delServer(spi!.id); + await ref.read(serversProvider.notifier).delServer(spi!.id); + if (!mounted) return; context.pop(true); }, red: true, diff --git a/lib/view/page/server/tab/tab.dart b/lib/view/page/server/tab/tab.dart index c1cb127e..6779b18c 100644 --- a/lib/view/page/server/tab/tab.dart +++ b/lib/view/page/server/tab/tab.dart @@ -21,6 +21,7 @@ import 'package:server_box/data/provider/server/all.dart'; import 'package:server_box/data/provider/server/single.dart'; import 'package:server_box/data/res/build_data.dart'; import 'package:server_box/data/res/store.dart'; +import 'package:server_box/view/page/server/connection_stats.dart'; import 'package:server_box/view/page/server/detail/view.dart'; import 'package:server_box/view/page/server/edit/edit.dart'; import 'package:server_box/view/page/setting/entry.dart'; diff --git a/lib/view/page/server/tab/top_bar.dart b/lib/view/page/server/tab/top_bar.dart index 523f1318..d9cc9ece 100644 --- a/lib/view/page/server/tab/top_bar.dart +++ b/lib/view/page/server/tab/top_bar.dart @@ -42,7 +42,16 @@ final class _TopBar extends ConsumerWidget implements PreferredSizeWidget { } final total = order.length; final connectionText = '$connected/$total ${context.libL10n.conn}'; - leading = Text(connectionText, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)); + leading = MouseRegion( + cursor: SystemMouseCursors.click, + child: Tooltip( + message: context.l10n.connectionStats, + child: InkWell( + onTap: () => ConnectionStatsPage.route.go(context), + child: Text(connectionText, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), + ), + ); } return Padding( diff --git a/lib/view/page/setting/entries/server.dart b/lib/view/page/setting/entries/server.dart index 8b45b6db..65c6e004 100644 --- a/lib/view/page/setting/entries/server.dart +++ b/lib/view/page/setting/entries/server.dart @@ -9,6 +9,7 @@ extension _Server on _AppSettingsPageState { _buildNetViewType(), _buildServerSeq(), _buildServerDetailCardSeq(), + _buildConnectionStats(), _buildDeleteServers(), _buildCpuView(), _buildServerMore(), @@ -38,6 +39,18 @@ extension _Server on _AppSettingsPageState { ); } + Widget _buildConnectionStats() { + return ListTile( + leading: const Icon(Icons.analytics, size: _kIconSize), + title: Text(l10n.connectionStats), + subtitle: Text(l10n.connectionStatsDesc), + trailing: const Icon(Icons.keyboard_arrow_right), + onTap: () { + ConnectionStatsPage.route.go(context); + }, + ); + } + Widget _buildDeleteServers() { return ListTile( title: Text(l10n.deleteServers), diff --git a/lib/view/page/setting/entry.dart b/lib/view/page/setting/entry.dart index 93775387..0f4a8938 100644 --- a/lib/view/page/setting/entry.dart +++ b/lib/view/page/setting/entry.dart @@ -23,6 +23,7 @@ import 'package:server_box/data/store/setting.dart'; import 'package:server_box/generated/l10n/l10n.dart'; import 'package:server_box/view/page/backup.dart'; import 'package:server_box/view/page/private_key/list.dart'; +import 'package:server_box/view/page/server/connection_stats.dart'; import 'package:server_box/view/page/server/discovery/discovery.dart'; import 'package:server_box/view/page/setting/entries/home_tabs.dart'; import 'package:server_box/view/page/setting/platform/ios.dart'; diff --git a/pubspec.lock b/pubspec.lock index 07568aa6..ba00145d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" boolean_selector: dependency: transitive description: @@ -117,10 +117,10 @@ packages: dependency: transitive description: name: built_value - sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" + sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af" url: "https://pub.dev" source: hosted - version: "8.12.4" + version: "8.12.5" camera: dependency: transitive description: @@ -508,10 +508,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" url: "https://pub.dev" source: hosted - version: "2.0.33" + version: "2.0.34" flutter_riverpod: dependency: "direct main" description: @@ -1030,10 +1030,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" url: "https://pub.dev" source: hosted - version: "2.2.22" + version: "2.2.23" path_provider_foundation: dependency: transitive description: @@ -1309,18 +1309,18 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 url: "https://pub.dev" source: hosted - version: "2.4.21" + version: "2.4.23" shared_preferences_foundation: dependency: transitive description: @@ -1341,10 +1341,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" shared_preferences_web: dependency: transitive description: @@ -1546,10 +1546,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" url: "https://pub.dev" source: hosted - version: "6.3.28" + version: "6.3.29" url_launcher_ios: dependency: transitive description: