smart_repository 0.4.0 copy "smart_repository: ^0.4.0" to clipboard
smart_repository: ^0.4.0 copied to clipboard

Policy-driven coordination for remote and local repository data.

example/lib/main.dart

import 'dart:async';

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

void main() => runApp(const SmartRepositoryExampleApp());

class SmartRepositoryExampleApp extends StatelessWidget {
  const SmartRepositoryExampleApp({super.key, this.store});

  final DemoUserStore? store;

  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'Smart Repository Lab',
    debugShowCheckedModeBanner: false,
    theme: ThemeData(
      colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff386a5a)),
      useMaterial3: true,
      cardTheme: const CardThemeData(
        margin: EdgeInsets.zero,
        clipBehavior: Clip.antiAlias,
      ),
      inputDecorationTheme: const InputDecorationTheme(
        border: OutlineInputBorder(),
      ),
    ),
    home: RepositoryLab(store: store),
  );
}

class RepositoryLab extends StatefulWidget {
  const RepositoryLab({super.key, this.store});

  final DemoUserStore? store;

  @override
  State<RepositoryLab> createState() => _RepositoryLabState();
}

class _RepositoryLabState extends State<RepositoryLab> {
  late final DemoUserStore _store;
  late final MappedRepositoryFamily<int, DemoUser, DemoUserDto, DemoUserBox>
  _users;
  StreamSubscription<RepositoryState<DemoUser>>? _subscription;
  RepositoryPolicy _policy = RepositoryPolicy.staleWhileRevalidate;
  RepositoryState<DemoUser> _state = const RepositoryInitial<DemoUser>();
  RepositorySuccess<DemoUser>? _lastSuccess;
  final List<String> _events = [];
  int _selectedUser = 1;
  bool _working = false;

  @override
  void initState() {
    super.initState();
    _store = widget.store ?? DemoUserStore.seeded();
    _users = MappedRepositoryFamily<int, DemoUser, DemoUserDto, DemoUserBox>(
      remote: _store.loadRemote,
      local: _store.loadLocal,
      saveLocal: _store.saveLocal,
      deleteLocal: _store.deleteLocal,
      mapRemote: (dto) => DemoUser(dto.id, dto.name, dto.email, dto.revision),
      mapLocal: (box) => DemoUser(box.id, box.name, box.email, box.revision),
      mapToLocal: (user) =>
          DemoUserBox(user.id, user.name, user.email, user.revision),
      localTimestamp: _store.localTimestamp,
      fallbackWhen: (_, error) => error is DemoOfflineException,
      observers: [DemoObserver(_addEvent)],
      config: const SmartRepositoryConfig(maxAge: Duration(seconds: 15)),
    );
    _listen();
    scheduleMicrotask(_runPolicy);
  }

  @override
  void dispose() {
    unawaited(_subscription?.cancel());
    unawaited(_users.dispose());
    super.dispose();
  }

  void _listen() {
    unawaited(_subscription?.cancel());
    _subscription = _users.watch(_selectedUser).listen((state) {
      if (mounted) setState(() => _state = state);
    });
  }

  void _addEvent(String event) {
    if (!mounted) return;
    setState(() {
      _events.insert(0, event);
      if (_events.length > 20) _events.removeLast();
    });
  }

  Future<void> _runPolicy() =>
      _run(() => _users.get(_selectedUser, policy: _policy));

  Future<void> _refresh() =>
      _run(() => _users.refresh(_selectedUser, force: true));

  Future<void> _run(
    Future<RepositoryResult<DemoUser>> Function() action,
  ) async {
    if (_working) return;
    setState(() => _working = true);
    final result = await action();
    if (!mounted) return;
    setState(() {
      _working = false;
      _lastSuccess = result is RepositorySuccess<DemoUser> ? result : null;
    });
  }

  Future<void> _clear() async {
    await _users.clear(_selectedUser);
    if (mounted) setState(() => _lastSuccess = null);
  }

