repository_kit 0.1.3 copy "repository_kit: ^0.1.3" to clipboard
repository_kit: ^0.1.3 copied to clipboard

A pure Dart toolkit for implementing clean, offline-first repositories by owning cache-vs-remote strategy decisions.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:repository_kit/repository_kit.dart';

// -----------------------------------------------------------------------------
// 1. Models and Services (Infrastructure)
// -----------------------------------------------------------------------------
class User {
  const User({required this.id, required this.name, required this.email});
  final String id;
  final String name;
  final String email;
}

class UserDao {
  final Map<String, User> _db = {};

  Future<User?> findUser(String id) async {
    await Future<void>.delayed(const Duration(milliseconds: 400));
    return _db[id];
  }

  Future<void> saveUser(User user) async {
    await Future<void>.delayed(const Duration(milliseconds: 200));
    _db[user.id] = user;
  }

  void clear() {
    _db.clear();
  }
}

class UserApi {
  int _requestCount = 0;
  bool _shouldFail = false;

  void setShouldFail(bool value) {
    _shouldFail = value;
    _requestCount = 0;
  }

  Future<Map<String, dynamic>> getUser(String id) async {
    _requestCount++;
    // 800ms — slower than UserDao (400ms), so local wins the parallel race
    // on the first call. On subsequent calls, local has cached data and wins again.
    // To see API win, reduce this delay below UserDao.findUser's 400ms delay.
    await Future<void>.delayed(const Duration(milliseconds: 800));

    if (_shouldFail) {
      // Exponential retry will attempt to recover from this
      if (_requestCount < 3) {
        throw Exception('Server Timeout (Attempt $_requestCount of 3)');
      }
    }

    return {
      'id': id,
      'name': 'Jane Doe',
      'email': 'jane@example.com',
    };
  }
}

// -----------------------------------------------------------------------------
// 2. Repository Definition
// -----------------------------------------------------------------------------

// Standard usage: extends RKitRepository directly.
class UserRepository extends RKitRepository<User, Map<String, dynamic>> {
  UserRepository({
    required UserDao db,
    required UserApi api,
    required this.userId,
    required RKitCachePolicy cachePolicy,
    required RKitRetryPolicy retryPolicy,
  })  : _db = db,
        _api = api,
        _cachePolicy = cachePolicy,
        _retryPolicy = retryPolicy;

  final UserDao _db;
  final UserApi _api;
  final String userId;
  final RKitCachePolicy _cachePolicy;
  final RKitRetryPolicy _retryPolicy;

  @override
  RKitCachePolicy get cachePolicy => _cachePolicy;

  @override
  RKitRetryPolicy get retryPolicy => _retryPolicy;

  @override
  Future<User?> load() => _db.findUser(userId);

  @override
  Future<Map<String, dynamic>> fetch() => _api.getUser(userId);

  @override
  Future<User> persist(Map<String, dynamic> response) async {
    final user = User(
      id: response['id'] as String,
      name: response['name'] as String,
      email: response['email'] as String,
    );
    await _db.saveUser(user);
    return user;
  }
}

// Mixin usage: when you already have a base class you cannot abandon,
// mix RKitRepository in instead of extending it.
abstract class BaseRepository {
  String get tag => runtimeType.toString();
}

class PostRepository extends BaseRepository
    with RKitRepository<User, Map<String, dynamic>> {
  PostRepository(this._api);
  final UserApi _api;

  @override
  Future<User?> load() async => null; // no local cache for posts

  @override
  Future<Map<String, dynamic>> fetch() => _api.getUser('post-42');

  @override
  Future<User> persist(Map<String, dynamic> response) async => User(
        id: response['id'] as String,
        name: response['name'] as String,
        email: response['email'] as String,
      );

  @override
  RKitCachePolicy get cachePolicy => RKitCachePolicy.never;
}

// -----------------------------------------------------------------------------
// 3. Presentation Layer
// -----------------------------------------------------------------------------
void main() {
  runApp(const RepositoryKitExampleApp());
}

