ttl_etag_cache 1.0.4 copy "ttl_etag_cache: ^1.0.4" to clipboard
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 #

Flutter Dart Version License

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-Match and If-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 β†’


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 #

  1. Choose appropriate TTL values

    // Frequently changing data - short TTL
    defaultTtl: Duration(minutes: 1)
       
    // Stable data - longer TTL
    defaultTtl: Duration(hours: 24)
    
  2. Dispose repositories when no longer needed

    @override
    void dispose() {
      repository.dispose();
      super.dispose();
    }
    
  3. Use conditional requests for bandwidth optimization

    • The cache automatically uses If-None-Match (ETag) and If-Modified-Since headers
    • Server should respond with 304 Not Modified when possible
  4. Enable encryption selectively

    • Only enable encryption for sensitive data
    • Plain cache is faster but less secure
  5. 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 #

πŸ“§ Support #

For issues, questions, or suggestions, please open an issue on our GitHub repository.


Made with ❀️ by Loic NGOU

1
likes
160
points
21
downloads

Publisher

unverified uploader

Weekly Downloads

A powerful, reactive caching solution for Flutter with TTL, ETag support, and optional AES-256 encryption for offline-first applications.

Repository (GitHub)
View/report issues

Documentation

API reference

License

MIT (license)

Dependencies

crypto, dio, encrypt, flutter, flutter_secure_storage, isar_community, isar_community_flutter_libs, path_provider, rxdart

More

Packages that depend on ttl_etag_cache

Packages that implement ttl_etag_cache