firewatch
Lightweight Firestore repositories for Flutter.
- π Auth-reactive: attach/detach on UID changes via any
ValueListenable<String?> - β‘ Instant UI: primes from local cache; then streams live updates
- πͺΆ Small surface area: single-doc, collection, and collection-group repos
- π Live window pagination: grow with
loadMore(), reset viaresetPages() - π§© No auth lock-in: bring your own auth listenable
Install
dependencies:
firewatch:
Quick start
Firewatch repositories are built to work seamlessly with watch_it.
Hereβs the minimal flow: Model β Repository β UI.
1. Define your model
Firestore-backed models must implement JsonModel so Firewatch can inject the
document ID.
class UserProfile implements JsonModel {
@override
final String id;
final String name;
UserProfile({required this.id, required this.name});
factory UserProfile.fromJson(Map<String, dynamic> json) => UserProfile(
id: json['id'] as String,
name: json['name'] as String? ?? 'Anonymous',
);
@override
Map<String, dynamic> toJson() => {'name': name};
}
2. Create your repositories
Repositories bind models to Firestore. Provide authUid
(a ValueListenable<String?>) so Firewatch knows which document/collection to
read.
final authUid = ValueNotifier<String?>(null); // wire this to your auth layer
class UserProfileRepository extends FirestoreDocRepository<UserProfile> {
UserProfileRepository()
: super(
fromJson: UserProfile.fromJson,
docRefBuilder: (fs, uid) => fs.doc('users/$uid'),
authUid: authUid,
);
}
class FriendsRepository extends FirestoreCollectionRepository<UserProfile> {
FriendsRepository()
: super(
fromJson: UserProfile.fromJson,
colRefBuilder: (fs, uid) => fs.collection('users/$uid/friends'),
authUid: authUid,
);
}
/// Query across ALL "friends" subcollections regardless of parent.
class AllFriendsRepository
extends FirestoreCollectionGroupRepository<UserProfile> {
AllFriendsRepository()
: super(
fromJson: UserProfile.fromJson,
queryRefBuilder: (fs, uid) => fs.collectionGroup('friends'),
authUid: authUid,
);
}
3. Consume in the UI with watch_it
Because repositories are ValueNotifiers, you can watch them directly in your widgets.
class ProfileCard extends StatelessWidget {
const ProfileCard({super.key});
@override
Widget build(BuildContext context) {
final profile = watchIt<UserProfileRepository>().value;
final friends = watchIt<FriendsRepository>().value;
if (profile == null) {
return const Center(child: CircularProgressIndicator());
}
return Card(
child: ListTile(
title: Text('User: ${profile.name}'),
subtitle: Text('Friends: ${friends.length}'),
),
);
}
}
π For a full runnable demo (with auth wiring and fake Firestore), check out the example/ app in this repo.
Parent ID injection
All repositories automatically inject parentId into the data map before
calling fromJson. This is the document ID of the parent document in the
Firestore path hierarchy. Models can opt-in by declaring a parentId field β
no changes to JsonModel required.
This is especially useful for collection group queries, where two documents
can share the same id but live under different parents
(users/u1/tasks/t1 vs projects/p1/tasks/t1).
class Task implements JsonModel {
@override
final String id;
final String title;
final String? parentId; // opt-in β injected automatically
Task({required this.id, required this.title, this.parentId});
factory Task.fromJson(Map<String, dynamic> json) => Task(
id: json['id'] as String,
title: json['title'] as String? ?? '',
parentId: json['parentId'] as String?,
);
@override
Map<String, dynamic> toJson() => {'title': title};
}
For a document at users/u1/tasks/t1, parentId will be 'u1'.
For a top-level document like users/u1, parentId will be null.
Bring your own Auth
Firewatch accepts any ValueListenable<String?> that yields the current user UID. Update it on sign-in/out and the repos will re-attach.
authUid.value = 'abc123'; // sign in
authUid.value = null; // sign out
Write API
All repos expose command_it async
commands with observable isRunning/errors state:
profileRepo.write(UserProfile(id: 'abc123', displayName: 'Marty'));
profileRepo.patch({'bio': 'Hello'});
friendsRepo.add({'displayName': 'Alice'});
friendsRepo.delete(id!);
// Collection-group writes use full document paths (no .add()):
allFriendsRepo.set((path: 'users/abc123/friends/f1', model: friend));
allFriendsRepo.patch((path: 'users/abc123/friends/f1', data: {'name': 'Bob'}));
allFriendsRepo.delete('users/abc123/friends/f1');
Direct writes (concurrent-safe)
Commands are single-execution β a second call while the first is in-flight is
silently dropped. When you need to rapidly write to different documents in
the same collection (e.g. toggling checkboxes), use the *Direct methods:
// These return Futures and can overlap safely:
await friendsRepo.patchDirect((id: 'f1', data: {'checked': true}));
await friendsRepo.patchDirect((id: 'f2', data: {'checked': false}));
// Also available: addDirect, setDirect, updateDirect, deleteDirect
Error handling
All repositories accept an optional onError callback for subscription and
fetch errors:
final repo = FirestoreCollectionRepository<Item>(
// ...
onError: (error, stackTrace) {
Sentry.captureException(error, stackTrace: stackTrace);
// or show a toast, retry, etc.
},
);
| Error source | How it surfaces |
|---|---|
| Snapshot listener / one-shot fetch | onError callback |
| Command-based writes | command.errors ValueNotifier |
Direct writes (*Direct) |
Exception on the returned Future |
| Auth guard (no signed-in user) | Synchronous StateError |
UI State
isLoading: true while fetching/refreshinghasInitialized(collections): first load completedhasMore(collections): whetherloadMore()can grow the windownotifierFor(docId): get a pre-soakedValueNotifier<T?>for a specific item (keyed by doc path for collection groups)
Documentation
License
MIT - See LICENSE
Libraries
- firewatch
- Firewatch β opinionated Firestore repositories for responsive UIs.