smart_repository 0.7.0
smart_repository: ^0.7.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,
};