  void _selectUser(int? id) {
    if (id == null || id == _selectedUser) return;
    setState(() {
      _selectedUser = id;
      _state = const RepositoryInitial<DemoUser>();
      _lastSuccess = null;
    });
    _listen();
    unawaited(_runPolicy());
  }

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(
      title: const Text('Smart Repository Lab'),
      actions: [
        Padding(
          padding: const EdgeInsets.only(right: 12),
          child: FilterChip(
            avatar: Icon(
              _store.offline ? Icons.cloud_off : Icons.cloud_done,
              size: 18,
            ),
            label: Text(_store.offline ? 'Offline' : 'Online'),
            selected: _store.offline,
            tooltip: 'Toggle simulated network availability',
            onSelected: (value) => setState(() => _store.offline = value),
          ),
        ),
      ],
    ),
    body: SafeArea(
      child: LayoutBuilder(
        builder: (context, constraints) {
          final controls = _ControlsPanel(
            policy: _policy,
            selectedUser: _selectedUser,
            working: _working,
            onPolicyChanged: (value) => setState(() => _policy = value),
            onUserChanged: _selectUser,
            onRun: _runPolicy,
            onRefresh: _refresh,
            onInvalidate: () {
              _users.invalidate(_selectedUser);
              setState(() => _lastSuccess = null);
            },
            onClear: _clear,
          );
          final output = Column(
            children: [
              _ResultCard(state: _state, result: _lastSuccess),
              const SizedBox(height: 16),
              Expanded(child: _EventLog(events: _events)),
            ],
          );
          return Center(
            child: ConstrainedBox(
              constraints: const BoxConstraints(maxWidth: 1120),
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: constraints.maxWidth >= 840
                    ? Row(
                        crossAxisAlignment: CrossAxisAlignment.stretch,
                        children: [
                          SizedBox(width: 350, child: controls),
                          const SizedBox(width: 16),
                          Expanded(child: output),
                        ],
                      )
                    : ListView(
                        children: [
                          controls,
                          const SizedBox(height: 16),
                          SizedBox(height: 520, child: output),
                        ],
                      ),
              ),
            ),
          );
        },
      ),
    ),
  );
}

class _ControlsPanel extends StatelessWidget {
  const _ControlsPanel({
    required this.policy,
    required this.selectedUser,
    required this.working,
    required this.onPolicyChanged,
    required this.onUserChanged,
    required this.onRun,
    required this.onRefresh,
    required this.onInvalidate,
    required this.onClear,
  });

  final RepositoryPolicy policy;
  final int selectedUser;
  final bool working;
  final ValueChanged<RepositoryPolicy> onPolicyChanged;
  final ValueChanged<int?> onUserChanged;
  final VoidCallback onRun;
  final VoidCallback onRefresh;
  final VoidCallback onInvalidate;
  final VoidCallback onClear;

  @override
  Widget build(BuildContext context) => Card(
    child: Padding(
      padding: const EdgeInsets.all(20),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            'Request controls',
            style: Theme.of(context).textTheme.titleLarge,
          ),
          const SizedBox(height: 6),
          const Text('Change key or policy, then watch source coordination.'),
          const SizedBox(height: 20),
          DropdownButtonFormField<int>(
            key: const Key('user-picker'),
            isExpanded: true,
            initialValue: selectedUser,
            decoration: const InputDecoration(
              labelText: 'Repository key',
              prefixIcon: Icon(Icons.person_outline),
            ),
            items: const [
              DropdownMenuItem(value: 1, child: Text('User 1 · Ada')),
              DropdownMenuItem(value: 2, child: Text('User 2 · Linus')),
              DropdownMenuItem(value: 3, child: Text('User 3 · Grace')),
            ],
            onChanged: working ? null : onUserChanged,
          ),
          const SizedBox(height: 16),
          DropdownButtonFormField<RepositoryPolicy>(
            key: const Key('policy-picker'),
            isExpanded: true,
            initialValue: policy,
            decoration: const InputDecoration(
              labelText: 'Repository policy',
              prefixIcon: Icon(Icons.route_outlined),
            ),
            items: RepositoryPolicy.values
                .map(
                  (value) => DropdownMenuItem(
                    value: value,
                    child: Text(_policyLabel(value)),
                  ),
                )
                .toList(),
            onChanged: working
                ? null
                : (value) {
                    if (value != null) onPolicyChanged(value);
                  },
          ),
          const SizedBox(height: 20),
          FilledButton.icon(
            key: const Key('run-policy'),
            onPressed: working ? null : onRun,
            icon: working
                ? const SizedBox.square(
                    dimension: 18,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  )
                : const Icon(Icons.play_arrow),
            label: Text(working ? 'Running…' : 'Run policy'),
          ),
          const SizedBox(height: 10),
          OutlinedButton.icon(
            onPressed: working ? null : onRefresh,
            icon: const Icon(Icons.refresh),
            label: const Text('Force remote refresh'),
          ),
          const Divider(height: 32),
          Wrap(
            spacing: 8,
            children: [
              ActionChip(
                avatar: const Icon(Icons.history_toggle_off, size: 18),
                label: const Text('Invalidate'),
                onPressed: working ? null : onInvalidate,
              ),
              ActionChip(
                avatar: const Icon(Icons.delete_outline, size: 18),
                label: const Text('Clear cache'),
                onPressed: working ? null : onClear,
              ),
            ],
          ),
          const SizedBox(height: 20),
          Container(
            padding: const EdgeInsets.all(12),
            decoration: BoxDecoration(
              color: Theme.of(context).colorScheme.surfaceContainerHighest,
              borderRadius: BorderRadius.circular(12),
            ),
            child: const Text(
              'Tip: go offline after a successful request, then run '
              'networkFirst to see cached fallback.',
            ),
          ),
        ],
      ),
    ),
  );
}