class RepositoryKitExampleApp extends StatelessWidget {
  const RepositoryKitExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Repository Kit Example',
      theme: ThemeData.dark().copyWith(
        scaffoldBackgroundColor: const Color(0xFF0F172A), // Deep Slate
        colorScheme: const ColorScheme.dark(
          primary: Color(0xFF6366F1), // Indigo Accent
          surface: Color(0xFF1E293B), // Card Slate
        ),
      ),
      home: const DashboardScreen(),
    );
  }
}

class DashboardScreen extends StatefulWidget {
  const DashboardScreen({super.key});

  @override
  State<DashboardScreen> createState() => _DashboardScreenState();
}

class _DashboardScreenState extends State<DashboardScreen> {
  final UserDao _db = UserDao();
  final UserApi _api = UserApi();

  RKitCachePolicy _cachePolicy = RKitCachePolicy.always;
  final RKitCachePolicy _staleIfPolicy = RKitCachePolicy.staleIf<User>((user) {
    // If the name is already correct, say it is NOT stale (skip API fetch)
    return user.name != 'Jane Doe';
  });
  bool _useRetryPolicy = true;
  bool _simulateNetworkError = false;

  Stream<RKitState<User>>? _repositoryStream;
  final List<String> _consoleLogs = [];
  String _activePolicyName = 'always';

  void _runWatch() {
    setState(() {
      _consoleLogs.clear();
      _api.setShouldFail(_simulateNetworkError);

      // Capture human-readable name for log context.
      if (_cachePolicy == RKitCachePolicy.always) {
        _activePolicyName = 'always (parallel)';
      } else if (_cachePolicy == RKitCachePolicy.ifEmpty) {
        _activePolicyName = 'ifEmpty (sequential)';
      } else if (_cachePolicy == RKitCachePolicy.never) {
        _activePolicyName = 'never (local only)';
      } else {
        _activePolicyName = 'staleIf (sequential)';
      }

      final retryPolicy = _useRetryPolicy
          ? RKitRetryPolicy.exponential(
              maxAttempts: 3,
              initialDelay: const Duration(milliseconds: 300),
            )
          : RKitRetryPolicy.none;

      final repository = UserRepository(
        db: _db,
        api: _api,
        userId: 'user_456',
        cachePolicy: _cachePolicy,
        retryPolicy: retryPolicy,
      );

      // Listen to the stream to print to our visual logs
      _repositoryStream = repository.watch().asBroadcastStream();
      _repositoryStream!.listen(
        (state) {
          setState(() {
            _consoleLogs.add(_getStateLog(state));
          });
        },
        onError: (Object error) {
          setState(() {
            _consoleLogs.add('[Stream Error] $error');
          });
        },
      );
    });
  }

  void _clearDatabase() {
    _db.clear();
    setState(() {
      _consoleLogs.add('[DB] Local database cleared');
      _repositoryStream = null;
    });
  }

