ZuZweit is an Android app for couples that collects shared wishes and preserves fulfilled experiences as memories. The goal of this project was to learn Android development from scratch while building a realistic product with auth, data modeling, an image pipeline, and push flows.
๐ฏ Goals
Why this project? Many couple apps are bloated. I wanted a focused, fast app that covers the core: shared wishes, memories, photos, and personal notes. The project served as a learning path for Jetpack Compose, Firebase, and clean app architecture.
Learning goals
- Jetpack Compose UI + state management
- Firebase Auth, Firestore, Storage, Messaging
- Image handling (camera, gallery, compression)
- Cloud Functions for server-side logic
- Offline strategies and UX feedback
โจ Overview
Features
- Registration, login, password reset via deep link
- Partner linking via connection code
- Shared wishlist with CRUD
- Wish completion via camera or gallery
- Memory detail page with journal entry
- Dynamic UI colors extracted from the memory image
- Push notifications for new wishes
User journey (short)
- User registers and receives a connection code
- Partner connects with the code and sets a nickname
- Both create wishes, mark them as completed, and upload images
- Completed wishes appear as memories with gallery and journal
๐งฑ Architecture & Data Flow
Architecture
- MVVM with
StateFlow/SharedFlowinviewmodel/* - Firestore snapshot listeners for live updates
- Separation of UI (Compose) and data access (Repository, ViewModels)
- Image pipeline: camera/gallery โ compression โ storage โ colors from image โ dynamic UI
- Partner context as global filter (pair ID) for wishes/memories
Modules
app/Android appfunctions/Firebase Cloud Functions
๐ State of the Art (Project Context)
Jetpack Compose Declarative UI framework for Android. Used for all screens, bottom navigation, bottom sheets, and dialogs.
Firebase Realtime backend with Auth, Firestore, and Storage. Snapshot listeners provide live updates in the wishlist and memories.
Push notifications FCM tokens are maintained client-side, the push event is triggered server-side via Cloud Functions.
๐ง Code Highlights
The highlights are grouped by topic. This keeps the tabs readable even with multiple examples.
Wish becomes a memory (upload + colors + Firestore update)app/src/main/java/de/moorizzle/zuzweit/viewmodel/WishlistViewModel.kt
val downloadUrls = imageRepository.uploadCompressedImages(context, imageUris, wish.id)
val updatedData = mutableMapOf(
"completed" to true,
"completedDate" to Timestamp.now(),
"completedImageUrls" to FieldValue.arrayUnion(*downloadUrls.toTypedArray())
)
val dynamicColors = imageRepository.extractColorsFromImageUrl(context, downloadUrls.first())
updatedData["dynamicContainerColor"] = dynamicColors.containerColor.toHexString()
updatedData["dynamicContentColor"] = dynamicColors.contentColor.toHexString()
db.collection("wishes").document(wish.id).update(updatedData).await()
Partner system with pair ID & live switchingapp/src/main/java/de/moorizzle/zuzweit/viewmodel/ActivePartnerViewModel.kt
val activePartner: StateFlow<Partner?> = combine(allPartners, _activePartnerId) { partners, activeId ->
partners.find { it.partnerId == activeId } ?: partners.firstOrNull()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
Dynamic UI from image colorsapp/src/main/java/de/moorizzle/zuzweit/ui/memory/MemoryDetailScreen.kt
val containerColor = memory.dynamicContainerColor?.let { Color(it.toColorInt()) } ?: Color.Gray
val contentColor = memory.dynamicContentColor?.let { Color(it.toColorInt()) } ?: Color.LightGray
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = containerColor,
titleContentColor = contentColor
)
)
Taking photos + permission handlingapp/src/main/java/de/moorizzle/zuzweit/ui/wishlist/WishlistScreen.ktapp/src/main/java/de/moorizzle/zuzweit/util/PermissionHandler.kt
val requestCameraPermission = rememberPermissionHandler(
permission = Manifest.permission.CAMERA,
onPermissionGranted = {
val uri = createImageUri(context)
imageUri = uri
cameraLauncher.launch(uri)
},
onPermissionDenied = { showPermissionDialog = true }
)
Image compression before uploadapp/src/main/java/de/moorizzle/zuzweit/util/ImageUtil.kt
val tempFile = File.createTempFile("compressed_", ".jpg", context.cacheDir)
FileOutputStream(tempFile).use { fos ->
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, fos)
}
Push for new wishes (Cloud Function)functions/src/index.ts
export const onNewWishSendNotification = onDocumentCreated("wishes/{wishId}", async (event) => {
const newWish = event.data?.data();
const recipientId = newWish.pairId.split("_").find((id: string) => id !== newWish.creatorId);
const recipientDoc = await getFirestore().collection("users").doc(recipientId).get();
const fcmToken = recipientDoc.data()?.fcmToken;
await getMessaging().send({
notification: {
title: "A new wish was shared! โจ",
body: `Your partner added the wish "${newWish.title}".`,
},
token: fcmToken,
});
});
Linking by code (transactional)app/src/main/java/de/moorizzle/zuzweit/viewmodel/ProfileViewModel.kt
val pairId = "${minOf(currentUser.uid, partnerId)}_${maxOf(currentUser.uid, partnerId)}"
val newPartnerForCurrentUser = Partner(partnerId, nicknameForPartner, pairId)
val newPartnerForPartner = Partner(currentUser.uid, ownNicknameForPartner, pairId)
db.runTransaction { transaction ->
val currentUserRef = db.collection("users").document(currentUser.uid)
val partnerUserRef = db.collection("users").document(partnerId)
transaction.update(currentUserRef, "partners", FieldValue.arrayUnion(newPartnerForCurrentUser))
transaction.update(partnerUserRef, "partners", FieldValue.arrayUnion(newPartnerForPartner))
}
Deep link for password resetapp/src/main/java/de/moorizzle/zuzweit/MainActivity.kt
private fun handleIntent(intent: Intent?) {
val actionLink = intent?.data?.toString()
if (actionLink != null) {
val code = intent.data?.getQueryParameter("oobCode")
if (code != null) {
authViewModel.actionCode = code
}
}
}
๐งฉ Data Model (Firestore)
Collections
userswith profile, connection code, and partner listwisheswith pair ID, status, images, colors, and journal entry
Wish structure (simplified)
title,description,creatorId,pairIdcompleted,completedDate,completedImageUrlsjournalEntry,dynamicContainerColor,dynamicContentColor
Partner relationship
pairIdis based on both UIDs and acts as a shared filter- Each user maintains a partner list, multiple connections are possible
๐จ UX Decisions
- Bottom navigation instead of a drawer for quick switching
- Pull-to-refresh on wishlist and memories
- Modal bottom sheets for add flow and image source
- Colors extracted from the memory image to make the detail screen more personal
โ ๏ธ Challenges
- Permissions & SDK differences: camera + media storage vary by Android version (
READ_MEDIA_IMAGESvs.READ_EXTERNAL_STORAGE). - State management in Compose: keeping UI state clean via
StateFlowand Compose state. - Firestore data model: partner linking via
pairId, live listeners, offline cache. - Image pipeline: compression and upload performance, quality trade-offs.
- Push notifications: reliably updating FCM tokens and using them in the backend.
- Error handling: UX feedback with snackbars and dialogs for network and permission errors.
โ What I learned
- The full Android stack in practice: UI, state, data, backend
- Firebase in practice: auth flows, Firestore modeling, storage uploads
- Architectural thinking: MVVM, repository pattern, clear state flows
- UX topics: image handling, dynamic UI, dialogs, bottom sheets
- Working with permissions, Activity Result APIs, and deep links
๐งช Tests & Quality
- Initial unit tests for the auth flow in
app/src/test/java/de/moorizzle/zuzweit/viewmodel/AuthViewModelTest.kt MainCoroutineRulefor coroutine tests inapp/src/test/java/de/moorizzle/zuzweit/MainCoroutineRule.kt- Potential next steps: Compose UI tests and Firestore mocks
๐ Outlook
- Memory search and filtering by date or tags
- Better offline UX with clear sync status
- In-app onboarding for new couples
- More analytics in the profile for shared activity