class _ResultCard extends StatelessWidget {
  const _ResultCard({required this.state, required this.result});

  final RepositoryState<DemoUser> state;
  final RepositorySuccess<DemoUser>? result;

  @override
  Widget build(BuildContext context) {
    final user = switch (state) {
      RepositoryData(:final data) ||
      RepositoryRefreshing(:final data) ||
      RepositoryStale(:final data) ||
      RepositoryPersistenceFailure(:final data) => data,
      _ => result?.data,
    };
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Row(
              children: [
                Icon(
                  _stateIcon(state),
                  color: Theme.of(context).colorScheme.primary,
                ),
                const SizedBox(width: 10),
                Expanded(
                  child: Text(
                    'Current repository state',
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                ),
                Chip(label: Text(_stateLabel(state))),
              ],
            ),
            const SizedBox(height: 18),
            if (user == null)
              const Padding(
                padding: EdgeInsets.symmetric(vertical: 22),
                child: Text(
                  'No value yet. Run a policy to load data.',
                  textAlign: TextAlign.center,
                ),
              )
            else
              Row(
                children: [
                  CircleAvatar(
                    radius: 28,
                    child: Text(user.name.substring(0, 1)),
                  ),
                  const SizedBox(width: 16),
                  Expanded(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          user.name,
                          style: Theme.of(context).textTheme.titleLarge,
                        ),
                        Text(user.email),
                        Text('Payload revision ${user.revision}'),
                      ],
                    ),
                  ),
                ],
              ),
            if (result != null) ...[
              const Divider(height: 28),
              Wrap(
                spacing: 8,
                children: [
                  Chip(
                    avatar: Icon(
                      result!.source == RepositorySource.remote
                          ? Icons.cloud_outlined
                          : Icons.storage_outlined,
                    ),
                    label: Text('Source: ${result!.source.name}'),
                  ),
                  Chip(
                    avatar: Icon(
                      result!.isStale ? Icons.warning_amber : Icons.check,
                    ),
                    label: Text(result!.isStale ? 'Stale' : 'Fresh'),
                  ),
                ],
              ),
            ],
          ],
        ),
      ),
    );
  }
}

class _EventLog extends StatelessWidget {
  const _EventLog({required this.events});

  final List<String> events;

  @override
  Widget build(BuildContext context) => Card(
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Padding(
          padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
          child: Text(
            'Observer events',
            style: Theme.of(context).textTheme.titleMedium,
          ),
        ),
        const Divider(height: 1),
        Expanded(
          child: events.isEmpty
              ? const Center(child: Text('Events appear here.'))
              : ListView.separated(
                  padding: const EdgeInsets.all(12),
                  itemCount: events.length,
                  separatorBuilder: (_, _) => const SizedBox(height: 4),
                  itemBuilder: (_, index) => Semantics(
                    label: 'Repository event ${index + 1}',
                    child: Text(
                      events[index],
                      style: const TextStyle(fontFamily: 'monospace'),
                    ),
                  ),
                ),
        ),
      ],
    ),
  );
}

class DemoUser {
  const DemoUser(this.id, this.name, this.email, this.revision);

  final int id;
  final String name;
  final String email;
  final int revision;
}

class DemoUserDto {
  const DemoUserDto(this.id, this.name, this.email, this.revision);

  final int id;
  final String name;
  final String email;
  final int revision;
}

class DemoUserBox {
  const DemoUserBox(this.id, this.name, this.email, this.revision);

  final int id;
  final String name;
  final String email;
  final int revision;
}

class DemoUserStore {
  DemoUserStore({
    Map<int, DemoUserBox>? cache,
    Map<int, DateTime>? timestamps,
    this.remoteDelay = const Duration(milliseconds: 650),
  }) : _cache = cache ?? {},
       _timestamps = timestamps ?? {};

  factory DemoUserStore.seeded({
    Duration remoteDelay = const Duration(milliseconds: 650),
  }) {
    final old = DateTime.now().subtract(const Duration(minutes: 5));
    return DemoUserStore(
      cache: {
        for (final id in [1, 2, 3]) id: _box(id, 0),
      },
      timestamps: {
        for (final id in [1, 2, 3]) id: old,
      },
      remoteDelay: remoteDelay,
    );
  }

  final Map<int, DemoUserBox> _cache;
  final Map<int, DateTime> _timestamps;
  final Map<int, int> _revisions = {};
  final Duration remoteDelay;
  bool offline = false;