  String _getStateLog(RKitState<User> state) {
    final isParallel = _cachePolicy == RKitCachePolicy.always;
    return switch (state) {
      RKitLoading() => isParallel
          ? '[policy: $_activePolicyName] Loading: load() and fetch() started in parallel'
          : '[policy: $_activePolicyName] Loading: load() started, fetch() conditional on result',
      RKitCache(:final data) => isParallel
          ? '[RKitCache] local won the race (User: ${data.name}) — fetch() still in flight'
          : '[RKitCache] load() returned stale data (User: ${data.name}) — fetch() starting',
      RKitSuccess(:final data) =>
        '[RKitSuccess] fresh data persisted (User: ${data.name})',
      RKitFailure(:final error, :final data) =>
        '[RKitFailure] error="$error", staleData=${data?.name ?? "null"}',
    };
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Repository Kit Dashboard'),
        backgroundColor: const Color(0xFF1E293B),
        elevation: 0,
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // Controls Card
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      'Configuration Settings',
                      style: Theme.of(context).textTheme.titleMedium?.copyWith(
                            color: const Color(0xFF818CF8),
                            fontWeight: FontWeight.bold,
                          ),
                    ),
                    const SizedBox(height: 12),
                    // Cache Policy Selector
                    DropdownButtonFormField<RKitCachePolicy>(
                      initialValue: _cachePolicy,
                      decoration: const InputDecoration(
                        labelText: 'Cache Policy',
                        border: OutlineInputBorder(),
                      ),
                      items: [
                        const DropdownMenuItem(
                          value: RKitCachePolicy.always,
                          child: Text('always (Fetch every time)'),
                        ),
                        const DropdownMenuItem(
                          value: RKitCachePolicy.ifEmpty,
                          child: Text('ifEmpty (Only fetch if cache empty)'),
                        ),
                        const DropdownMenuItem(
                          value: RKitCachePolicy.never,
                          child: Text('never (Serve from cache only)'),
                        ),
                        DropdownMenuItem(
                          value: _staleIfPolicy,
                          child: const Text(
                              'staleIf (Fetch only if name NOT Jane)'),
                        ),
                      ],
                      onChanged: (val) {
                        if (val != null) {
                          setState(() => _cachePolicy = val);
                        }
                      },
                    ),
                    const SizedBox(height: 8),
                    // Checkboxes
                    SwitchListTile(
                      title: const Text('Exponential Retry (3 Attempts)'),
                      subtitle:
                          const Text('Recover from simulated network issues'),
                      value: _useRetryPolicy,
                      onChanged: (val) => setState(() => _useRetryPolicy = val),
                    ),
                    SwitchListTile(
                      title: const Text('Simulate Network Timeout'),
                      subtitle:
                          const Text('First 2 attempts fail. Succeeds on 3rd.'),
                      value: _simulateNetworkError,
                      onChanged: (val) =>
                          setState(() => _simulateNetworkError = val),
                    ),
                    const SizedBox(height: 12),
                    // Action Buttons
                    Row(
                      children: [
                        Expanded(
                          child: ElevatedButton.icon(
                            icon: const Icon(Icons.refresh),
                            label: const Text('Watch Repository'),
                            style: ElevatedButton.styleFrom(
                              padding: const EdgeInsets.symmetric(vertical: 12),
                            ),
                            onPressed: _runWatch,
                          ),
                        ),
                        const SizedBox(width: 8),
                        OutlinedButton.icon(
                          icon: const Icon(Icons.delete_sweep),
                          label: const Text('Clear DB'),
                          onPressed: _clearDatabase,
                        ),
                      ],
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 16),

            // Live Output UI State
            Text(
              'UI State Result',
              style: Theme.of(context).textTheme.titleSmall,
            ),
            const SizedBox(height: 8),
            Expanded(
              flex: 2,
              child: Container(
                decoration: BoxDecoration(
                  color: const Color(0xFF1E293B),
                  borderRadius: BorderRadius.circular(8),
                ),
                alignment: Alignment.center,
                padding: const EdgeInsets.all(16),
                child: _repositoryStream == null
                    ? const Text('Click "Watch Repository" to start stream')
                    : StreamBuilder<RKitState<User>>(
                        stream: _repositoryStream,
                        builder: (context, snapshot) {
                          if (!snapshot.hasData) {
                            return const CircularProgressIndicator();
                          }
                          final state = snapshot.data!;
                          return _buildStateCard(context, state);
                        },
                      ),
              ),
            ),
            const SizedBox(height: 16),

