ZuZweit ist eine Android‑App für Paare, die gemeinsame Wünsche sammelt und erfüllte Erlebnisse als Erinnerungen festhält. Ziel des Projekts war es, Android‑Entwicklung von Grund auf zu lernen und gleichzeitig ein realistisches Produkt mit Auth, Datenmodell, Bildpipeline und Push‑Flows umzusetzen.
🎯 Zielsetzung
Warum dieses Projekt? Viele Paar‑Apps sind überladen. Ich wollte eine fokussierte, schnelle App, die den Kern abdeckt: gemeinsame Wünsche, Erinnerungen, Fotos und persönliche Notizen. Das Projekt diente als Lernpfad für Jetpack Compose, Firebase und saubere App‑Architektur.
Lernziele
- Jetpack Compose UI + State‑Management
- Firebase Auth, Firestore, Storage, Messaging
- Bildhandling (Kamera, Galerie, Kompression)
- Cloud Functions für serverseitige Logik
- Offline‑Strategien und UX‑Feedback
✨ Überblick
Funktionen
- Registrierung, Login, Passwort‑Reset per Deep Link
- Partner‑Verknüpfung via Verbindungscode
- Gemeinsame Wunschliste mit CRUD
- Wunsch‑Erfüllung per Kamera oder Galerie
- Erinnerungs‑Detailseite mit Journal‑Eintrag
- Dynamische UI‑Farben aus dem Erinnerungsbild
- Push‑Benachrichtigungen bei neuen Wünschen
User Journey (kurz)
- Nutzer registriert sich und erhält einen Verbindungscode
- Partner verbindet sich mit Code und vergibt einen Spitznamen
- Beide erstellen Wünsche, markieren sie als erledigt und laden Bilder hoch
- Erledigte Wünsche erscheinen als Erinnerungen mit Galerie und Journal
🧱 Architektur & Datenfluss
Architektur
- MVVM mit
StateFlow/SharedFlowinviewmodel/* - Firestore Snapshot‑Listener für Live‑Updates
- Trennung von UI (Compose) und Datenzugriff (Repository, ViewModels)
- Bildpipeline: Kamera/Galerie → Kompression → Storage → Farben aus Bild → dynamische UI
- Partner‑Kontext als globaler Filter (Pair‑ID) für Wünsche/Erinnerungen
Module
app/Android‑Appfunctions/Firebase Cloud Functions
🔍 Stand der Technik (im Projektkontext)
Jetpack Compose Deklaratives UI‑Framework für Android. Im Projekt verwendet für alle Screens, Bottom‑Navigation, Bottom‑Sheets und Dialoge.
Firebase Realtime‑Backend mit Auth, Firestore und Storage. Snapshot‑Listener sorgen für Live‑Updates in der Wunschliste und Erinnerungen.
Push‑Benachrichtigungen FCM‑Tokens werden clientseitig gepflegt, das Push‑Event wird serverseitig über Cloud Functions ausgelöst.
🧠 Code‑Highlights
Die Highlights sind nach Themen gruppiert. So bleiben die Tabs übersichtlich, obwohl es mehrere Beispiele gibt.
Wunsch wird zur Erinnerung (Upload + Farben + 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 mit 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)
Dynamisches UI aus Bildfarbenapp/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
)
)
Bilder aufnehmen + Berechtigungen kapselnapp/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 }
)
Bildkompression vor 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 bei neuen Wünschen (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: "Ein neuer Wunsch wurde geteilt! ✨",
body: `Dein Partner hat den Wunsch "${newWish.title}" hinzugefügt.`,
},
token: fcmToken,
});
});
Verbindung per Code (transaktional)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 für Passwort‑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
}
}
}
🧩 Datenmodell (Firestore)
Collections
usersmit Profil, Verbindungscode und Partnerlistewishesmit Pair‑ID, Status, Bildern, Farben und Journal‑Eintrag
Wunsch‑Struktur (vereinfacht)
title,description,creatorId,pairIdcompleted,completedDate,completedImageUrlsjournalEntry,dynamicContainerColor,dynamicContentColor
Partner‑Beziehung
pairIdbasiert auf beiden UIDs und dient als gemeinsamer Filter- Pro User wird eine Partnerliste gepflegt, mehrere Verbindungen sind möglich
🎨 UX‑Entscheidungen
- Bottom‑Navigation statt Navigation Drawer für schnellen Wechsel
- Pull‑To‑Refresh auf Wunschliste und Erinnerungen
- Modal Bottom Sheets für Add‑Flow und Bildquelle
- Farben aus Erinnerungsbild, um die Detailseite persönlicher wirken zu lassen
⚠️ Herausforderungen
- Berechtigungen & SDK‑Unterschiede: Kamera + Media Storage je nach Android Version (
READ_MEDIA_IMAGESvs.READ_EXTERNAL_STORAGE). - State‑Management in Compose: UI‑State sauber über
StateFlowund Compose‑State synchron halten. - Firestore‑Datenmodell: Partner‑Verknüpfung über
pairId, Live‑Listener, Offline‑Cache. - Bildpipeline: Kompression und Upload‑Performance, sinnvolle Qualitäts‑Balance.
- Push‑Notifications: FCM‑Tokens zuverlässig aktualisieren und im Backend nutzen.
- Fehler‑Handling: UX‑Feedback mit Snackbars und Dialogen für Netzwerk‑ und Permission‑Fehler.
✅ Was ich dabei gelernt habe
- Den kompletten Android‑Stack im Zusammenspiel: UI, State, Daten, Backend
- Firebase in der Praxis: Auth‑Flows, Firestore‑Modeling, Storage‑Uploads
- Architektur‑Denken: MVVM, Repository‑Pattern, klare Zustandsflüsse
- UX‑Themen: Bildhandling, dynamische UI, Dialoge, Bottom Sheets
- Umgang mit Permissions, Activity‑Result APIs und Deep‑Links
🧪 Tests & Qualität
- Erste Unit‑Tests für den Auth‑Flow in
app/src/test/java/de/moorizzle/zuzweit/viewmodel/AuthViewModelTest.kt MainCoroutineRulefür Coroutine‑Tests inapp/src/test/java/de/moorizzle/zuzweit/MainCoroutineRule.kt- Potenzieller Ausbau: UI‑Tests für Compose‑Screens und Firestore‑Mocks
📈 Ausblick
- Erinnerungssuche und Filter nach Datum oder Tags
- Bessere Offline‑UX mit klaren Sync‑Statusanzeigen
- In‑App Onboarding für neue Paare
- Mehr Analytics im Profil für gemeinsame Aktivität