refactor: merge profile cache into unified cache repository
This commit is contained in:
@@ -25,6 +25,7 @@ import '../../features/friends/data/friends_api.dart';
|
|||||||
import '../../features/messages/data/inbox_api.dart';
|
import '../../features/messages/data/inbox_api.dart';
|
||||||
import '../../features/settings/data/settings_api.dart';
|
import '../../features/settings/data/settings_api.dart';
|
||||||
import '../../features/settings/data/services/settings_user_cache.dart';
|
import '../../features/settings/data/services/settings_user_cache.dart';
|
||||||
|
import '../../features/settings/data/services/user_profile_cache_repository.dart';
|
||||||
import '../../features/users/data/users_api.dart';
|
import '../../features/users/data/users_api.dart';
|
||||||
import '../../features/todo/data/todo_api.dart';
|
import '../../features/todo/data/todo_api.dart';
|
||||||
|
|
||||||
@@ -76,6 +77,12 @@ Future<void> configureDependencies() async {
|
|||||||
final usersApi = UsersApi(apiClient);
|
final usersApi = UsersApi(apiClient);
|
||||||
sl.registerSingleton<UsersApi>(usersApi);
|
sl.registerSingleton<UsersApi>(usersApi);
|
||||||
|
|
||||||
|
final userProfileCacheRepository = UserProfileCacheRepository(
|
||||||
|
store: hybridCacheStore,
|
||||||
|
remoteLoader: usersApi.getMe,
|
||||||
|
);
|
||||||
|
sl.registerSingleton<UserProfileCacheRepository>(userProfileCacheRepository);
|
||||||
|
|
||||||
final calendarApi = CalendarApi(apiClient);
|
final calendarApi = CalendarApi(apiClient);
|
||||||
sl.registerSingleton<CalendarApi>(calendarApi);
|
sl.registerSingleton<CalendarApi>(calendarApi);
|
||||||
|
|
||||||
@@ -100,7 +107,9 @@ Future<void> configureDependencies() async {
|
|||||||
final settingsApi = SettingsApi(apiClient);
|
final settingsApi = SettingsApi(apiClient);
|
||||||
sl.registerSingleton<SettingsApi>(settingsApi);
|
sl.registerSingleton<SettingsApi>(settingsApi);
|
||||||
|
|
||||||
sl.registerSingleton<SettingsUserCache>(SettingsUserCache());
|
sl.registerSingleton<SettingsUserCache>(
|
||||||
|
SettingsUserCache(userProfileCacheRepository),
|
||||||
|
);
|
||||||
|
|
||||||
final inboxApi = InboxApi(apiClient);
|
final inboxApi = InboxApi(apiClient);
|
||||||
sl.registerSingleton<InboxApi>(inboxApi);
|
sl.registerSingleton<InboxApi>(inboxApi);
|
||||||
|
|||||||
@@ -1,49 +1,30 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import '../../../users/data/models/user_response.dart';
|
import '../../../users/data/models/user_response.dart';
|
||||||
|
import 'user_profile_cache_repository.dart';
|
||||||
|
|
||||||
class SettingsUserCache {
|
class SettingsUserCache {
|
||||||
|
final UserProfileCacheRepository _repository;
|
||||||
|
|
||||||
|
SettingsUserCache(this._repository);
|
||||||
|
|
||||||
UserResponse? _cachedUser;
|
UserResponse? _cachedUser;
|
||||||
Future<UserResponse>? _inflight;
|
|
||||||
int _generation = 0;
|
|
||||||
|
|
||||||
UserResponse? get cachedUser => _cachedUser;
|
UserResponse? get cachedUser => _cachedUser;
|
||||||
|
|
||||||
Future<UserResponse> getOrLoad(Future<UserResponse> Function() loader) {
|
Future<UserResponse> getProfile({bool forceRefresh = false}) async {
|
||||||
final cached = _cachedUser;
|
final user = await _repository.getProfile(forceRefresh: forceRefresh);
|
||||||
if (cached != null) {
|
_cachedUser = user;
|
||||||
return Future<UserResponse>.value(cached);
|
return user;
|
||||||
}
|
|
||||||
|
|
||||||
final inflight = _inflight;
|
|
||||||
if (inflight != null) {
|
|
||||||
return inflight;
|
|
||||||
}
|
|
||||||
|
|
||||||
final generation = _generation;
|
|
||||||
late final Future<UserResponse> request;
|
|
||||||
request = loader()
|
|
||||||
.then((user) {
|
|
||||||
if (generation == _generation) {
|
|
||||||
_cachedUser = user;
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
})
|
|
||||||
.whenComplete(() {
|
|
||||||
if (identical(_inflight, request)) {
|
|
||||||
_inflight = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
_inflight = request;
|
|
||||||
return request;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void set(UserResponse user) {
|
void set(UserResponse user) {
|
||||||
_cachedUser = user;
|
_cachedUser = user;
|
||||||
|
unawaited(_repository.setCached(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
void invalidate() {
|
void invalidate() {
|
||||||
_generation += 1;
|
|
||||||
_cachedUser = null;
|
_cachedUser = null;
|
||||||
_inflight = null;
|
unawaited(_repository.invalidate());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import '../../../../core/cache/cache_entry.dart';
|
||||||
|
import '../../../../core/cache/cache_policy.dart';
|
||||||
|
import '../../../../core/cache/hybrid_cache_store.dart';
|
||||||
|
import '../../../users/data/models/user_response.dart';
|
||||||
|
|
||||||
|
class UserProfileCacheRepository {
|
||||||
|
static const String cacheKey = 'settings:user_profile';
|
||||||
|
|
||||||
|
final HybridCacheStore store;
|
||||||
|
final CachePolicy policy;
|
||||||
|
final DateTime Function() now;
|
||||||
|
final Future<UserResponse> Function() remoteLoader;
|
||||||
|
|
||||||
|
Future<void>? _refreshInFlight;
|
||||||
|
|
||||||
|
UserProfileCacheRepository({
|
||||||
|
required this.store,
|
||||||
|
required this.remoteLoader,
|
||||||
|
CachePolicy? policy,
|
||||||
|
DateTime Function()? now,
|
||||||
|
}) : policy =
|
||||||
|
policy ??
|
||||||
|
const CachePolicy(
|
||||||
|
softTtl: Duration(minutes: 2),
|
||||||
|
hardTtl: Duration(minutes: 30),
|
||||||
|
minRefreshInterval: Duration(minutes: 1),
|
||||||
|
),
|
||||||
|
now = now ?? DateTime.now;
|
||||||
|
|
||||||
|
Future<UserResponse> getProfile({bool forceRefresh = false}) async {
|
||||||
|
if (forceRefresh) {
|
||||||
|
return _refreshAndRead();
|
||||||
|
}
|
||||||
|
|
||||||
|
final cached = await store.read<CacheEntry<UserResponse>>(cacheKey);
|
||||||
|
if (cached == null) {
|
||||||
|
return _refreshAndRead();
|
||||||
|
}
|
||||||
|
|
||||||
|
final decision = policy.evaluate(now: now(), fetchedAt: cached.fetchedAt);
|
||||||
|
if (decision.shouldRefreshInBackground) {
|
||||||
|
_refreshInBackground();
|
||||||
|
}
|
||||||
|
if (decision.mustBlockForNetwork || !decision.canUseCached) {
|
||||||
|
return _refreshAndRead();
|
||||||
|
}
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setCached(UserResponse user) {
|
||||||
|
return store.write<CacheEntry<UserResponse>>(
|
||||||
|
cacheKey,
|
||||||
|
CacheEntry<UserResponse>(value: user, fetchedAt: now()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> invalidate() => store.remove(cacheKey);
|
||||||
|
|
||||||
|
void _refreshInBackground() {
|
||||||
|
final running = _refreshInFlight;
|
||||||
|
if (running != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final task = _refreshAndWrite().whenComplete(() {
|
||||||
|
_refreshInFlight = null;
|
||||||
|
});
|
||||||
|
_refreshInFlight = task;
|
||||||
|
unawaited(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<UserResponse> _refreshAndRead() async {
|
||||||
|
await _refreshAndWrite();
|
||||||
|
final cached = await store.read<CacheEntry<UserResponse>>(cacheKey);
|
||||||
|
return cached!.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshAndWrite() async {
|
||||||
|
final remote = await remoteLoader();
|
||||||
|
await setCached(remote);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,6 @@ import 'package:social_app/features/friends/data/friends_api.dart';
|
|||||||
import 'package:social_app/features/settings/data/settings_api.dart';
|
import 'package:social_app/features/settings/data/settings_api.dart';
|
||||||
import 'package:social_app/features/settings/data/services/settings_user_cache.dart';
|
import 'package:social_app/features/settings/data/services/settings_user_cache.dart';
|
||||||
import 'package:social_app/features/users/data/models/user_response.dart';
|
import 'package:social_app/features/users/data/models/user_response.dart';
|
||||||
import 'package:social_app/features/users/data/users_api.dart';
|
|
||||||
import 'package:social_app/features/home/ui/navigation/home_return_policy.dart';
|
import 'package:social_app/features/home/ui/navigation/home_return_policy.dart';
|
||||||
import '../widgets/settings_page_scaffold.dart';
|
import '../widgets/settings_page_scaffold.dart';
|
||||||
|
|
||||||
@@ -34,7 +33,6 @@ class SettingsScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
final UsersApi _usersApi = sl<UsersApi>();
|
|
||||||
final FriendsApi _friendsApi = sl<FriendsApi>();
|
final FriendsApi _friendsApi = sl<FriendsApi>();
|
||||||
final SettingsUserCache _userCache = sl<SettingsUserCache>();
|
final SettingsUserCache _userCache = sl<SettingsUserCache>();
|
||||||
|
|
||||||
@@ -56,7 +54,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
Future<void> _loadData() async {
|
Future<void> _loadData() async {
|
||||||
try {
|
try {
|
||||||
final user = await _userCache.getOrLoad(_usersApi.getMe);
|
final user = await _userCache.getProfile();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_user = user;
|
_user = user;
|
||||||
|
|||||||
@@ -1,70 +1,69 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:social_app/core/cache/cache_policy.dart';
|
||||||
|
import 'package:social_app/core/cache/hybrid_cache_store.dart';
|
||||||
|
import 'package:social_app/core/cache/memory_cache_store.dart';
|
||||||
|
import 'package:social_app/core/cache/persistent_cache_store.dart';
|
||||||
import 'package:social_app/features/settings/data/services/settings_user_cache.dart';
|
import 'package:social_app/features/settings/data/services/settings_user_cache.dart';
|
||||||
|
import 'package:social_app/features/settings/data/services/user_profile_cache_repository.dart';
|
||||||
import 'package:social_app/features/users/data/models/user_response.dart';
|
import 'package:social_app/features/users/data/models/user_response.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('getOrLoad calls loader only once when cache exists', () async {
|
test('getProfile caches latest user in memory field', () async {
|
||||||
final cache = SettingsUserCache();
|
|
||||||
var loadCalls = 0;
|
var loadCalls = 0;
|
||||||
|
final repository = UserProfileCacheRepository(
|
||||||
|
store: HybridCacheStore(
|
||||||
|
memory: MemoryCacheStore(),
|
||||||
|
persistent: PersistentCacheStore(),
|
||||||
|
),
|
||||||
|
policy: const CachePolicy(
|
||||||
|
softTtl: Duration(minutes: 2),
|
||||||
|
hardTtl: Duration(minutes: 30),
|
||||||
|
minRefreshInterval: Duration(minutes: 1),
|
||||||
|
),
|
||||||
|
remoteLoader: () async {
|
||||||
|
loadCalls += 1;
|
||||||
|
return const UserResponse(id: 'u1', username: 'first');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final cache = SettingsUserCache(repository);
|
||||||
|
|
||||||
Future<UserResponse> loader() async {
|
final first = await cache.getProfile();
|
||||||
loadCalls += 1;
|
final second = await cache.getProfile();
|
||||||
return const UserResponse(id: 'u1', username: 'first');
|
|
||||||
}
|
|
||||||
|
|
||||||
final first = await cache.getOrLoad(loader);
|
|
||||||
final second = await cache.getOrLoad(loader);
|
|
||||||
|
|
||||||
expect(first.username, 'first');
|
expect(first.username, 'first');
|
||||||
expect(second.username, 'first');
|
expect(second.username, 'first');
|
||||||
|
expect(cache.cachedUser?.id, 'u1');
|
||||||
expect(loadCalls, 1);
|
expect(loadCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('invalidate forces next load', () async {
|
test('invalidate clears memory cache', () {
|
||||||
final cache = SettingsUserCache();
|
final repository = UserProfileCacheRepository(
|
||||||
var loadCalls = 0;
|
store: HybridCacheStore(
|
||||||
|
memory: MemoryCacheStore(),
|
||||||
|
persistent: PersistentCacheStore(),
|
||||||
|
),
|
||||||
|
remoteLoader: () async => const UserResponse(id: 'u1', username: 'first'),
|
||||||
|
);
|
||||||
|
final cache = SettingsUserCache(repository);
|
||||||
|
|
||||||
Future<UserResponse> loader() async {
|
cache.set(const UserResponse(id: 'u1', username: 'first'));
|
||||||
loadCalls += 1;
|
|
||||||
return UserResponse(id: 'u$loadCalls', username: 'user$loadCalls');
|
|
||||||
}
|
|
||||||
|
|
||||||
final first = await cache.getOrLoad(loader);
|
|
||||||
cache.invalidate();
|
cache.invalidate();
|
||||||
final second = await cache.getOrLoad(loader);
|
|
||||||
|
|
||||||
expect(first.id, 'u1');
|
expect(cache.cachedUser, isNull);
|
||||||
expect(second.id, 'u2');
|
|
||||||
expect(loadCalls, 2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('set should update cached user immediately', () {
|
||||||
'invalidate blocks stale inflight response from repopulating cache',
|
final repository = UserProfileCacheRepository(
|
||||||
() async {
|
store: HybridCacheStore(
|
||||||
final cache = SettingsUserCache();
|
memory: MemoryCacheStore(),
|
||||||
final completer = Completer<UserResponse>();
|
persistent: PersistentCacheStore(),
|
||||||
var loadCalls = 0;
|
),
|
||||||
|
remoteLoader: () async => const UserResponse(id: 'u1', username: 'first'),
|
||||||
|
);
|
||||||
|
final cache = SettingsUserCache(repository);
|
||||||
|
|
||||||
Future<UserResponse> slowLoader() {
|
cache.set(const UserResponse(id: 'u2', username: 'next'));
|
||||||
loadCalls += 1;
|
|
||||||
return completer.future;
|
|
||||||
}
|
|
||||||
|
|
||||||
final pending = cache.getOrLoad(slowLoader);
|
expect(cache.cachedUser?.id, 'u2');
|
||||||
cache.invalidate();
|
});
|
||||||
completer.complete(const UserResponse(id: 'u1', username: 'stale'));
|
|
||||||
await pending;
|
|
||||||
|
|
||||||
final fresh = await cache.getOrLoad(() async {
|
|
||||||
loadCalls += 1;
|
|
||||||
return const UserResponse(id: 'u2', username: 'fresh');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fresh.id, 'u2');
|
|
||||||
expect(cache.cachedUser?.id, 'u2');
|
|
||||||
expect(loadCalls, 2);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:social_app/core/cache/cache_entry.dart';
|
||||||
|
import 'package:social_app/core/cache/cache_policy.dart';
|
||||||
|
import 'package:social_app/core/cache/hybrid_cache_store.dart';
|
||||||
|
import 'package:social_app/core/cache/memory_cache_store.dart';
|
||||||
|
import 'package:social_app/core/cache/persistent_cache_store.dart';
|
||||||
|
import 'package:social_app/features/settings/data/services/user_profile_cache_repository.dart';
|
||||||
|
import 'package:social_app/features/users/data/models/user_response.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test(
|
||||||
|
'repository should return persistent cache first then refresh in background',
|
||||||
|
() async {
|
||||||
|
final store = HybridCacheStore(
|
||||||
|
memory: MemoryCacheStore(),
|
||||||
|
persistent: PersistentCacheStore(),
|
||||||
|
);
|
||||||
|
const key = UserProfileCacheRepository.cacheKey;
|
||||||
|
final stale = CacheEntry<UserResponse>(
|
||||||
|
value: const UserResponse(id: 'u1', username: 'cached'),
|
||||||
|
fetchedAt: DateTime(2026, 3, 20, 11, 0),
|
||||||
|
);
|
||||||
|
await store.persistent.write<CacheEntry<UserResponse>>(key, stale);
|
||||||
|
|
||||||
|
var refreshCalls = 0;
|
||||||
|
final repository = UserProfileCacheRepository(
|
||||||
|
store: store,
|
||||||
|
now: () => DateTime(2026, 3, 20, 11, 5),
|
||||||
|
policy: const CachePolicy(
|
||||||
|
softTtl: Duration(minutes: 2),
|
||||||
|
hardTtl: Duration(minutes: 30),
|
||||||
|
minRefreshInterval: Duration(minutes: 1),
|
||||||
|
),
|
||||||
|
remoteLoader: () async {
|
||||||
|
refreshCalls += 1;
|
||||||
|
return const UserResponse(id: 'u1', username: 'remote');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await repository.getProfile();
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||||
|
|
||||||
|
expect(result.username, 'cached');
|
||||||
|
expect(refreshCalls, 1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user