            // Console Logs Console
            Text(
              'Stream Lifecycle Log Output',
              style: Theme.of(context).textTheme.titleSmall,
            ),
            const SizedBox(height: 8),
            Expanded(
              flex: 3,
              child: Container(
                decoration: BoxDecoration(
                  color: Colors.black,
                  borderRadius: BorderRadius.circular(8),
                  border: Border.all(color: Colors.white10),
                ),
                padding: const EdgeInsets.all(12),
                child: _consoleLogs.isEmpty
                    ? const Text(
                        'No emissions yet...',
                        style: TextStyle(
                            color: Colors.grey, fontFamily: 'monospace'),
                      )
                    : ListView.builder(
                        itemCount: _consoleLogs.length,
                        itemBuilder: (context, index) {
                          return Padding(
                            padding: const EdgeInsets.symmetric(vertical: 2.0),
                            child: Text(
                              _consoleLogs[index],
                              style: const TextStyle(
                                color:
                                    Color(0xFF34D399), // Emerald terminal green
                                fontFamily: 'monospace',
                                fontSize: 13,
                              ),
                            ),
                          );
                        },
                      ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildStateCard(BuildContext context, RKitState<User> state) {
    return switch (state) {
      RKitLoading() => const Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            CircularProgressIndicator(),
            SizedBox(height: 12),
            Text('Starting Repository Fetch...'),
          ],
        ),
      RKitCache(:final data) => _UserCardView(
          user: data,
          statusText: 'Serving Stale Cache Data...',
          borderColor: Colors.amber,
          badgeColor: Colors.amber.withValues(alpha: 0.2),
          badgeText: 'STALE CACHE',
        ),
      RKitSuccess(:final data) => _UserCardView(
          user: data,
          statusText: 'Data Fetched & Synced Successfully',
          borderColor: Colors.green,
          badgeColor: Colors.green.withValues(alpha: 0.2),
          badgeText: 'SYNCHRONIZED',
        ),
      RKitFailure(:final error, :final data) => data == null
          ? Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Icon(Icons.error_outline, color: Colors.red, size: 48),
                const SizedBox(height: 12),
                Text(
                  'Operation Failed',
                  style: Theme.of(context)
                      .textTheme
                      .titleMedium
                      ?.copyWith(color: Colors.red),
                ),
                const SizedBox(height: 8),
                Text(
                  error.toString(),
                  textAlign: TextAlign.center,
                  style: const TextStyle(color: Colors.grey, fontSize: 12),
                ),
              ],
            )
          : _UserCardView(
              user: data,
              statusText: 'Fetch Failed: Showing Stale Backup Data',
              borderColor: Colors.redAccent,
              badgeColor: Colors.red.withValues(alpha: 0.2),
              badgeText: 'ERROR FALLBACK',
            ),
    };
  }
}

class _UserCardView extends StatelessWidget {
  const _UserCardView({
    required this.user,
    required this.statusText,
    required this.borderColor,
    required this.badgeColor,
    required this.badgeText,
  });

  final User user;
  final String statusText;
  final Color borderColor;
  final Color badgeColor;
  final String badgeText;

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        color: const Color(0xFF1E293B),
        borderRadius: BorderRadius.circular(8),
        border:
            Border.all(color: borderColor.withValues(alpha: 0.5), width: 1.5),
      ),
      padding: const EdgeInsets.all(16),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text(
                'USER INFORMATION',
                style: TextStyle(
                  color: Colors.grey[400],
                  fontSize: 11,
                  fontWeight: FontWeight.bold,
                ),
              ),
              Container(
                padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                decoration: BoxDecoration(
                  color: badgeColor,
                  borderRadius: BorderRadius.circular(4),
                ),
                child: Text(
                  badgeText,
                  style: TextStyle(
                    color: borderColor,
                    fontSize: 10,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            ],
          ),
          const SizedBox(height: 16),
          Text(
            user.name,
            style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
          ),
          const SizedBox(height: 4),
          Text(
            user.email,
            style: TextStyle(color: Colors.grey[400], fontSize: 14),
          ),
          const SizedBox(height: 16),
          Divider(color: Colors.white10, height: 1),
          const SizedBox(height: 12),
          Text(
            statusText,
            textAlign: TextAlign.center,
            style: TextStyle(
              color: borderColor,
              fontSize: 12,
              fontStyle: FontStyle.italic,
            ),
          ),
        ],
      ),
    );
  }
}
0
likes
160
points
18
downloads

Documentation

API reference

Publisher

verified publishershakyapurna.com.np

Weekly Downloads

A pure Dart toolkit for implementing clean, offline-first repositories by owning cache-vs-remote strategy decisions.

Repository (GitHub)
View/report issues

Topics

#repository #architecture #offline-first #cache #stream

License

MIT (license)

More

Packages that depend on repository_kit