ttl_etag_cache 1.0.4
ttl_etag_cache: ^1.0.4 copied to clipboard
A powerful, reactive caching solution for Flutter with TTL, ETag support, and optional AES-256 encryption for offline-first applications.
TTL/ETag Cache #
A powerful, reactive caching solution for Flutter applications with TTL (Time To Live), ETag support, and optional AES-256 encryption. Perfect for building offline-first applications with intelligent data synchronization.
β¨ Features #
- π Reactive Caching - Stream-based architecture with automatic UI updates
- β° TTL Support - Automatic cache expiration based on server headers or custom values
- π ETag Validation - Conditional requests with
If-None-MatchandIf-Modified-Since - π Optional Encryption - AES-256 encryption with secure key storage
- π± Offline-First - Serve stale cache when network is unavailable
- π― Type-Safe - Full generic type support with Dart's type system
- πΎ Persistent Storage - Uses Isar for fast, local database storage
- π Reactive Updates - BroadcastStream notifies all listeners of cache changes
- π¨ Clean Architecture - Repository pattern with separation of concerns
- π Network Optimization - Reduces bandwidth with 304 Not Modified responses
π¦ Installation #
Add this to your package's pubspec.yaml file:
dependencies:
ttl_etag_cache: ^1.0.0
Then run:
flutter pub get
π Quick Start #
Method 1: Interceptor (Easiest - 3 Lines!) #
Perfect for adding caching to existing apps with zero code changes:
import 'package:ttl_etag_cache/ttl_etag_cache.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final dio = Dio();
// Add these 3 lines - that's it!
dio.interceptors.add(
CacheTtlEtagInterceptor(
enableEncryption: true,
defaultTtl: Duration(minutes: 5),
),
);
runApp(MyApp());
}
// Your existing Dio code works unchanged!
final response = await dio.get('https://api.example.com/users');
// β¨ Now automatically cached with TTL and ETag support!
That's it! All your GET requests are now cached. See Interceptor Guide β
Method 2: Repository Pattern (Recommended for New Code) #
For clean architecture with reactive state management:
import 'package:ttl_etag_cache/ttl_etag_cache.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize without encryption
await TtlEtagCache.init();
// OR with encryption enabled
await TtlEtagCache.init(enableEncryption: true);
runApp(MyApp());
}
2. Create a Repository #
import 'package:ttl_etag_cache/ttl_etag_cache.dart';
class UserRepository {
late final CachedTtlEtagRepository<User> _repository;
UserRepository(String userId) {
_repository = CachedTtlEtagRepository<User>(
config: CacheTtlEtagConfig<User>(
url: 'https://api.example.com/users/$userId',
fromJson: (json) => User.fromJson(json),
defaultTtl: Duration(minutes: 5),
),
);
}
Stream<CacheTtlEtagState<User>> get stream => _repository.stream;
Future<void> fetch() => _repository.fetch();
Future<void> refresh() => _repository.refresh();
void dispose() => _repository.dispose();
}
3. Use in Your Widget #
class UserProfileScreen extends StatefulWidget {
final String userId;
const UserProfileScreen({required this.userId});
@override
State<UserProfileScreen> createState() => _UserProfileScreenState();
}
class _UserProfileScreenState extends State<UserProfileScreen> {
late final UserRepository _userRepo;
@override
void initState() {
super.initState();
_userRepo = UserRepository(widget.userId);
}
@override
void dispose() {
_userRepo.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('User Profile'),
actions: [
IconButton(
icon: Icon(Icons.refresh),
onPressed: () => _userRepo.refresh(),
),
],
),
body: StreamBuilder<CacheTtlEtagState<User>>(
stream: _userRepo.stream,
builder: (context, snapshot) {
final state = snapshot.data ?? CacheTtlEtagState<User>();
// Show loading indicator
if (state.isEmpty && state.isLoading) {
return Center(child: CircularProgressIndicator());
}
// Show error screen
if (state.hasError && !state.hasData) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: Colors.red),
SizedBox(height: 16),
Text('Error: ${state.error}'),
SizedBox(height: 16),
ElevatedButton(
onPressed: () => _userRepo.fetch(),
child: Text('Retry'),
),
],
),
);
}
// Show data with loading/stale indicators
if (state.hasData) {
return Stack(
children: [
RefreshIndicator(
onRefresh: () => _userRepo.refresh(),
child: ListView(
children: [
// Stale data indicator
if (state.isStale)
Container(
color: Colors.orange.shade100,
padding: EdgeInsets.all(8),
child: Row(
children: [
Icon(Icons.warning_amber, size: 16),
SizedBox(width: 8),
Text('Data is outdated, refreshing...'),
],
),
),
// User data
ListTile(
leading: CircleAvatar(
backgroundImage: NetworkImage(state.data!.avatarUrl),
),
title: Text(state.data!.name),
subtitle: Text(state.data!.email),
),
// Cache metadata
if (state.timestamp != null)
Padding(
padding: EdgeInsets.all(16),
child: Text(
'Last updated: ${state.timestamp!.toLocal()}',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
],
),
),
// Loading indicator overlay
if (state.isLoading)
Positioned(
top: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(),
),
],
);
}
return Center(child: Text('No data available'));
},
),
);
}
}
Which Approach Should I Use? #
| Feature | Interceptor π | Repository Pattern ποΈ |
|---|---|---|
| Setup Time | 3 lines of code | Moderate refactoring |
| Existing Code | Works as-is β | Requires changes |
| State Management | Manual | Automatic (reactive) |
| UI Updates | Call setState() | StreamBuilder auto-updates |
| Learning Curve | Minimal | Medium |
| Best For | Quick wins, existing apps | Clean architecture, new features |
Quick Decision:
- π Use Interceptor if you want caching NOW with zero refactoring
- ποΈ Use Repository if you're building new features or want reactive streams
- π‘ Use Both! They work together perfectly
β Interceptor Guide | β Repository Guide
π Core Concepts #
Cache State #
The CacheTtlEtagState<T> class contains all information needed by your UI:
class CacheTtlEtagState<T> {
final T? data; // Cached data
final bool isLoading; // Fetch in progress
final bool isStale; // Cache exceeded TTL
final Object? error; // Error if fetch failed
final DateTime? timestamp; // Last update time
final int? ttlSeconds; // Time-to-live
final String? etag; // ETag value
bool get hasData; // Has cached data
bool get hasError; // Has error
bool get isEmpty; // No data, not loading
bool get isExpired; // Cache has expired
Duration? get timeUntilExpiry; // Remaining cache time
}
Cache Lifecycle #
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Fetch Request β
βββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββ
β Check Local Cache β
ββββββββββ¬ββββββββββββββββ
β
βββββββββββββ΄ββββββββββββ
β β
βΌ βΌ
ββββββββββββ ββββββββββββββββ
β Fresh β β Stale/Empty β
β Cache β β Cache β
ββββββ¬ββββββ βββββββββ¬βββββββ
β β
β βΌ
β βββββββββββββββββββ
β β Network Request β
β β with ETag/IMS β
β ββββββββββ¬βββββββββ
β β
β ββββββββββββββ΄βββββββββββββ
β β β
β βΌ βΌ
β ββββββββββββ ββββββββββββββ
β β 304 β β 200 β
β βNot Modified β New Data β
β ββββββ¬ββββββ βββββββ¬βββββββ
β β β
β βΌ βΌ
β ββββββββββββββββ ββββββββββββββββββ
β βUpdate TTL & β β Update Cache β
β β Timestamp β β with New Data β
β ββββββββ¬ββββββββ ββββββββββ¬ββββββββ
β β β
βββββββββββ΄ββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββ
β Emit Update β
β to Streams β
ββββββββββββββββββ
π Encryption #
Enable Encryption #
Enable encryption during initialization:
await TtlEtagCache.init(enableEncryption: true);
How It Works #
- AES-256-CBC encryption algorithm
- Secure key storage using Flutter Secure Storage
- Unique IV for each cache entry
- Transparent encryption/decryption in repositories
- Per-user keys supported (optional)
Migrate Existing Cache #
Convert between encrypted and plain cache:
// Enable encryption on existing plain cache
await TtlEtagCache.migrateEncryption(enableEncryption: true);
// Disable encryption
await TtlEtagCache.migrateEncryption(enableEncryption: false);
Security Best Practices #
// On user logout - clear cache and reset key
await TtlEtagCache.clearAndResetEncryption();
// Per-user encryption
final encryption = EncryptionService();
await encryption.initForUser(userId);
// Delete user's key on logout
await encryption.deleteUserKey(userId);
π― Advanced Usage #
Custom Cache Keys #
final repo = CachedTtlEtagRepository<List<Post>>(
url: 'https://api.example.com/posts',
body: {'category': 'tech', 'limit': 10},
fromJson: (json) => (json as List).map((e) => Post.fromJson(e)).toList(),
getCacheKey: (url, body) {
// Custom key generation
return '$url?category=${body!['category']}&limit=${body['limit']}';
},
);
Extract Data from Response #
final repo = CachedTtlEtagRepository<User>(
url: 'https://api.example.com/user',
fromJson: (json) => User.fromJson(json),
getDataFromResponseData: (responseData) {
// Extract data from nested response
return responseData['data']['user'];
},
);
Custom TTL #
// Use server's cache headers
final repo = CachedTtlEtagRepository<News>(
url: 'https://api.example.com/news',
fromJson: (json) => News.fromJson(json),
// No defaultTtl - uses Cache-Control: max-age or Expires header
);
// Override with custom TTL
final repo = CachedTtlEtagRepository<Weather>(
url: 'https://api.example.com/weather',
fromJson: (json) => Weather.fromJson(json),
defaultTtl: Duration(minutes: 10), // Cache for 10 minutes
);
Force Refresh #
// Force refresh bypassing cache
await repository.refresh();
// OR
await repository.fetch(forceRefresh: true);
POST Requests #
final repo = CachedTtlEtagRepository<SearchResult>(
url: 'https://api.example.com/search',
method: 'POST',
body: {'query': 'flutter', 'page': 1},
fromJson: (json) => SearchResult.fromJson(json),
);
Manual Cache Control #
// Invalidate specific cache
await TtlEtagCache.invalidate<User>(
url: 'https://api.example.com/user/123',
);
// Clear all cache
await TtlEtagCache.clearAll();
// Manual refetch
await TtlEtagCache.refetch<User>(
url: 'https://api.example.com/user/123',
fromJson: (json) => User.fromJson(json),
forceRefresh: true,
);
Cache Statistics #
final cache = ReactiveCacheDio();
final stats = await cache.getStatistics();
print('Total entries: ${stats.totalEntries}');
print('Encrypted: ${stats.encryptedEntries}');
print('Plain: ${stats.plainEntries}');
print('Stale: ${stats.staleEntries}');
print('Expired: ${stats.expiredEntries}');
π Combining Multiple Repositories #
Parallel Data Loading #
import 'package:rxdart/rxdart.dart';
class DashboardRepository {
final userRepo = CachedTtlEtagRepository<User>(/*...*/);
final postsRepo = CachedTtlEtagRepository<List<Post>>(/*...*/);
final statsRepo = CachedTtlEtagRepository<Stats>(/*...*/);
Stream<DashboardData> get combinedStream {
return Rx.combineLatest3(
userRepo.stream,
postsRepo.stream,
statsRepo.stream,
(userState, postsState, statsState) {
return DashboardData(
user: userState.data,
posts: postsState.data,
stats: statsState.data,
isLoading: userState.isLoading ||
postsState.isLoading ||
statsState.isLoading,
isStale: userState.isStale ||
postsState.isStale ||
statsState.isStale,
);
},
);
}
Future<void> refreshAll() {
return Future.wait([
userRepo.refresh(),
postsRepo.refresh(),
statsRepo.refresh(),
]);
}
void dispose() {
userRepo.dispose();
postsRepo.dispose();
statsRepo.dispose();
}
}
Dependent Data Loading #
class UserPostsRepository {
final userRepo = CachedTtlEtagRepository<User>(/*...*/);
late CachedTtlEtagRepository<List<Post>> postsRepo;
Stream<CombinedState> get stream {
return userRepo.stream.switchMap((userState) {
if (userState.hasData) {
postsRepo = CachedTtlEtagRepository<List<Post>>(
url: 'https://api.example.com/users/${userState.data!.id}/posts',
fromJson: (json) => (json as List).map((e) => Post.fromJson(e)).toList(),
);
return postsRepo.stream.map((postsState) {
return CombinedState(
user: userState.data,
posts: postsState.data,
isLoading: userState.isLoading || postsState.isLoading,
);
});
}
return Stream.value(CombinedState(user: userState.data));
});
}
}
π§ͺ Testing #
Mock the Cache #
class MockReactiveCacheDio extends Mock implements ReactiveCacheDio {}
void main() {
group('UserRepository', () {
late MockReactiveCacheDio mockCache;
late UserRepository repository;
setUp(() {
mockCache = MockReactiveCacheDio();
repository = UserRepository(cache: mockCache);
});
test('fetch should load user data', () async {
when(() => mockCache.fetchReactive<User>(
url: any(named: 'url'),
fromJson: any(named: 'fromJson'),
)).thenAnswer((_) async {});
await repository.fetch();
verify(() => mockCache.fetchReactive<User>(
url: any(named: 'url'),
fromJson: any(named: 'fromJson'),
)).called(1);
});
});
}
π οΈ Configuration #
Custom Dio Instance #
final customDio = Dio(
BaseOptions(
connectTimeout: Duration(seconds: 30),
receiveTimeout: Duration(seconds: 30),
headers: {
'Authorization': 'Bearer $token',
'Accept': 'application/json',
},
),
);
customDio.interceptors.add(LogInterceptor());
await TtlEtagCache.init(
dio: customDio,
enableEncryption: true,
);
Environment-Based Configuration #
class CacheConfig {
static Future<void> initialize() async {
// Enable encryption only in production
final enableEncryption = kReleaseMode;
await TtlEtagCache.init(
enableEncryption: enableEncryption,
);
print('Cache initialized with encryption: $enableEncryption');
}
}
π Performance Considerations #
Best Practices #
-
Choose appropriate TTL values
// Frequently changing data - short TTL defaultTtl: Duration(minutes: 1) // Stable data - longer TTL defaultTtl: Duration(hours: 24) -
Dispose repositories when no longer needed
@override void dispose() { repository.dispose(); super.dispose(); } -
Use conditional requests for bandwidth optimization
- The cache automatically uses
If-None-Match(ETag) andIf-Modified-Sinceheaders - Server should respond with
304 Not Modifiedwhen possible
- The cache automatically uses
-
Enable encryption selectively
- Only enable encryption for sensitive data
- Plain cache is faster but less secure
-
Clean up duplicates after migration
final cache = ReactiveCacheDio(); await cache.cleanupDuplicates();
π Troubleshooting #
Common Issues #
1. "EncryptionService not initialized"
// Solution: Initialize cache with encryption enabled
await TtlEtagCache.init(enableEncryption: true);
2. "Cache is encrypted but encryption is not enabled"
// Solution: Either enable encryption or migrate to plain cache
await TtlEtagCache.migrateEncryption(enableEncryption: false);
3. Data not updating
// Solution: Check TTL and force refresh if needed
await repository.refresh();
4. Memory leaks
// Solution: Always dispose repositories
@override
void dispose() {
repository.dispose();
super.dispose();
}
π API Reference #
TtlEtagCache #
Main entry point for cache operations.
| Method | Description |
|---|---|
init({dio, enableEncryption}) |
Initialize the cache system |
refetch<T>({...}) |
Fetch data with caching |
invalidate<T>({url, body}) |
Delete specific cache entry |
clearAll() |
Clear all cached data |
clearAndResetEncryption() |
Clear cache and reset encryption key |
migrateEncryption({enableEncryption}) |
Migrate between encryption modes |
isEncryptionEnabled |
Check if encryption is enabled |
CachedTtlEtagRepository #
Repository for accessing cached data.
| Property/Method | Description |
|---|---|
stream |
Stream of state updates |
state |
Current state snapshot |
fetch({forceRefresh}) |
Fetch data from network |
refresh() |
Force refresh from network |
invalidate() |
Delete cache entry |
dispose() |
Clean up resources |
CacheTtlEtagState #
State container for cached data.
| Property | Description |
|---|---|
data |
Cached data of type T |
isLoading |
Fetch in progress |
isStale |
Cache exceeded TTL |
error |
Error if fetch failed |
timestamp |
Last update time |
ttlSeconds |
Time-to-live |
etag |
ETag value |
hasData |
Has cached data |
hasError |
Has error |
isEmpty |
No data, not loading |
isExpired |
Cache has expired |
timeUntilExpiry |
Remaining cache time |
π€ Contributing #
Contributions are welcome! Please read our contributing guidelines and submit pull requests to our repository.
π License #
This project is licensed under the MIT License - see the LICENSE file for details.
π Acknowledgments #
- Built with Isar Database
- Encryption powered by encrypt
- HTTP client using Dio
- Reactive streams with RxDart
π§ Support #
For issues, questions, or suggestions, please open an issue on our GitHub repository.
Made with β€οΈ by Loic NGOU