Firebase7 min read
Collaborative Travel Planner in Flutter
Build a collaborative travel planner in Flutter with Firestore real-time sync: shared itineraries, packing checklists, split expenses and offline support.
- #flutter
- #firestore
- #firebase
- #real-time
- #travel-app

Planning a group trip usually means a spreadsheet for the itinerary, a group chat for decisions, and a notes app for the packing list — and none of them agree with each other. Someone books a flight the spreadsheet does not know about, the checklist lives on one person's phone, and the split expenses get settled weeks later from memory. That is the problem Turnie was built to solve: one collaborative app that centralizes itineraries, split expenses and packing checklists, so the whole group works from the same plan. I built Turnie in Flutter with Firebase as the backend, and implemented real-time collaboration using Firestore. More than 5,000 trips have been planned with it. This guide walks through the patterns that make a shared trip planner work in Flutter: a data model built for multiple editors, real-time listeners instead of manual refreshes, conflict-safe checklists and expense splits, offline support, and security rules that keep each trip private to its members.
Model the trip as shared documents
A collaborative app starts with a data model that expects more than one writer. The natural shape in Firestore is one document per trip, with subcollections for the things that grow independently: itinerary days, checklist items and expenses. The trip document itself holds the fields every screen needs at a glance — the destination, the dates and the list of member user IDs:
class Trip {
const Trip({
required this.id,
required this.name,
required this.destination,
required this.startDate,
required this.endDate,
this.memberIds = const [],
});
final String id;
final String name;
final String destination;
final DateTime startDate;
final DateTime endDate;
final List<String> memberIds;
factory Trip.fromJson(String id, Map<String, dynamic> json) {
return Trip(
id: id,
name: json['name'] as String,
destination: json['destination'] as String,
startDate: (json['startDate'] as Timestamp).toDate(),
endDate: (json['endDate'] as Timestamp).toDate(),
memberIds: List<String>.from(json['memberIds'] as List? ?? []),
);
}
Map<String, dynamic> toJson() => {
'name': name,
'destination': destination,
'startDate': Timestamp.fromDate(startDate),
'endDate': Timestamp.fromDate(endDate),
'memberIds': memberIds,
};
}
Keep each checklist item and each expense as its own document in a subcollection, not as a list field on the trip. Two people editing the same list field overwrite each other; two people editing different documents never collide. That one decision removes most of the sync bugs a collaborative app can have.
Listen, do not fetch
The defining feature of a collaborative app is that changes appear without anyone pressing refresh. In Firestore that means listening to a query with snapshots() and rebuilding the UI from the stream, instead of calling get() once and showing a stale copy:
Stream<List<Trip>> watchTrips(String userId) {
return FirebaseFirestore.instance
.collection('trips')
.where('memberIds', arrayContains: userId)
.orderBy('startDate')
.snapshots()
.map((snapshot) => snapshot.docs
.map((doc) => Trip.fromJson(doc.id, doc.data()))
.toList());
}
Wire that stream into a StreamBuilder and the screen updates the moment a travel companion adds a flight or checks off a bag. Two details matter. First, show pending writes: snapshot.metadata.hasPendingWrites tells you the device has local changes the server has not confirmed yet, so you can dim or badge them instead of pretending they are saved. Second, handle the error branch of the stream — a permission denial or a lost connection should show a clear message, not a spinner that never stops.
Make shared checklists conflict-safe
A packing checklist is the feature where two people tap at the same time, and the naive implementation loses one of the taps. If the checklist is a single array on the trip document, each phone reads the array, flips one item and writes the whole array back — the second write silently erases the first. The fix is to store every checklist item as its own document and toggle it with a single-field update:
Future<void> toggleChecklistItem({
required String tripId,
required String itemId,
required bool packed,
}) {
return FirebaseFirestore.instance
.collection('trips')
.doc(tripId)
.collection('checklist')
.doc(itemId)
.update({'packed': packed, 'packedBy': currentUserId});
}
A single-field update touches only that field, so two people packing different items never conflict, and even two people toggling the same item resolve to the last write — which is the correct answer for a checkbox. Adding items is just as safe: each new item is a new document with an auto-generated ID, so two people adding at the same moment create two items instead of overwriting one.
Split expenses with transactions
Split expenses are the one place where last-write-wins is not good enough. If two people add expenses at the same time and both read the running total, add their amount and write it back, one expense disappears from the total. Firestore transactions solve this: the transaction re-reads the document and retries if it changed underneath you:
Future<void> addExpense({
required String tripId,
required Expense expense,
}) async {
final db = FirebaseFirestore.instance;
final tripRef = db.collection('trips').doc(tripId);
await db.runTransaction((transaction) async {
final tripSnap = await transaction.get(tripRef);
final currentTotal = (tripSnap.data()?['totalSpent'] as num?) ?? 0;
transaction.set(tripRef.collection('expenses').doc(), expense.toJson());
transaction.update(tripRef, {
'totalSpent': currentTotal + expense.amount,
});
});
}
Even simpler, when all you need is a running total, FieldValue.increment() applies the change on the server without reading first:
tripRef.update({'totalSpent': FieldValue.increment(expense.amount)});
Use the transaction when the write depends on the current value in a way increment cannot express — for example, capping the total or splitting by shares — and use FieldValue.increment() everywhere else. Either way, never compute a shared total on the client from a value you read seconds ago.
Keep it working offline
A travel app is used in airports, on planes and in places with no signal, so offline behaviour is a feature, not an edge case. Firestore on Android and iOS persists its cache and queues writes by default, which means the app keeps working without a connection: listeners keep serving cached data, writes queue locally, and everything syncs when the connection returns.
Design the UI around that reality. Show a small "offline — changes will sync" state when the device has no connection, and use snapshot.metadata.hasPendingWrites to mark queued items. Avoid patterns that break offline: do not require a server round trip before showing a screen, and do not treat a failed write as lost data when it may simply be queued. The same offline-first thinking applies to other apps too, and the full pattern is covered in the guide to privacy-first offline apps.
Lock each trip to its members
A shared trip is also a private trip: the itinerary, the expenses and the packing list belong to the members and nobody else. Firestore security rules enforce that on the server, so a client bug can never leak another group's trip:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /trips/{tripId} {
allow read, write: if request.auth != null
&& request.auth.uid in resource.data.memberIds;
match /{subcollection}/{docId} {
allow read, write: if request.auth != null
&& request.auth.uid in get(/databases/$(database)/documents/trips/$(tripId)).data.memberIds;
}
}
}
}
Note the rule for adding a member: the new member is not in memberIds yet, so the invite itself has to be handled by a trusted path — a Cloud Function or a dedicated invites collection with its own rules — rather than a direct write from the invited user. The setup for the auth side of this is covered in the Firebase authentication guide.
Test the sync with two devices
Real-time features cannot be tested on one emulator. Run the app on two devices or emulators signed in as two different members of the same trip, then exercise the cases that break naive implementations: toggle the same checklist item on both at once, add two expenses simultaneously, and kill the network on one device, make edits, and reconnect. Watch for the failure modes this article was designed around: a toggle that reverts, a total that drops an expense, or a screen that goes blank offline. Keep the data layer behind a repository so the screens never talk to Firestore directly. That separation is what lets you test the sync logic with fake streams in widget tests, and it is the same pattern described in the guide to the repository pattern.
Putting it together
A collaborative travel planner is not a harder app to build than a single-user one — it is a different set of defaults. Model shared things as documents, not list fields. Listen instead of fetching. Update single fields instead of whole objects. Use transactions or server-side increments for shared totals. Let the offline cache do its job, lock each trip to its members with security rules, and test everything with two devices before you ship. That is how Turnie works: one shared plan for the whole group, with itineraries, split expenses and packing checklists that stay in sync wherever the trip takes you. You can see the app in the Turnie case study. Get in touch and let's talk about your project.
Frequently asked questions
- How do I build a collaborative app in Flutter with Firestore?
- Model shared data as documents with subcollections, listen with snapshots() instead of fetching once, and update single fields so concurrent edits do not overwrite each other. Lock each document to its members with Firestore security rules.
- How do I stop two users overwriting each other in Firestore?
- Store each editable thing as its own document and update single fields, so two people editing different items never collide. For values that depend on the current total, use a transaction or FieldValue.increment() so the server applies the change atomically.
- Does a Flutter Firestore app work offline?
- Yes. On Android and iOS Firestore persists its cache and queues writes by default, so listeners keep serving cached data and edits sync when the connection returns. Mark queued items with snapshot.metadata.hasPendingWrites so users can see what is still syncing.
- How do I keep a shared trip private in Firestore?
- Write security rules that allow read and write only when the signed-in user ID is in the trip document memberIds list, and apply the same check to every subcollection. Handle invites through a Cloud Function or a dedicated invites collection, since a new member cannot add themselves.