  Future<DemoUserDto> loadRemote(int id) async {
    await Future<void>.delayed(remoteDelay);
    if (offline) throw const DemoOfflineException();
    final revision = (_revisions[id] ?? 0) + 1;
    _revisions[id] = revision;
    return _dto(id, revision);
  }

  DemoUserBox? loadLocal(int id) => _cache[id];

  void saveLocal(int id, DemoUserBox user) {
    _cache[id] = user;
    _timestamps[id] = DateTime.now();
  }

  void deleteLocal(int id) {
    _cache.remove(id);
    _timestamps.remove(id);
  }

  DateTime? localTimestamp(int id) => _timestamps[id];

  static DemoUserDto _dto(int id, int revision) {
    final values = _values(id);
    return DemoUserDto(id, values.$1, values.$2, revision);
  }

  static DemoUserBox _box(int id, int revision) {
    final values = _values(id);
    return DemoUserBox(id, values.$1, values.$2, revision);
  }

  static (String, String) _values(int id) {
    const names = {1: 'Ada Lovelace', 2: 'Linus Torvalds', 3: 'Grace Hopper'};
    final name = names[id] ?? 'User $id';
    return (name, '${name.toLowerCase().replaceAll(' ', '.')}@example.dev');
  }
}

class DemoOfflineException implements Exception {
  const DemoOfflineException();

  @override
  String toString() => 'DemoOfflineException: network unavailable';
}

class DemoObserver extends RepositoryObserver<DemoUser> {
  const DemoObserver(this.log);

  final ValueChanged<String> log;
  String _key(Object? key) => 'user[$key]';

  @override
  void onReadStarted(RepositoryPolicy policy, {Object? repositoryKey}) =>
      log('${_key(repositoryKey)} GET ${policy.name}');

  @override
  void onLocalHit(
    DemoUser data,
    CacheFreshness freshness, {
    Object? repositoryKey,
  }) => log('${_key(repositoryKey)} local hit · ${freshness.name}');

  @override
  void onLocalMiss({Object? repositoryKey}) =>
      log('${_key(repositoryKey)} local miss');

  @override
  void onRemoteStarted({Object? repositoryKey}) =>
      log('${_key(repositoryKey)} remote started');

  @override
  void onRemoteSuccess(DemoUser data, {Object? repositoryKey}) =>
      log('${_key(repositoryKey)} remote success · revision ${data.revision}');

  @override
  void onRemoteFailure(
    Object error,
    StackTrace stackTrace, {
    Object? repositoryKey,
  }) => log('${_key(repositoryKey)} remote failed · $error');

  @override
  void onFallback(DemoUser data, Object remoteError, {Object? repositoryKey}) =>
      log('${_key(repositoryKey)} fallback → local cache');

  @override
  void onPersist(DemoUser data, {Object? repositoryKey}) =>
      log('${_key(repositoryKey)} persisted revision ${data.revision}');

  @override
  void onInvalidated({Object? repositoryKey}) =>
      log('${_key(repositoryKey)} invalidated');

  @override
  void onCleared({Object? repositoryKey}) =>
      log('${_key(repositoryKey)} cache cleared');
}

String _policyLabel(RepositoryPolicy policy) => switch (policy) {
  RepositoryPolicy.networkOnly => 'Network only',
  RepositoryPolicy.cacheOnly => 'Cache only',
  RepositoryPolicy.cacheFirst => 'Cache first',
  RepositoryPolicy.networkFirst => 'Network first',
  RepositoryPolicy.staleWhileRevalidate => 'Stale while revalidate',
  RepositoryPolicy.cacheAndNetwork => 'Cache and network',
};

String _stateLabel(RepositoryState<DemoUser> state) => switch (state) {
  RepositoryInitial() => 'Initial',
  RepositoryLoading() => 'Loading',
  RepositoryData(:final source, :final isStale) =>
    '${source.name}${isStale ? ' · stale' : ''}',
  RepositoryRefreshing() => 'Refreshing',
  RepositoryStale() => 'Fallback · stale',
  RepositoryFailureState() => 'Failed',
  RepositoryPersistenceFailure() => 'Persistence failed',
};

IconData _stateIcon(RepositoryState<DemoUser> state) => switch (state) {
  RepositoryInitial() => Icons.hourglass_empty,
  RepositoryLoading() => Icons.downloading,
  RepositoryData() => Icons.check_circle_outline,
  RepositoryRefreshing() => Icons.sync,
  RepositoryStale() => Icons.warning_amber,
  RepositoryFailureState() => Icons.error_outline,
  RepositoryPersistenceFailure() => Icons.save_as_outlined,
};
0
likes
0
points
476
downloads

Publisher

verified publisherpinz.dev

Weekly Downloads

Policy-driven coordination for remote and local repository data.

Repository (GitHub)
View/report issues

License

unknown (license)

More

Packages that depend on smart_repository