Compare commits

...

35 Commits

Author SHA1 Message Date
null ddfe9e250a security: remove google-services.json from repo and git tracking, add to .gitignore 2026-06-16 21:47:42 -05:00
null c28ce9c58d security: restrict couple-level Firestore writes — immutable fields, owner-only messages/reactions, server-only deletes, valid state transitions 2026-06-16 21:46:56 -05:00
null bd1ea5cecd security: fix invite rules missing-doc bypass, webhook timing attack, entitlement replay protection and entitlement_id check 2026-06-16 21:45:04 -05:00
null f45f8dd114 security: fail-closed webhook auth, constant-time secret comparison, centralized env validation 2026-06-16 21:37:57 -05:00
null e8274370d1 chore: add CONCERN.md to .gitignore 2026-06-16 21:32:14 -05:00
null afeb1a1a03 docs: add README, add proguard rules, Firestore entitlement checker, network security config, update build config and onboarding 2026-06-16 20:16:47 -05:00
null 84995641f3 refactor: package rename from com.couplesconnect.app to app.closer, update build config and firebase setup 2026-06-16 20:03:58 -05:00
null e42de938e7 fix: update Invite model, backup rules, and Firestore security rules 2026-06-16 19:44:28 -05:00
null a870b17801 refactor: update AcceptInviteScreen with theme-consistent visuals and layout 2026-06-16 19:14:06 -05:00
null 99ff77a357 refactor: consolidate theme usage across components, screens, and wheel flow 2026-06-16 18:44:23 -05:00
null 71441bec14 refactor: extract shared auth/settings visuals, apply consistent theme across screens 2026-06-16 04:16:16 -05:00
null 0f73c656d8 fix(answers): update answer flow screens, navigation, and question input component 2026-06-16 03:39:40 -05:00
null 56f2d8c045 fix(home): update HomeScreen and HomeViewModel 2026-06-16 03:25:03 -05:00
null 0e9606366b fix(questions): update category and pack library screens 2026-06-16 03:07:32 -05:00
null 888ffa3c1a fix(ui): polish screens, update ViewModels, add docs 2026-06-16 02:55:16 -05:00
null 5302526d32 fix(theme): apply consistent color system, polish UI across all screens 2026-06-16 02:49:36 -05:00
null c1548f28fb fix(navigation, pairing): update route handling and invite screen layout 2026-06-16 02:30:18 -05:00
null 342c3276a0 fix(home,onboarding): correct navigation and state handling 2026-06-16 02:18:28 -05:00
null db177bc792 fix(firebase): replace placeholder Firebase config, update initializer and home screen wiring 2026-06-16 01:57:48 -05:00
null 7a9d4c3b49 feat(ui): wheel history screen, session repo, external links, shared loading/error/empty components 2026-06-16 01:24:04 -05:00
null bee617c493 chore(server): add Node.js backend with auth, questions, answers + update gitignore 2026-06-16 01:17:58 -05:00
null 1a33d4f2b9 feat(analytics): Firebase Analytics, Crashlytics, ObservabilityModule, Firestore rules 2026-06-16 01:13:20 -05:00
null 11a81cb826 feat(settings): DataStore-based settings repo, notification preferences, special dates component 2026-06-16 01:07:13 -05:00
null 88004cf219 feat(notifications): FCM messaging service, notification helper, FCM+delete account+relationship screens 2026-06-16 01:03:07 -05:00
null 011745e7d4 feat(wheel): ViewModel-backed wheel screens with local session store 2026-06-16 00:56:08 -05:00
null 577d39ea11 feat(paywall): question pack library, entitlement checker, home screen wiring 2026-06-16 00:50:13 -05:00
null 29d512c679 fix(couple): correct Firestore query and repository method signatures 2026-06-16 00:40:00 -05:00
null 6fe5e5048e fix(daily): correct question flow state handling and UI edge cases 2026-06-16 00:33:55 -05:00
null 78e145352b feat(settings): SettingsViewModel, auth logout wiring, FirebaseAuth cleanup
- Added SettingsViewModel with logout/profile navigation
- Updated FirebaseAuthDataSource with signOut()
- Refined AuthRepository interface and FirebaseAuthRepositoryImpl
- Wired SettingsScreen to SettingsViewModel
2026-06-16 00:27:29 -05:00
null 112de3398f feat(pairing): Firebase Firestore invite/couple data sources with ViewModels
- Added FirestoreCoupleDataSource, FirestoreInviteDataSource
- CoupleRepository + InviteRepository interfaces and Firestore impls
- AcceptInviteViewModel, CreateInviteViewModel, InviteConfirmViewModel
- Updated AcceptInviteScreen, CreateInviteScreen, InviteConfirmScreen with ViewModel-backed state
- Updated RepositoryModule and OnboardingViewModel for new dependencies
2026-06-16 00:22:20 -05:00
null 8bcb3308c1 feat(auth): Firebase auth wiring, login/signup/forgot-password with ViewModels
- Added FirebaseAuthDataSource, FirestoreUserDataSource
- AuthRepository + UserRepository interfaces and Firebase impls
- LoginViewModel, SignUpViewModel, ForgotPasswordViewModel
- OnboardingViewModel, CreateProfileViewModel
- Updated LoginScreen, SignUpScreen, ForgotPasswordScreen, OnboardingScreen, CreateProfileScreen with ViewModel-backed state
- Wired RepositoryModule with Firebase auth dependencies
2026-06-16 00:01:34 -05:00
null af7603d61c feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
null 5991acb283 chore(gitignore): exclude app/build/ from version control
app/build/ wasn't caught by /build pattern which only matches root dir. Removes ~200 build artifact files from tracking.
2026-06-15 21:38:39 -05:00
null 92a0e8f2eb feat(db): add 2 new v2 question categories (difficult_conversations, home_life) — 20 total, 5,500 questions
- Rebuilt seeded DB with difficult_conversations.json + home_life.json
- Hilt DI wiring (@HiltAndroidApp, @AndroidEntryPoint)
- Updated User/Couple models to current schema
- Replaced placeholder with AppNavigation
- DB: 20 categories, 5,500 questions
2026-06-15 21:38:22 -05:00
null 1c976935c9 feat: Firebase + RevenueCat stack, PLAN.md integration, 11 implementation batches (v0.1.0) 2026-06-15 18:43:43 -05:00
224 changed files with 132578 additions and 0 deletions

8
.gitignore vendored
View File

@ -14,6 +14,7 @@ FUTURE.md
HISTORY.md HISTORY.md
PROJECT.md PROJECT.md
STRUCTURE.md STRUCTURE.md
CONCERN.md
project-requirements.md project-requirements.md
DEVELOPMENT_LOG.md DEVELOPMENT_LOG.md
BUILD_SUMMARY.md BUILD_SUMMARY.md
@ -34,3 +35,10 @@ out/
.env .env
.env.local .env.local
*.env *.env
# App module build
app/build/
SecurityReport.md
# Firebase config (contains project ID, app ID, OAuth client, API key)
app/google-services.json

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "interactive"
}

148
README.md Normal file
View File

@ -0,0 +1,148 @@
# Closer
A private, warm, intentional space for couples to build deeper emotional connection.
## About
Closer is a native Android app designed to help couples communicate better, learn more about each other, and create intentional moments together. It focuses on emotional closeness — not task management, not social media, not productivity.
## Screens & Features
### Onboarding & Auth
- Welcome flow with app introduction
- Create profile (display name, photo)
- Sign up with email or Google
- Login and password recovery
- Anonymous trial mode
### Home
- Suggested next-best action ranked by state
- Latest daily question status
- Quick-action cards for daily question, question packs, and history
- Streak tracking display
- Answer stats at a glance
### Daily Question
- One question per day for the couple
- Multiple answer types: written, choice, scale, this-or-that
- Each partner answers privately, then both answers are revealed
### Question Packs
**20 categories across 2,500+ questions:**
- Fun, Communication, Conflict, Values, Emotional Intimacy
- Physical Intimacy, Sex & Desire, Future, Money, Stress
- Boundaries, Conflict Repair, Date Night, Difficult Conversations
- Gratitude, Home Life, Marriage, Parenting, Rebuilding Trust, Trust
Each pack has curated questions at varying depth levels. Packs are labeled as free or premium.
### Answer System
- Private written, choice, scale, or this-or-that answers
- Partner answer reveal flow
- Local answer history with delete
- Emoji reactions to partner answers
- Threaded conversation around specific questions
### Spin Wheel (Wheel of Questions)
- Pick a category, spin the wheel, get a random question
- Session-based: spin multiple questions in one session
- Full session history for premium users
- Perfect for date nights, long drives, or quiet moments together
### Partner Pairing
- Generate a 6-character invite code
- Invite partner via email
- Accept invite and confirm pairing
- Partner home screen showing partner's activity
### Settings
- Account management (email, display name, photo)
- Notification preferences (reminders, quiet hours)
- Privacy controls
- Subscription management via RevenueCat
- Relationship settings
- Account deletion
## Subscription Model
| Tier | Key Features |
|------|-------------|
| **Free** | Daily question, recent answer history, basic categories, basic reminders, streak tracking, limited spin wheel sessions |
| **Premium** | All free features + premium question packs, full spin wheel access with saved history, unlimited questions, full answer history with search, custom questions, private notes, exportable memories, advanced reminders, AI-assisted suggestions (future) |
Powered by RevenueCat + Google Play Billing.
## Tech Stack
| Layer | Tech |
|-------|------|
| Language | Kotlin |
| UI | Jetpack Compose, Material 3 |
| Architecture | Clean Architecture (data / domain / ui / core) |
| Navigation | Navigation Compose |
| DI | Hilt |
| Local Storage | Room, DataStore Preferences |
| Backend | Firebase Auth, Firestore, Cloud Functions |
| Notifications | Firebase Cloud Messaging |
| Analytics | Firebase Analytics, Crashlytics |
| Payments | RevenueCat |
| Security | Firebase App Check (Play Integrity) |
| Min SDK | 26 |
| Target SDK | 35 |
## Architecture
```
app/
├── core/ # Cross-cutting: analytics, billing, crash, features, Firebase, navigation, notifications
├── data/ # Data layer: Room DB, Firestore datasources, repository implementations, question JSON parser
├── domain/ # Domain layer: models, repository interfaces, use cases
└── ui/ # Presentation layer: screens + viewmodels per feature area
├── answers/ # Answer history, answer reveal
├── auth/ # Login, signup, forgot password
├── components/ # Shared UI components (empty, error, loading states, special dates)
├── home/ # Home screen, partner home
├── onboarding/ # Onboarding flow, create profile
├── pairing/ # Invite creation, acceptance, confirmation
├── paywall/ # Subscription upsell
├── questions/ # Daily question, pack library, categories, composer, thread
├── settings/ # Account, notifications, privacy, relationship, subscription, delete
├── theme/ # Color, typography, theme configuration
└── wheel/ # Category picker, spin wheel, session, history, complete
```
## Data Sources
- **Firestore** — user profiles, couple relationships, question threads, invites, entitlements
- **Room** — local question cache, answered questions offline
- **DataStore** — user preferences, feature flags, settings
- **JSON assets** — bundled questions (2500+ across 20 categories)
## Build
```bash
cd app/
./gradlew assembleDebug # Build debug APK
./gradlew installDebug # Install on connected device/emulator
./gradlew assembleRelease # Build release APK (minified + shrunk)
```
### Prerequisites
- Android Studio Hedgehog or later
- JDK 17
- Firebase project with Auth, Firestore, Cloud Messaging, and App Check configured
- `google-services.json` in `app/` directory
- RevenueCat project configured for Google Play
## Design Principles
- **Warm & Intimate** — The app should feel like a private emotional space, not a productivity tool
- **Privacy-first** — Data isolated by couple, strict Firestore rules
- **Offline-resilient** — Room cache keeps the app fast without network
- **Intentional** — Short, meaningful interactions. 5 good minutes > endless scrolling
- **2026 modern** — Smooth Material 3 animations, instant feedback, no jank
## License
Private project. All rights reserved.

109
app/build.gradle.kts Normal file
View File

@ -0,0 +1,109 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp")
}
android {
namespace = "app.closer"
compileSdk = 35
defaultConfig {
applicationId = "app.closer"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
}
buildFeatures {
buildConfig = true
compose = true
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.01.01")
implementation(composeBom)
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.activity:activity-compose:1.9.3")
// Compose
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
// Navigation
implementation("androidx.navigation:navigation-compose:2.8.5")
// ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
// Firebase
implementation(platform("com.google.firebase:firebase-bom:33.8.0"))
implementation("com.google.firebase:firebase-auth-ktx")
implementation("com.google.firebase:firebase-firestore-ktx")
implementation("com.google.firebase:firebase-messaging-ktx")
implementation("com.google.firebase:firebase-config-ktx")
implementation("com.google.firebase:firebase-analytics-ktx")
implementation("com.google.firebase:firebase-crashlytics-ktx")
implementation("com.google.firebase:firebase-appcheck-ktx")
implementation("com.google.firebase:firebase-appcheck-playintegrity")
debugImplementation("com.google.firebase:firebase-appcheck-debug")
// Hilt
implementation("com.google.dagger:hilt-android:2.53.1")
ksp("com.google.dagger:hilt-android-compiler:2.53.1")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
// Room
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// DataStore
implementation("androidx.datastore:datastore-preferences:1.1.2")
// Encrypted storage
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// RevenueCat
implementation("com.revenuecat.purchases:purchases-hybrid-common:13.5.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// Debug
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

49
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,49 @@
# ── Firebase ─────────────────────────────────────────────────────────────────
-keep class com.google.firebase.** { *; }
-keep class com.google.android.gms.** { *; }
-keepattributes Signature
# ── Hilt ─────────────────────────────────────────────────────────────────────
-keep class dagger.hilt.** { *; }
-keepclasseswithmembers class * {
@dagger.hilt.android.lifecycle.HiltViewModel <init>(...);
}
# ── Room ──────────────────────────────────────────────────────────────────────
-keep @androidx.room.Entity class * { *; }
-keep @androidx.room.Dao interface * { *; }
-keep @androidx.room.Database class * { *; }
# ── Domain models (used in Firestore manual mapping & local JSON) ─────────────
-keepclassmembers class app.closer.domain.model.** {
<fields>;
<init>(...);
}
# ── RevenueCat ────────────────────────────────────────────────────────────────
-keep class com.revenuecat.** { *; }
-dontwarn com.revenuecat.**
# ── Kotlin coroutines ────────────────────────────────────────────────────────
-keepclassmembernames class kotlinx.** {
volatile <fields>;
}
-dontwarn kotlinx.coroutines.**
# ── Crash reporting: preserve source file names and line numbers ─────────────
-keepattributes SourceFile,LineNumberTable
-keep public class * extends java.lang.Exception
-renamesourcefileattribute SourceFile
# ── Kotlin metadata (needed for reflection-free Kotlin code) ─────────────────
-keepattributes RuntimeVisibleAnnotations
-keepattributes RuntimeInvisibleAnnotations
-keepattributes *Annotation*
# ── Prevent stripping of BuildConfig ─────────────────────────────────────────
-keep class app.closer.BuildConfig { *; }
# ── Suppress warnings for optional dependencies ──────────────────────────────
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**

View File

@ -0,0 +1,138 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "4c0a60329b23e0bc0526d7cb7e7269b9",
"entities": [
{
"tableName": "question",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `text` TEXT NOT NULL, `category_id` TEXT NOT NULL, `depth_level` INTEGER NOT NULL, `is_premium` INTEGER NOT NULL, `type` TEXT NOT NULL, `tags` TEXT NOT NULL, `answer_config` TEXT NOT NULL, `pack_id` TEXT, `created_at` INTEGER NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "depthLevel",
"columnName": "depth_level",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPremium",
"columnName": "is_premium",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "answerConfig",
"columnName": "answer_config",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "packId",
"columnName": "pack_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "question_category",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `display_name` TEXT NOT NULL, `description` TEXT NOT NULL, `access` TEXT NOT NULL, `icon_name` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "display_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "access",
"columnName": "access",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconName",
"columnName": "icon_name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4c0a60329b23e0bc0526d7cb7e7269b9')"
]
}
}

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".CloserApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.Closer"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Closer">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".core.notifications.AppMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>

Binary file not shown.

View File

@ -0,0 +1,17 @@
package app.closer
import android.app.Application
import app.closer.core.firebase.FirebaseInitializer
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp
class CloserApp : Application() {
@Inject lateinit var firebaseInitializer: FirebaseInitializer
override fun onCreate() {
super.onCreate()
firebaseInitializer.initialize()
}
}

View File

@ -0,0 +1,29 @@
package app.closer
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import app.closer.core.navigation.AppNavigation
import app.closer.ui.theme.CloserTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CloserTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AppNavigation()
}
}
}
}
}

View File

@ -0,0 +1,16 @@
package app.closer.core.analytics
interface AnalyticsTracker {
fun trackScreenViewed(screenName: String)
fun trackAppOpened()
fun trackQuestionViewed(questionId: String, category: String)
fun trackAnswerSubmitted(questionId: String, category: String, answerType: String)
fun trackAnswerRevealed(questionId: String, category: String)
fun trackPaywallViewed(source: String)
fun trackCouplePaired()
fun trackCoupleLeft()
fun trackSpinWheelStarted(categoryId: String)
fun trackSpinWheelCompleted(categoryId: String, answeredCount: Int)
fun trackSignIn(method: String)
fun trackSignUp(method: String)
}

View File

@ -0,0 +1,84 @@
package app.closer.core.analytics
import android.os.Bundle
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.logEvent
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FirebaseAnalyticsTracker @Inject constructor(
private val analytics: FirebaseAnalytics
) : AnalyticsTracker {
override fun trackScreenViewed(screenName: String) {
analytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
param(FirebaseAnalytics.Param.SCREEN_NAME, screenName)
}
}
override fun trackAppOpened() {
analytics.logEvent("app_opened", Bundle.EMPTY)
}
override fun trackQuestionViewed(questionId: String, category: String) {
analytics.logEvent("question_viewed") {
param("question_id", questionId)
param("category", category)
}
}
override fun trackAnswerSubmitted(questionId: String, category: String, answerType: String) {
analytics.logEvent("answer_submitted") {
param("question_id", questionId)
param("category", category)
param("answer_type", answerType)
}
}
override fun trackAnswerRevealed(questionId: String, category: String) {
analytics.logEvent("answer_revealed") {
param("question_id", questionId)
param("category", category)
}
}
override fun trackPaywallViewed(source: String) {
analytics.logEvent("paywall_viewed") {
param("source", source)
}
}
override fun trackCouplePaired() {
analytics.logEvent("couple_paired", Bundle.EMPTY)
}
override fun trackCoupleLeft() {
analytics.logEvent("couple_left", Bundle.EMPTY)
}
override fun trackSpinWheelStarted(categoryId: String) {
analytics.logEvent("spin_wheel_started") {
param("category_id", categoryId)
}
}
override fun trackSpinWheelCompleted(categoryId: String, answeredCount: Int) {
analytics.logEvent("spin_wheel_completed") {
param("category_id", categoryId)
param("answered_count", answeredCount.toLong())
}
}
override fun trackSignIn(method: String) {
analytics.logEvent(FirebaseAnalytics.Event.LOGIN) {
param(FirebaseAnalytics.Param.METHOD, method)
}
}
override fun trackSignUp(method: String) {
analytics.logEvent(FirebaseAnalytics.Event.SIGN_UP) {
param(FirebaseAnalytics.Param.METHOD, method)
}
}
}

View File

@ -0,0 +1,13 @@
package app.closer.core.billing
import javax.inject.Inject
import javax.inject.Singleton
interface EntitlementChecker {
val hasPremium: Boolean
}
@Singleton
class FakeEntitlementChecker @Inject constructor() : EntitlementChecker {
override val hasPremium: Boolean = false
}

View File

@ -0,0 +1,42 @@
package app.closer.core.billing
import app.closer.domain.model.AuthState
import app.closer.domain.repository.AuthRepository
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.ListenerRegistration
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FirestoreEntitlementChecker @Inject constructor(
private val firestore: FirebaseFirestore,
private val authRepository: AuthRepository
) : EntitlementChecker {
@Volatile private var _hasPremium = false
override val hasPremium: Boolean get() = _hasPremium
private var listenerRegistration: ListenerRegistration? = null
init {
CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
authRepository.authState.collect { state ->
listenerRegistration?.remove()
listenerRegistration = null
if (state is AuthState.Authenticated) {
listenerRegistration = firestore.collection("users")
.document(state.userId)
.addSnapshotListener { snap, _ ->
_hasPremium = snap?.getBoolean("hasPremium") == true
}
} else {
_hasPremium = false
}
}
}
}
}

View File

@ -0,0 +1,8 @@
package app.closer.core.crash
interface CrashReporter {
fun setUserId(uid: String)
fun clearUserId()
fun log(message: String)
fun recordException(throwable: Throwable)
}

View File

@ -0,0 +1,27 @@
package app.closer.core.crash
import com.google.firebase.crashlytics.FirebaseCrashlytics
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FirebaseCrashReporter @Inject constructor() : CrashReporter {
private val crashlytics = FirebaseCrashlytics.getInstance()
override fun setUserId(uid: String) {
crashlytics.setUserId(uid)
}
override fun clearUserId() {
crashlytics.setUserId("")
}
override fun log(message: String) {
crashlytics.log(message)
}
override fun recordException(throwable: Throwable) {
crashlytics.recordException(throwable)
}
}

View File

@ -0,0 +1,140 @@
package app.closer.core.feature
/**
* Feature flag definition.
* Each feature has a stable key string, billing status, and priority.
*
* @property key Unique identifier for this feature (used in config, analytics, Remote Config)
* @property status FREE or PREMIUM
* @property priority MVP or LATER
* @property description Human-readable description
*/
sealed class FeatureFlag(
val key: String,
val status: FeatureStatus,
val priority: FeaturePriority,
val description: String
) {
data object DAILY_QUESTION : FeatureFlag(
key = "DAILY_QUESTION",
status = FeatureStatus.FREE,
priority = FeaturePriority.MVP,
description = "Answer one daily relationship question"
)
data object ANSWER_HISTORY : FeatureFlag(
key = "ANSWER_HISTORY",
status = FeatureStatus.FREE,
priority = FeaturePriority.MVP,
description = "View limited recent answer history"
)
data object BASIC_CATEGORIES : FeatureFlag(
key = "BASIC_CATEGORIES",
status = FeatureStatus.FREE,
priority = FeaturePriority.MVP,
description = "Basic question categories"
)
data object BASIC_REMINDERS : FeatureFlag(
key = "BASIC_REMINDERS",
status = FeatureStatus.FREE,
priority = FeaturePriority.MVP,
description = "Basic push reminders"
)
data object STREAK_TRACKING : FeatureFlag(
key = "STREAK_TRACKING",
status = FeatureStatus.FREE,
priority = FeaturePriority.MVP,
description = "Daily answer streak tracking"
)
data object SPIN_WHEEL_LIMITED : FeatureFlag(
key = "SPIN_WHEEL_LIMITED",
status = FeatureStatus.FREE,
priority = FeaturePriority.MVP,
description = "Limited spin wheel sessions"
)
data object PREMIUM_PACKS : FeatureFlag(
key = "PREMIUM_PACKS",
status = FeatureStatus.PREMIUM,
priority = FeaturePriority.MVP,
description = "Premium question packs"
)
data object FULL_SPIN_WHEEL : FeatureFlag(
key = "FULL_SPIN_WHEEL",
status = FeatureStatus.PREMIUM,
priority = FeaturePriority.MVP,
description = "All spin wheel categories + saved sessions"
)
data object UNLIMITED_QUESTIONS : FeatureFlag(
key = "UNLIMITED_QUESTIONS",
status = FeatureStatus.PREMIUM,
priority = FeaturePriority.LATER,
description = "Unlimited daily questions"
)
data object FULL_HISTORY : FeatureFlag(
key = "FULL_HISTORY",
status = FeatureStatus.PREMIUM,
priority = FeaturePriority.LATER,
description = "Full answer history with search/filter"
)
data object CUSTOM_QUESTIONS : FeatureFlag(
key = "CUSTOM_QUESTIONS",
status = FeatureStatus.PREMIUM,
priority = FeaturePriority.LATER,
description = "Create custom questions"
)
data object RELATIONSHIP_QUIZZES : FeatureFlag(
key = "RELATIONSHIP_QUIZZES",
status = FeatureStatus.PREMIUM,
priority = FeaturePriority.LATER,
description = "Deeper relationship quizzes"
)
data object PRIVATE_NOTES : FeatureFlag(
key = "PRIVATE_NOTES",
status = FeatureStatus.PREMIUM,
priority = FeaturePriority.LATER,
description = "Private notes per user"
)
data object EXPORT_MEMORIES : FeatureFlag(
key = "EXPORT_MEMORIES",
status = FeatureStatus.PREMIUM,
priority = FeaturePriority.LATER,
description = "Exportable memories"
)
data object ADVANCED_REMINDERS : FeatureFlag(
key = "ADVANCED_REMINDERS",
status = FeatureStatus.PREMIUM,
priority = FeaturePriority.LATER,
description = "Custom reminder times, quiet hours"
)
data object EXTRA_CATEGORIES : FeatureFlag(
key = "EXTRA_CATEGORIES",
status = FeatureStatus.PREMIUM,
priority = FeaturePriority.LATER,
description = "Extra question categories"
)
data object AI_QUESTIONS : FeatureFlag(
key = "AI_QUESTIONS",
status = FeatureStatus.PREMIUM,
priority = FeaturePriority.LATER,
description = "Future AI-assisted question suggestions"
)
// Convenience helpers
val isPremium: Boolean get() = status == FeatureStatus.PREMIUM
val isMvp: Boolean get() = priority == FeaturePriority.MVP
}

View File

@ -0,0 +1,11 @@
package app.closer.core.feature
/**
* Feature implementation priority.
* - MVP: Will be released in initial version (with or without paywall)
* - LATER: Future enhancement, not in initial release scope
*/
enum class FeaturePriority {
MVP,
LATER
}

View File

@ -0,0 +1,52 @@
package app.closer.core.feature
/**
* Central registry for all feature flags.
* Holds all defined features and provides query helpers.
*
* Usage:
* ```
* val features = FeatureRegistry.allFeatures()
* val premiumFeatures = FeatureRegistry.featuresByStatus(FeatureStatus.PREMIUM)
* val isPremium = FeatureRegistry.isPremiumFeature("CUSTOM_QUESTIONS")
* ```
*/
object FeatureRegistry {
// Query methods
fun allFeatures(): List<FeatureFlag> =
listOf(
FeatureFlag.DAILY_QUESTION,
FeatureFlag.ANSWER_HISTORY,
FeatureFlag.BASIC_CATEGORIES,
FeatureFlag.BASIC_REMINDERS,
FeatureFlag.STREAK_TRACKING,
FeatureFlag.SPIN_WHEEL_LIMITED,
FeatureFlag.PREMIUM_PACKS,
FeatureFlag.FULL_SPIN_WHEEL,
FeatureFlag.UNLIMITED_QUESTIONS,
FeatureFlag.FULL_HISTORY,
FeatureFlag.CUSTOM_QUESTIONS,
FeatureFlag.RELATIONSHIP_QUIZZES,
FeatureFlag.PRIVATE_NOTES,
FeatureFlag.EXPORT_MEMORIES,
FeatureFlag.ADVANCED_REMINDERS,
FeatureFlag.EXTRA_CATEGORIES,
FeatureFlag.AI_QUESTIONS
)
fun getFeature(key: String): FeatureFlag? =
allFeatures().find { it.key == key }
fun featuresByStatus(status: FeatureStatus): List<FeatureFlag> =
allFeatures().filter { it.status == status }
fun featuresByPriority(priority: FeaturePriority): List<FeatureFlag> =
allFeatures().filter { it.priority == priority }
fun isPremiumFeature(key: String): Boolean =
getFeature(key)?.isPremium == true
fun isMvpFeature(key: String): Boolean =
getFeature(key)?.isMvp == true
}

View File

@ -0,0 +1,11 @@
package app.closer.core.feature
/**
* Feature billing status.
* - FREE: Available to all users
* - PREMIUM: Requires active subscription/entitlement
*/
enum class FeatureStatus {
FREE,
PREMIUM
}

View File

@ -0,0 +1,35 @@
package app.closer.core.firebase
import app.closer.BuildConfig // generated by buildFeatures { buildConfig = true }
import com.google.firebase.appcheck.FirebaseAppCheck
import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FirebaseInitializer @Inject constructor() {
fun initialize() {
val appCheck = FirebaseAppCheck.getInstance()
if (BuildConfig.DEBUG) {
// DebugAppCheckProviderFactory is in the debug artifact only;
// referenced by name to avoid a compile-time dep in release.
try {
val cls = Class.forName(
"com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory"
)
val factory = cls.getMethod("getInstance").invoke(null)
as com.google.firebase.appcheck.AppCheckProviderFactory
appCheck.installAppCheckProviderFactory(factory)
} catch (_: Exception) {
appCheck.installAppCheckProviderFactory(
PlayIntegrityAppCheckProviderFactory.getInstance()
)
}
} else {
appCheck.installAppCheckProviderFactory(
PlayIntegrityAppCheckProviderFactory.getInstance()
)
}
}
}

View File

@ -0,0 +1,370 @@
package app.closer.core.navigation
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavType
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.navArgument
import app.closer.ui.auth.ForgotPasswordScreen
import app.closer.ui.answers.AnswerHistoryScreen
import app.closer.ui.answers.AnswerRevealScreen
import app.closer.ui.auth.LoginScreen
import app.closer.ui.auth.SignUpScreen
import app.closer.ui.home.HomeScreen
import app.closer.ui.home.PartnerHomeScreen
import app.closer.ui.onboarding.CreateProfileScreen
import app.closer.ui.onboarding.OnboardingScreen
import app.closer.ui.pairing.AcceptInviteScreen
import app.closer.ui.pairing.CreateInviteScreen
import app.closer.ui.pairing.EmailInviteScreen
import app.closer.ui.pairing.InviteConfirmScreen
import app.closer.ui.paywall.PaywallScreen
import app.closer.ui.questions.DailyQuestionScreen
import app.closer.ui.questions.QuestionCategoryScreen
import app.closer.ui.questions.QuestionComposerScreen
import app.closer.ui.questions.QuestionPackLibraryScreen
import app.closer.ui.questions.QuestionThreadScreen
import app.closer.ui.settings.AccountScreen
import app.closer.ui.settings.DeleteAccountScreen
import app.closer.ui.settings.NotificationSettingsScreen
import app.closer.ui.settings.PrivacyScreen
import app.closer.ui.settings.RelationshipSettingsScreen
import app.closer.ui.settings.SettingsScreen
import app.closer.ui.settings.SubscriptionScreen
import app.closer.ui.wheel.CategoryPickerScreen
import app.closer.ui.wheel.SpinWheelScreen
import app.closer.ui.wheel.WheelCompleteScreen
import app.closer.ui.wheel.WheelHistoryScreen
import app.closer.ui.wheel.WheelSessionScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppNavigation(
modifier: Modifier = Modifier,
startDestination: String = AppRoute.ONBOARDING
) {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val bottomRoutes = AppRoute.topLevelRoutes
val shellTitle = currentRoute
?.takeIf { it in shellBackRoutes }
?.let(AppRoute::titleFor)
val navigateBackOrHome: () -> Unit = {
if (!navController.popBackStack()) {
navController.navigate(AppRoute.HOME) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
}
val navigateRoute: (String) -> Unit = { route ->
if (route == "back") {
navigateBackOrHome()
} else {
navController.navigate(route)
}
}
Scaffold(
modifier = modifier,
topBar = {
if (shellTitle != null) {
TopAppBar(
title = {
Text(
text = shellTitle,
style = MaterialTheme.typography.titleLarge
)
},
navigationIcon = {
IconButton(onClick = navigateBackOrHome) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.96f)
)
)
}
},
bottomBar = {
if (currentRoute in bottomRoutes) {
AppBottomNavigation(
currentRoute = currentRoute,
onRouteSelected = { route ->
navController.navigate(route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
) { padding ->
NavHost(
navController = navController,
startDestination = startDestination,
modifier = Modifier.padding(padding)
) {
// Onboarding
composable(route = AppRoute.ONBOARDING) {
OnboardingScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.CREATE_PROFILE) {
CreateProfileScreen(onNavigate = navigateRoute)
}
// Auth
composable(route = AppRoute.LOGIN) {
LoginScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.SIGN_UP) {
SignUpScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.FORGOT_PASSWORD) {
ForgotPasswordScreen(onNavigate = navigateRoute)
}
// Home
composable(route = AppRoute.HOME) {
HomeScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.PARTNER_HOME) {
PartnerHomeScreen(onNavigate = navigateRoute)
}
// Daily Question
composable(route = AppRoute.DAILY_QUESTION) {
DailyQuestionScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.QUESTION_PACKS) {
QuestionPackLibraryScreen(onNavigate = navigateRoute)
}
composable(
route = AppRoute.QUESTION_CATEGORY,
arguments = listOf(navArgument("categoryId") { type = NavType.StringType })
) {
QuestionCategoryScreen(
categoryId = it.arguments?.getString("categoryId") ?: "",
onNavigate = navigateRoute
)
}
composable(route = AppRoute.QUESTION_COMPOSER) {
QuestionComposerScreen(onNavigate = navigateRoute)
}
// Question Thread
composable(
route = AppRoute.QUESTION_THREAD,
arguments = listOf(
navArgument("coupleId") { type = NavType.StringType },
navArgument("questionId") { type = NavType.StringType },
navArgument("prevId") {
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument("nextId") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) {
QuestionThreadScreen(
coupleId = it.arguments?.getString("coupleId") ?: "",
questionId = it.arguments?.getString("questionId") ?: "",
previousQuestionId = it.arguments?.getString("prevId"),
nextQuestionId = it.arguments?.getString("nextId"),
onNavigate = navigateRoute,
onBack = navigateBackOrHome
)
}
// Answers
composable(
route = AppRoute.ANSWER_REVEAL,
arguments = listOf(navArgument("questionId") { type = NavType.StringType })
) {
AnswerRevealScreen(
questionId = it.arguments?.getString("questionId") ?: "",
onNavigate = navigateRoute
)
}
composable(route = AppRoute.ANSWER_HISTORY) {
AnswerHistoryScreen(onNavigate = navigateRoute)
}
// Pairing
composable(route = AppRoute.CREATE_INVITE) {
CreateInviteScreen(
onNavigate = navigateRoute,
onBack = { navController.popBackStack() }
)
}
composable(route = AppRoute.EMAIL_INVITE) {
EmailInviteScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.ACCEPT_INVITE) {
AcceptInviteScreen(onNavigate = navigateRoute)
}
composable(
route = AppRoute.INVITE_CONFIRM,
arguments = listOf(navArgument("inviteCode") { type = NavType.StringType })
) {
InviteConfirmScreen(
inviteCode = it.arguments?.getString("inviteCode") ?: "",
onNavigate = navigateRoute
)
}
// Wheel / Category Selection
composable(route = AppRoute.CATEGORY_PICKER) {
CategoryPickerScreen(onNavigate = navigateRoute)
}
composable(
route = AppRoute.SPIN_WHEEL,
arguments = listOf(navArgument("categoryId") { type = NavType.StringType })
) {
SpinWheelScreen(
categoryId = it.arguments?.getString("categoryId") ?: "",
onNavigate = navigateRoute
)
}
composable(
route = AppRoute.WHEEL_SESSION,
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
) {
WheelSessionScreen(
sessionId = it.arguments?.getString("sessionId") ?: "",
onNavigate = navigateRoute
)
}
composable(
route = AppRoute.WHEEL_COMPLETE,
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
) {
WheelCompleteScreen(
sessionId = it.arguments?.getString("sessionId") ?: "",
onNavigate = navigateRoute
)
}
composable(route = AppRoute.WHEEL_HISTORY) {
WheelHistoryScreen(onNavigate = navigateRoute)
}
// Paywall
composable(route = AppRoute.PAYWALL) {
PaywallScreen(onNavigate = navigateRoute)
}
// Settings
composable(route = AppRoute.SETTINGS) {
SettingsScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.ACCOUNT) {
AccountScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.NOTIFICATIONS) {
NotificationSettingsScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.PRIVACY) {
PrivacyScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.SUBSCRIPTION) {
SubscriptionScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.RELATIONSHIP_SETTINGS) {
RelationshipSettingsScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.DELETE_ACCOUNT) {
DeleteAccountScreen(onNavigate = navigateRoute)
}
}
}
}
private data class TopLevelRoute(
val route: String,
val label: String,
val icon: ImageVector
)
private val topLevelRoutes = listOf(
TopLevelRoute(AppRoute.HOME, "Home", Icons.Filled.Home),
TopLevelRoute(AppRoute.DAILY_QUESTION, "Today", Icons.Filled.Favorite),
TopLevelRoute(AppRoute.QUESTION_PACKS, "Packs", Icons.Filled.Star),
TopLevelRoute(AppRoute.ANSWER_HISTORY, "Answers", Icons.Filled.Done),
TopLevelRoute(AppRoute.SETTINGS, "Settings", Icons.Filled.Settings)
)
private val shellBackRoutes = setOf(
AppRoute.PARTNER_HOME,
AppRoute.QUESTION_CATEGORY,
AppRoute.QUESTION_COMPOSER,
AppRoute.QUESTION_THREAD,
AppRoute.ANSWER_REVEAL,
AppRoute.CATEGORY_PICKER,
AppRoute.SPIN_WHEEL,
AppRoute.WHEEL_SESSION,
AppRoute.WHEEL_COMPLETE,
AppRoute.ACCOUNT,
AppRoute.SUBSCRIPTION,
AppRoute.PAYWALL
)
@Composable
private fun AppBottomNavigation(
currentRoute: String?,
onRouteSelected: (String) -> Unit
) {
NavigationBar {
topLevelRoutes.forEach { item ->
NavigationBarItem(
selected = currentRoute == item.route,
onClick = { onRouteSelected(item.route) },
icon = {
Icon(
imageVector = item.icon,
contentDescription = item.label
)
},
label = { Text(item.label) }
)
}
}
}

View File

@ -0,0 +1,155 @@
package app.closer.core.navigation
import android.net.Uri
object AppRoute {
const val ONBOARDING = "onboarding"
const val LOGIN = "login"
const val SIGN_UP = "sign_up"
const val FORGOT_PASSWORD = "forgot_password"
const val CREATE_PROFILE = "create_profile"
const val HOME = "home"
const val PARTNER_HOME = "partner_home"
const val DAILY_QUESTION = "daily_question"
const val QUESTION_PACKS = "question_packs"
const val QUESTION_CATEGORY = "question_category/{categoryId}"
const val QUESTION_COMPOSER = "question_composer"
const val ANSWER_REVEAL = "answer_reveal/{questionId}"
const val ANSWER_HISTORY = "answer_history"
const val CREATE_INVITE = "create_invite"
const val EMAIL_INVITE = "email_invite"
const val ACCEPT_INVITE = "accept_invite"
const val INVITE_CONFIRM = "invite_confirm/{inviteCode}"
const val CATEGORY_PICKER = "category_picker"
const val SPIN_WHEEL = "spin_wheel/{categoryId}"
const val WHEEL_SESSION = "wheel_session/{sessionId}"
const val WHEEL_COMPLETE = "wheel_complete/{sessionId}"
const val PAYWALL = "paywall"
const val SETTINGS = "settings"
const val ACCOUNT = "account"
const val NOTIFICATIONS = "notifications"
const val PRIVACY = "privacy"
const val SUBSCRIPTION = "subscription"
const val RELATIONSHIP_SETTINGS = "relationship_settings"
const val DELETE_ACCOUNT = "delete_account"
const val WHEEL_HISTORY = "wheel_history"
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
const val QUESTION_THREAD =
"question_thread/{coupleId}/{questionId}?prevId={prevId}&nextId={nextId}"
data class Definition(
val route: String,
val title: String,
val group: String
)
val definitions = listOf(
Definition(ONBOARDING, "Onboarding", "onboarding"),
Definition(CREATE_PROFILE, "Create Profile", "onboarding"),
Definition(LOGIN, "Login", "auth"),
Definition(SIGN_UP, "Sign Up", "auth"),
Definition(FORGOT_PASSWORD, "Forgot Password", "auth"),
Definition(HOME, "Home", "home"),
Definition(PARTNER_HOME, "Partner", "home"),
Definition(DAILY_QUESTION, "Daily Question", "questions"),
Definition(QUESTION_PACKS, "Question Packs", "questions"),
Definition(QUESTION_CATEGORY, "Question Pack", "questions"),
Definition(QUESTION_COMPOSER, "New Question", "questions"),
Definition(QUESTION_THREAD, "Answer", "questions"),
Definition(ANSWER_REVEAL, "Reveal", "answers"),
Definition(ANSWER_HISTORY, "Answer History", "answers"),
Definition(CREATE_INVITE, "Create Invite", "pairing"),
Definition(EMAIL_INVITE, "Email Invite", "pairing"),
Definition(ACCEPT_INVITE, "Accept Invite", "pairing"),
Definition(INVITE_CONFIRM, "Invite Confirm", "pairing"),
Definition(CATEGORY_PICKER, "Choose A Category", "wheel"),
Definition(SPIN_WHEEL, "Spin", "wheel"),
Definition(WHEEL_SESSION, "Wheel Session", "wheel"),
Definition(WHEEL_COMPLETE, "Complete", "wheel"),
Definition(PAYWALL, "Unlock Everything", "paywall"),
Definition(SETTINGS, "Settings", "settings"),
Definition(ACCOUNT, "Account", "settings"),
Definition(NOTIFICATIONS, "Notifications", "settings"),
Definition(PRIVACY, "Privacy", "settings"),
Definition(SUBSCRIPTION, "Subscription", "settings"),
Definition(RELATIONSHIP_SETTINGS, "Relationship Settings", "settings"),
Definition(DELETE_ACCOUNT, "Delete Account", "settings"),
Definition(WHEEL_HISTORY, "Wheel History", "wheel")
)
val topLevelRoutes = setOf(
HOME,
DAILY_QUESTION,
QUESTION_PACKS,
ANSWER_HISTORY,
SETTINGS
)
val onboardingAuthRoutes = setOf(
ONBOARDING,
CREATE_PROFILE,
LOGIN,
SIGN_UP,
FORGOT_PASSWORD
)
val modalLikeRoutes = setOf(
CREATE_INVITE,
EMAIL_INVITE,
ACCEPT_INVITE,
INVITE_CONFIRM,
PAYWALL
)
val drillInRoutes = setOf(
PARTNER_HOME,
QUESTION_CATEGORY,
QUESTION_COMPOSER,
QUESTION_THREAD,
ANSWER_REVEAL,
CATEGORY_PICKER,
SPIN_WHEEL,
WHEEL_SESSION,
WHEEL_COMPLETE,
WHEEL_HISTORY,
ACCOUNT,
NOTIFICATIONS,
PRIVACY,
SUBSCRIPTION,
RELATIONSHIP_SETTINGS,
DELETE_ACCOUNT
)
fun titleFor(route: String?): String? =
definitions.firstOrNull { it.route == route }?.title
fun answerReveal(questionId: String): String = "answer_reveal/${questionId.asRouteArg()}"
fun inviteConfirm(inviteCode: String): String = "invite_confirm/${inviteCode.asRouteArg()}"
fun questionCategory(categoryId: String): String = "question_category/${categoryId.asRouteArg()}"
fun spinWheel(categoryId: String): String = "spin_wheel/${categoryId.asRouteArg()}"
fun wheelSession(sessionId: String): String = "wheel_session/${sessionId.asRouteArg()}"
fun wheelComplete(sessionId: String): String = "wheel_complete/${sessionId.asRouteArg()}"
fun questionThread(
coupleId: String,
questionId: String,
prevId: String? = null,
nextId: String? = null
): String {
var route = "question_thread/${coupleId.asRouteArg()}/${questionId.asRouteArg()}"
val params = buildList {
prevId?.let { add("prevId=${it.asRouteArg()}") }
nextId?.let { add("nextId=${it.asRouteArg()}") }
}
if (params.isNotEmpty()) route += "?" + params.joinToString("&")
return route
}
private fun String.asRouteArg(): String = Uri.encode(this)
}

View File

@ -0,0 +1,8 @@
package app.closer.core.navigation
object ExternalLinks {
const val PRIVACY_POLICY = "https://couplesconnect.app/privacy"
const val TERMS_OF_SERVICE = "https://couplesconnect.app/terms"
const val SUBSCRIPTION_TERMS = "https://couplesconnect.app/subscription-terms"
const val SUPPORT = "https://couplesconnect.app/support"
}

View File

@ -0,0 +1,58 @@
package app.closer.core.notifications
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.UserRepository
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class AppMessagingService : FirebaseMessagingService() {
@Inject lateinit var authRepository: AuthRepository
@Inject lateinit var userRepository: UserRepository
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
NotificationHelper.createChannels(this)
}
override fun onDestroy() {
super.onDestroy()
serviceScope.cancel()
}
override fun onNewToken(token: String) {
val uid = authRepository.currentUserId ?: return
serviceScope.launch {
runCatching { userRepository.storeFcmToken(uid, token) }
}
}
override fun onMessageReceived(message: RemoteMessage) {
val title = message.notification?.title ?: message.data["title"] ?: return
val body = message.notification?.body ?: message.data["body"] ?: return
val type = message.data["type"] ?: "general"
val channelId = when (type) {
"partner_answered" -> NotificationHelper.CHANNEL_PARTNER
else -> NotificationHelper.CHANNEL_REMINDERS
}
NotificationHelper.show(
context = this,
id = System.currentTimeMillis().toInt(),
channelId = channelId,
title = title,
body = body
)
}
}

View File

@ -0,0 +1,60 @@
package app.closer.core.notifications
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import app.closer.MainActivity
import app.closer.R
object NotificationHelper {
const val CHANNEL_REMINDERS = "reminders"
const val CHANNEL_PARTNER = "partner_activity"
fun createChannels(context: Context) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(
NotificationChannel(
CHANNEL_REMINDERS,
"Daily reminders",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Daily question and streak reminders"
}
)
nm.createNotificationChannel(
NotificationChannel(
CHANNEL_PARTNER,
"Partner activity",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "When your partner answers a question"
}
)
}
fun show(context: Context, id: Int, channelId: String, title: String, body: String) {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pending = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pending)
.build()
if (NotificationManagerCompat.from(context).areNotificationsEnabled()) {
NotificationManagerCompat.from(context).notify(id, notification)
}
}
}

View File

@ -0,0 +1,19 @@
package app.closer.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import app.closer.data.local.converters.Converters
import app.closer.data.local.entity.CategoryEntity
import app.closer.data.local.entity.QuestionEntity
@Database(
entities = [QuestionEntity::class, CategoryEntity::class],
version = 1,
exportSchema = false
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun questionDao(): QuestionDao
abstract fun categoryDao(): CategoryDao
}

View File

@ -0,0 +1,29 @@
package app.closer.data.local
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import app.closer.data.local.entity.CategoryEntity
@Dao
interface CategoryDao {
@Query("SELECT * FROM question_category ORDER BY display_name ASC")
suspend fun getAllCategories(): List<CategoryEntity>
@Query("SELECT * FROM question_category WHERE id = :id LIMIT 1")
suspend fun getCategoryById(id: String): CategoryEntity?
@Query("SELECT * FROM question_category WHERE access = :access")
suspend fun getCategoriesByAccess(access: String): List<CategoryEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(category: CategoryEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(categories: List<CategoryEntity>)
@Delete
suspend fun delete(category: CategoryEntity)
}

View File

@ -0,0 +1,38 @@
package app.closer.data.local
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import app.closer.data.local.entity.QuestionEntity
@Dao
interface QuestionDao {
@Query("SELECT * FROM question WHERE id = :id LIMIT 1")
suspend fun getQuestionById(id: String): QuestionEntity?
@Query("SELECT * FROM question WHERE status = 'active' AND is_premium = 0 ORDER BY RANDOM() LIMIT 1")
suspend fun getDailyQuestion(): QuestionEntity?
@Query("SELECT * FROM question WHERE category_id = :categoryId AND status = 'active' ORDER BY depth_level ASC, id ASC")
suspend fun getQuestionsByCategory(categoryId: String): List<QuestionEntity>
@Query("SELECT COUNT(*) FROM question WHERE category_id = :categoryId AND status = 'active'")
suspend fun getQuestionCountByCategory(categoryId: String): Int
@Query("SELECT * FROM question WHERE is_premium = 0 AND status = 'active'")
suspend fun getFreeQuestions(): List<QuestionEntity>
@Query("SELECT * FROM question WHERE is_premium = 1 AND status = 'active'")
suspend fun getPremiumQuestions(): List<QuestionEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(question: QuestionEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(questions: List<QuestionEntity>)
@Delete
suspend fun delete(question: QuestionEntity)
}

View File

@ -0,0 +1,49 @@
package app.closer.data.local
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import app.closer.domain.repository.AppSettings
import app.closer.domain.repository.SettingsRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SettingsDataStore @Inject constructor(
private val dataStore: DataStore<Preferences>
) : SettingsRepository {
private val DAILY_REMINDER = booleanPreferencesKey("daily_reminder")
private val PARTNER_ANSWERED = booleanPreferencesKey("partner_answered")
private val STREAK_REMINDER = booleanPreferencesKey("streak_reminder")
private val QUIET_HOURS = booleanPreferencesKey("quiet_hours")
private val ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete")
override val settings: Flow<AppSettings> = dataStore.data.map { prefs ->
AppSettings(
dailyReminderEnabled = prefs[DAILY_REMINDER] ?: true,
partnerAnsweredEnabled = prefs[PARTNER_ANSWERED] ?: true,
streakReminderEnabled = prefs[STREAK_REMINDER] ?: false,
quietHoursEnabled = prefs[QUIET_HOURS] ?: false,
onboardingComplete = prefs[ONBOARDING_COMPLETE] ?: false
)
}
override suspend fun setDailyReminder(enabled: Boolean) =
dataStore.edit { it[DAILY_REMINDER] = enabled }.let {}
override suspend fun setPartnerAnswered(enabled: Boolean) =
dataStore.edit { it[PARTNER_ANSWERED] = enabled }.let {}
override suspend fun setStreakReminder(enabled: Boolean) =
dataStore.edit { it[STREAK_REMINDER] = enabled }.let {}
override suspend fun setQuietHours(enabled: Boolean) =
dataStore.edit { it[QUIET_HOURS] = enabled }.let {}
override suspend fun setOnboardingComplete(complete: Boolean) =
dataStore.edit { it[ONBOARDING_COMPLETE] = complete }.let {}
}

View File

@ -0,0 +1,15 @@
package app.closer.data.local.converters
import androidx.room.TypeConverter
class Converters {
@TypeConverter
fun fromStringList(value: List<String>?): String? {
return value?.toString()
}
@TypeConverter
fun toStringList(value: String?): List<String>? {
return value?.takeIf { it != "[]" }?.drop(1)?.dropLast(1)?.split(", ")?.map { it.trim() }
}
}

View File

@ -0,0 +1,14 @@
package app.closer.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "question_category")
data class CategoryEntity(
@PrimaryKey val id: String,
@ColumnInfo(name = "display_name") val displayName: String,
val description: String,
val access: String,
@ColumnInfo(name = "icon_name") val iconName: String
)

View File

@ -0,0 +1,20 @@
package app.closer.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "question")
data class QuestionEntity(
@PrimaryKey val id: String,
@ColumnInfo val text: String,
@ColumnInfo(name = "category_id") val categoryId: String,
@ColumnInfo(name = "depth_level") val depthLevel: Int,
@ColumnInfo(name = "is_premium") val isPremium: Boolean,
@ColumnInfo val type: String,
val tags: String,
@ColumnInfo(name = "answer_config") val answerConfig: String,
@ColumnInfo(name = "pack_id") val packId: String?,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo val status: String
)

View File

@ -0,0 +1,100 @@
package app.closer.data.local.mapper
import app.closer.data.local.entity.QuestionEntity
import app.closer.data.local.entity.CategoryEntity
import app.closer.domain.model.ChoiceAnswerConfig
import app.closer.domain.model.ChoiceAnswerConfigImpl
import app.closer.domain.model.ChoiceOption
import app.closer.domain.model.Question
import app.closer.domain.model.QuestionCategory
import app.closer.domain.model.ScaleAnswerConfig
import app.closer.domain.model.ScaleAnswerConfigImpl
import app.closer.domain.model.ThisOrThatAnswerConfig
import app.closer.domain.model.ThisOrThatAnswerConfigImpl
import app.closer.domain.model.WrittenAnswerConfig
import app.closer.domain.model.WrittenAnswerConfigImpl
import org.json.JSONArray
import org.json.JSONObject
fun QuestionEntity.toQuestion(): Question {
return Question(
id = id,
text = text,
category = categoryId,
depthLevel = depthLevel,
isPremium = isPremium,
type = type,
tags = parseTags(tags),
answerConfig = parseAnswerConfig(answerConfig, type),
packId = packId,
createdAt = createdAt,
status = status
)
}
fun CategoryEntity.toQuestionCategory(): QuestionCategory {
return QuestionCategory(
id = id,
displayName = displayName,
description = description,
access = access,
iconName = iconName
)
}
private fun parseTags(raw: String): List<String> = try {
val arr = JSONArray(raw)
(0 until arr.length()).map { arr.getString(it) }
} catch (e: Exception) {
emptyList()
}
private fun parseAnswerConfig(raw: String, questionType: String) = try {
val json = JSONObject(raw)
val configObj = json.optJSONObject("config")
when (questionType) {
"written" -> WrittenAnswerConfigImpl(
config = WrittenAnswerConfig(
minLength = configObj?.optInt("minLength", 1) ?: 1,
maxLength = configObj?.optInt("maxLength", 1000) ?: 1000,
placeholder = configObj?.optString("placeholder", "Write your answer...") ?: "Write your answer..."
)
)
"single_choice", "multi_choice" -> {
val optionsArr = configObj?.optJSONArray("options")
val options = parseOptions(optionsArr)
ChoiceAnswerConfigImpl(type = questionType, config = ChoiceAnswerConfig(options = options))
}
"scale" -> ScaleAnswerConfigImpl(
config = ScaleAnswerConfig(
minScale = configObj?.optInt("minScale", 1) ?: 1,
maxScale = configObj?.optInt("maxScale", 5) ?: 5,
minLabel = configObj?.optString("minLabel", "") ?: "",
maxLabel = configObj?.optString("maxLabel", "") ?: "",
scaleStep = configObj?.optInt("scaleStep", 1) ?: 1
)
)
"this_or_that" -> {
val optA = configObj?.optJSONObject("optionA")
val optB = configObj?.optJSONObject("optionB")
if (optA != null && optB != null) {
ThisOrThatAnswerConfigImpl(
config = ThisOrThatAnswerConfig(
optionA = ChoiceOption(id = optA.optString("id", "a"), text = optA.optString("text", "")),
optionB = ChoiceOption(id = optB.optString("id", "b"), text = optB.optString("text", ""))
)
)
} else null
}
else -> null
}
} catch (e: Exception) { null }
private fun parseOptions(arr: org.json.JSONArray?): List<ChoiceOption> {
arr ?: return emptyList()
return (0 until arr.length()).mapNotNull { i ->
arr.optJSONObject(i)?.let { obj ->
ChoiceOption(id = obj.optString("id", ""), text = obj.optString("text", ""))
}
}
}

View File

@ -0,0 +1,189 @@
package app.closer.data.questions
import app.closer.domain.model.*
import org.json.JSONArray
import org.json.JSONObject
/**
* Parses a JSON file containing question data in the v2 schema format.
*
* Expected JSON structure:
* {
* "category": { ... },
* "questions": [
* {
* "id": "...",
* "category_id": "...",
* "type": "written|single_choice|multi_choice|scale|this_or_that",
* "text": "...",
* "depth": 1-5,
* "access": "free|premium",
* "tags": [...],
* "answer_config": { ... }
* }
* ]
* }
*/
object QuestionJsonParser {
data class ParsedQuestionBundle(
val category: QuestionCategory,
val questions: List<Question>
)
fun parseFromFile(jsonFilePath: String): ParsedQuestionBundle? {
val jsonContent = try {
java.io.File(jsonFilePath).readText()
} catch (e: Exception) {
android.util.Log.e("QuestionJsonParser", "Failed to read JSON file: ${e.message}")
return null
}
return parseFromJsonString(jsonContent)
}
fun parseFromJsonString(jsonString: String): ParsedQuestionBundle? {
return try {
val root = JSONObject(jsonString)
val categoryObj = root.getJSONObject("category")
val questionsArray = root.getJSONArray("questions")
val category = parseCategory(categoryObj)
val questions = mutableListOf<Question>()
for (i in 0 until questionsArray.length()) {
val questionObj = questionsArray.getJSONObject(i)
questionObj?.let { q ->
questions.add(parseQuestion(q, category.id))
}
}
ParsedQuestionBundle(category, questions)
} catch (e: Exception) {
android.util.Log.e("QuestionJsonParser", "Failed to parse JSON: ${e.message}")
null
}
}
private fun parseCategory(obj: JSONObject): QuestionCategory {
return QuestionCategory(
id = obj.optString("id", ""),
displayName = obj.optString("display_name", ""),
description = obj.optString("description", ""),
access = obj.optString("access", "free"),
iconName = obj.optString("icon_name", "message")
)
}
private fun parseQuestion(obj: JSONObject, categoryId: String): Question {
val type = obj.optString("type", "written")
val tags = obj.optJSONArray("tags")?.let { arr ->
mutableListOf<String>().apply {
for (i in 0 until arr.length()) {
add(arr.optString(i))
}
}.toList()
} ?: emptyList()
val answerConfig = parseAnswerConfig(obj, type)
return Question(
id = obj.optString("id", ""),
text = obj.optString("text", ""),
category = categoryId,
depthLevel = obj.optInt("depth", 1),
isPremium = obj.optString("access", "free") == "premium",
type = type,
tags = tags,
answerConfig = answerConfig
)
}
private fun parseAnswerConfig(obj: JSONObject, type: String): AnswerConfig? {
val answerConfigObj = obj.optJSONObject("answer_config")
if (answerConfigObj == null) return null
return when (type) {
"written" -> {
WrittenAnswerConfigImpl(
config = WrittenAnswerConfig(
minLength = answerConfigObj.optInt("min_length", 1),
maxLength = answerConfigObj.optInt("max_length", 1000),
placeholder = answerConfigObj.optString("placeholder", "Write your answer...")
)
)
}
"single_choice" -> {
val options = mutableListOf<ChoiceOption>()
val optionsArray = obj.optJSONArray("options")
if (optionsArray != null) {
for (i in 0 until optionsArray.length()) {
val opt = optionsArray.optJSONObject(i)
opt?.let { o ->
options.add(ChoiceOption(
id = o.optString("id", ""),
text = o.optString("text", "")
))
}
}
}
ChoiceAnswerConfigImpl(
config = ChoiceAnswerConfig(options = options.toList())
)
}
"scale" -> {
ScaleAnswerConfigImpl(
config = ScaleAnswerConfig(
minScale = answerConfigObj.optInt("min_scale", 1),
maxScale = answerConfigObj.optInt("max_scale", 5),
minLabel = answerConfigObj.optString("min_label", ""),
maxLabel = answerConfigObj.optString("max_label", ""),
scaleStep = answerConfigObj.optInt("scale_step", 1)
)
)
}
"this_or_that" -> {
val optionsArray = obj.optJSONArray("options")
if (optionsArray?.length() == 2) {
val optA = optionsArray.optJSONObject(0)
val optB = optionsArray.optJSONObject(1)
ThisOrThatAnswerConfigImpl(
config = ThisOrThatAnswerConfig(
optionA = ChoiceOption(
id = optA?.optString("id", "") ?: "",
text = optA?.optString("text", "") ?: ""
),
optionB = ChoiceOption(
id = optB?.optString("id", "") ?: "",
text = optB?.optString("text", "") ?: ""
)
)
)
} else {
null
}
}
"multi_choice" -> {
// For now, treat multi_choice like single_choice (can be extended later)
val options = mutableListOf<ChoiceOption>()
val optionsArray = obj.optJSONArray("options")
if (optionsArray != null) {
for (i in 0 until optionsArray.length()) {
val opt = optionsArray.optJSONObject(i)
opt?.let { o ->
options.add(ChoiceOption(
id = o.optString("id", ""),
text = o.optString("text", "")
))
}
}
}
ChoiceAnswerConfigImpl(
type = "multi_choice",
config = ChoiceAnswerConfig(options = options.toList())
)
}
else -> null
}
}
}

View File

@ -0,0 +1,77 @@
package app.closer.data.remote
import app.closer.domain.model.AuthState
import com.google.firebase.auth.FirebaseAuth
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Singleton
class FirebaseAuthDataSource @Inject constructor() {
private val auth = FirebaseAuth.getInstance()
val currentUserId: String? get() = auth.currentUser?.uid
val currentUserEmail: String? get() = auth.currentUser?.email
val isSignedIn: Boolean get() = auth.currentUser != null
val authState: Flow<AuthState> = callbackFlow {
trySend(snapshot())
val listener = FirebaseAuth.AuthStateListener { fa ->
trySend(
fa.currentUser?.let { AuthState.Authenticated(it.uid, it.isAnonymous) }
?: AuthState.Unauthenticated
)
}
auth.addAuthStateListener(listener)
awaitClose { auth.removeAuthStateListener(listener) }
}
private fun snapshot(): AuthState =
auth.currentUser?.let { AuthState.Authenticated(it.uid, it.isAnonymous) }
?: AuthState.Unauthenticated
suspend fun signInAnonymously(): String =
suspendCancellableCoroutine { cont ->
auth.signInAnonymously()
.addOnSuccessListener { cont.resume(it.user?.uid ?: "") }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun signInWithEmail(email: String, password: String): String =
suspendCancellableCoroutine { cont ->
auth.signInWithEmailAndPassword(email, password)
.addOnSuccessListener { cont.resume(it.user?.uid ?: "") }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun signUpWithEmail(email: String, password: String): String =
suspendCancellableCoroutine { cont ->
auth.createUserWithEmailAndPassword(email, password)
.addOnSuccessListener { cont.resume(it.user?.uid ?: "") }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun sendPasswordResetEmail(email: String): Unit =
suspendCancellableCoroutine { cont ->
auth.sendPasswordResetEmail(email)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
fun signOut() = auth.signOut()
suspend fun deleteAccount(): Unit =
suspendCancellableCoroutine { cont ->
auth.currentUser
?.delete()
?.addOnSuccessListener { cont.resume(Unit) }
?.addOnFailureListener { cont.resumeWithException(it) }
?: cont.resumeWithException(IllegalStateException("No signed-in user"))
}
}

View File

@ -0,0 +1,130 @@
package app.closer.data.remote
import app.closer.domain.model.Couple
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Singleton
class FirestoreCoupleDataSource @Inject constructor() {
private val db = FirebaseFirestore.getInstance()
private fun coupleRef(coupleId: String) = db.collection("couples").document(coupleId)
private fun userRef(uid: String) = db.collection("users").document(uid)
suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): String {
val coupleId = UUID.randomUUID().toString()
val now = System.currentTimeMillis()
createCoupleDoc(coupleId, inviterUserId, acceptorUserId, inviteCode, now)
updateUserCoupleId(inviterUserId, coupleId)
updateUserCoupleId(acceptorUserId, coupleId)
return coupleId
}
private suspend fun createCoupleDoc(
coupleId: String,
inviterUserId: String,
acceptorUserId: String,
inviteCode: String,
now: Long
): Unit = suspendCancellableCoroutine { cont ->
coupleRef(coupleId).set(
mapOf(
"id" to coupleId,
"userIds" to listOf(inviterUserId, acceptorUserId),
"inviteCode" to inviteCode,
"createdAt" to now,
"streakCount" to 0
)
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun updateUserCoupleId(uid: String, coupleId: String): Unit =
suspendCancellableCoroutine { cont ->
userRef(uid).set(
mapOf("coupleId" to coupleId),
SetOptions.merge()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun getCoupleById(coupleId: String): Couple? =
suspendCancellableCoroutine { cont ->
coupleRef(coupleId).get()
.addOnSuccessListener { snap ->
if (!snap.exists()) { cont.resume(null); return@addOnSuccessListener }
cont.resume(snap.toCouple())
}
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun updateStreak(coupleId: String) {
val snap = suspendCancellableCoroutine<DocumentSnapshot> { cont ->
coupleRef(coupleId).get()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
}
val lastAnsweredAt = snap.getLong("lastAnsweredAt") ?: 0L
val now = System.currentTimeMillis()
val current = (snap.getLong("streakCount") ?: 0L).toInt()
val newStreak = if (lastAnsweredAt == 0L || now - lastAnsweredAt > TWO_DAYS_MS) 1 else current + 1
suspendCancellableCoroutine<Unit> { cont ->
coupleRef(coupleId).set(
mapOf("streakCount" to newStreak, "lastAnsweredAt" to now),
SetOptions.merge()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
}
suspend fun leaveCouple(userId: String) {
val userSnap = suspendCancellableCoroutine<DocumentSnapshot> { cont ->
db.collection("users").document(userId).get()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
}
val coupleId = userSnap.getString("coupleId") ?: return
val coupleSnap = suspendCancellableCoroutine<DocumentSnapshot> { cont ->
coupleRef(coupleId).get()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
}
@Suppress("UNCHECKED_CAST")
val allUserIds = (coupleSnap.get("userIds") as? List<String>) ?: listOf(userId)
suspendCancellableCoroutine<Unit> { cont ->
val batch = db.batch()
allUserIds.forEach { uid ->
batch.update(db.collection("users").document(uid), "coupleId", null)
}
batch.commit()
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
}
@Suppress("UNCHECKED_CAST")
private fun DocumentSnapshot.toCouple() = Couple(
id = id,
userIds = (get("userIds") as? List<String>) ?: emptyList(),
inviteCode = getString("inviteCode") ?: "",
createdAt = getLong("createdAt") ?: 0L,
currentQuestionId = getString("currentQuestionId"),
streakCount = (getLong("streakCount") ?: 0L).toInt(),
lastAnsweredAt = getLong("lastAnsweredAt"),
activePackId = getString("activePackId")
)
companion object {
private const val TWO_DAYS_MS = 48L * 60 * 60 * 1000
}
}

View File

@ -0,0 +1,80 @@
package app.closer.data.remote
import app.closer.domain.model.Invite
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.random.Random
@Singleton
class FirestoreInviteDataSource @Inject constructor() {
private val db = FirebaseFirestore.getInstance()
private fun inviteRef(code: String) = db.collection("invites").document(code)
fun generateCode(): String = (1..6)
.map { CODE_CHARS[Random.nextInt(CODE_CHARS.length)] }
.joinToString("")
suspend fun createInvite(code: String, inviterUserId: String): Unit =
suspendCancellableCoroutine { cont ->
val now = System.currentTimeMillis()
inviteRef(code).set(
mapOf(
"code" to code,
"inviterUserId" to inviterUserId,
"status" to "pending",
"createdAt" to now,
"expiresAt" to now + 24 * 60 * 60 * 1000L
)
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun getInviteByCode(code: String): Invite? =
suspendCancellableCoroutine { cont ->
inviteRef(code).get()
.addOnSuccessListener { snap ->
if (!snap.exists()) { cont.resume(null); return@addOnSuccessListener }
cont.resume(
Invite(
id = snap.id,
code = snap.getString("code") ?: snap.id,
inviterUserId = snap.getString("inviterUserId") ?: "",
inviteeEmail = snap.getString("inviteeEmail"),
coupleId = snap.getString("coupleId"),
status = snap.getString("status") ?: "pending",
createdAt = snap.getLong("createdAt") ?: 0L,
expiresAt = snap.getLong("expiresAt") ?: 0L,
acceptedAt = snap.getLong("acceptedAt"),
acceptedByUserId = snap.getString("acceptedByUserId")
)
)
}
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Unit =
suspendCancellableCoroutine { cont ->
inviteRef(code).set(
mapOf(
"status" to "accepted",
"acceptedByUserId" to acceptorUserId,
"acceptedAt" to System.currentTimeMillis(),
"coupleId" to coupleId
),
SetOptions.merge()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
companion object {
private const val CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
}
}

View File

@ -0,0 +1,245 @@
package app.closer.data.remote
import app.closer.domain.model.QuestionAnswer
import app.closer.domain.model.QuestionMessage
import app.closer.domain.model.QuestionReaction
import app.closer.domain.model.QuestionThread
import app.closer.domain.model.QuestionThreadStatus
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FieldValue
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.Query
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Singleton
class FirestoreQuestionThreadDataSource @Inject constructor() {
private val db = FirebaseFirestore.getInstance()
private fun threadsRef(coupleId: String) =
db.collection("couples").document(coupleId).collection("question_threads")
// ─── Thread ─────────────────────────────────────────────────────────────────
suspend fun findThreadByQuestionId(coupleId: String, questionId: String): String? {
val snap = threadsRef(coupleId)
.whereEqualTo("questionId", questionId)
.limit(1)
.getAwait()
return snap.documents.firstOrNull()?.id
}
suspend fun createThread(coupleId: String, questionId: String, categoryId: String): String {
val now = FieldValue.serverTimestamp()
val doc = threadsRef(coupleId).document()
doc.set(
mapOf(
"questionId" to questionId,
"categoryId" to categoryId,
"status" to QuestionThreadStatus.NOT_STARTED.toFirestoreValue(),
"currentIndex" to 0,
"createdAt" to now,
"updatedAt" to now
)
).voidAwait()
return doc.id
}
fun observeThread(coupleId: String, threadId: String): Flow<QuestionThread> = callbackFlow {
val listener = threadsRef(coupleId).document(threadId)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
trySend(snap.toQuestionThread(coupleId))
}
awaitClose { listener.remove() }
}
suspend fun updateThreadStatus(coupleId: String, threadId: String, status: QuestionThreadStatus) {
threadsRef(coupleId).document(threadId)
.update(
mapOf(
"status" to status.toFirestoreValue(),
"updatedAt" to FieldValue.serverTimestamp()
)
).voidAwait()
}
// ─── Answers ─────────────────────────────────────────────────────────────────
suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
val now = FieldValue.serverTimestamp()
threadsRef(coupleId)
.document(threadId)
.collection("answers")
.document(userId)
.set(
mapOf(
"userId" to answer.userId,
"questionId" to answer.questionId,
"answerType" to answer.answerType,
"writtenText" to answer.writtenText,
"selectedOptionIds" to answer.selectedOptionIds,
"scaleValue" to answer.scaleValue,
"createdAt" to now,
"updatedAt" to now
)
).voidAwait()
}
suspend fun getAnswerCount(coupleId: String, threadId: String): Int {
val snap = threadsRef(coupleId)
.document(threadId)
.collection("answers")
.getAwait()
return snap.size()
}
fun observeAnswers(coupleId: String, threadId: String): Flow<List<QuestionAnswer>> = callbackFlow {
val listener = threadsRef(coupleId)
.document(threadId)
.collection("answers")
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
trySend(snap.documents.mapNotNull { it.toQuestionAnswer() })
}
awaitClose { listener.remove() }
}
// ─── Messages ────────────────────────────────────────────────────────────────
suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) {
threadsRef(coupleId)
.document(threadId)
.collection("messages")
.add(
mapOf(
"userId" to message.userId,
"text" to message.text,
"createdAt" to FieldValue.serverTimestamp()
)
).refAwait()
}
fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>> = callbackFlow {
val listener = threadsRef(coupleId)
.document(threadId)
.collection("messages")
.orderBy("createdAt", Query.Direction.ASCENDING)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
trySend(snap.documents.mapNotNull { it.toQuestionMessage() })
}
awaitClose { listener.remove() }
}
// ─── Reactions ───────────────────────────────────────────────────────────────
suspend fun addReaction(coupleId: String, threadId: String, reaction: QuestionReaction) {
val docId = "${reaction.userId}_${reaction.targetUserId}"
threadsRef(coupleId)
.document(threadId)
.collection("reactions")
.document(docId)
.set(
mapOf(
"userId" to reaction.userId,
"targetUserId" to reaction.targetUserId,
"emoji" to reaction.emoji,
"createdAt" to FieldValue.serverTimestamp()
)
).voidAwait()
}
fun observeReactions(coupleId: String, threadId: String): Flow<List<QuestionReaction>> = callbackFlow {
val listener = threadsRef(coupleId)
.document(threadId)
.collection("reactions")
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
trySend(snap.documents.mapNotNull { it.toQuestionReaction() })
}
awaitClose { listener.remove() }
}
// ─── Coroutine helpers ───────────────────────────────────────────────────────
private suspend fun com.google.firebase.firestore.CollectionReference.getAwait() =
get().queryAwait()
private suspend fun com.google.firebase.firestore.Query.getAwait() =
get().queryAwait()
private suspend fun com.google.android.gms.tasks.Task<com.google.firebase.firestore.QuerySnapshot>.queryAwait() =
suspendCancellableCoroutine { cont ->
addOnSuccessListener { cont.resume(it) }
addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun com.google.android.gms.tasks.Task<Void>.voidAwait() =
suspendCancellableCoroutine<Unit> { cont ->
addOnSuccessListener { cont.resume(Unit) }
addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun com.google.android.gms.tasks.Task<com.google.firebase.firestore.DocumentReference>.refAwait() =
suspendCancellableCoroutine<Unit> { cont ->
addOnSuccessListener { cont.resume(Unit) }
addOnFailureListener { cont.resumeWithException(it) }
}
// ─── Document mappers ────────────────────────────────────────────────────────
private fun DocumentSnapshot.toQuestionThread(coupleId: String) = QuestionThread(
id = id,
coupleId = coupleId,
questionId = getString("questionId") ?: "",
categoryId = getString("categoryId") ?: "",
status = QuestionThreadStatus.fromFirestoreValue(getString("status") ?: ""),
currentIndex = getLong("currentIndex")?.toInt() ?: 0,
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L,
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
)
@Suppress("UNCHECKED_CAST")
private fun DocumentSnapshot.toQuestionAnswer(): QuestionAnswer? {
val userId = getString("userId") ?: return null
return QuestionAnswer(
userId = userId,
questionId = getString("questionId") ?: "",
answerType = getString("answerType") ?: "written",
writtenText = getString("writtenText"),
selectedOptionIds = (get("selectedOptionIds") as? List<String>) ?: emptyList(),
scaleValue = getLong("scaleValue")?.toInt(),
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L,
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
)
}
private fun DocumentSnapshot.toQuestionMessage(): QuestionMessage? {
val userId = getString("userId") ?: return null
return QuestionMessage(
id = id,
userId = userId,
text = getString("text") ?: "",
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
)
}
private fun DocumentSnapshot.toQuestionReaction(): QuestionReaction? {
val userId = getString("userId") ?: return null
return QuestionReaction(
id = id,
userId = userId,
targetUserId = getString("targetUserId") ?: "",
emoji = getString("emoji") ?: "",
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
)
}
}

View File

@ -0,0 +1,99 @@
package app.closer.data.remote
import app.closer.domain.model.User
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Singleton
class FirestoreUserDataSource @Inject constructor() {
private val db = FirebaseFirestore.getInstance()
private fun userRef(uid: String) = db.collection("users").document(uid)
suspend fun getUser(uid: String): User? =
suspendCancellableCoroutine { cont ->
userRef(uid).get()
.addOnSuccessListener { snap ->
if (!snap.exists()) { cont.resume(null); return@addOnSuccessListener }
cont.resume(
User(
id = snap.id,
email = snap.getString("email") ?: "",
displayName = snap.getString("displayName") ?: "",
photoUrl = snap.getString("photoUrl") ?: "",
partnerId = snap.getString("partnerId"),
coupleId = snap.getString("coupleId"),
plan = snap.getString("plan") ?: "free",
createdAt = snap.getLong("createdAt") ?: 0L,
lastActiveAt = snap.getLong("lastActiveAt") ?: 0L
)
)
}
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun createUser(user: User): Unit =
suspendCancellableCoroutine { cont ->
userRef(user.id).set(
mapOf(
"email" to user.email,
"displayName" to user.displayName,
"photoUrl" to user.photoUrl,
"plan" to user.plan,
"createdAt" to user.createdAt,
"lastActiveAt" to user.lastActiveAt
)
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun updateDisplayName(uid: String, displayName: String): Unit =
suspendCancellableCoroutine { cont ->
userRef(uid).set(
mapOf("displayName" to displayName, "lastActiveAt" to System.currentTimeMillis()),
SetOptions.merge()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun hasProfile(uid: String): Boolean =
suspendCancellableCoroutine { cont ->
userRef(uid).get()
.addOnSuccessListener { snap ->
val name = snap.getString("displayName") ?: ""
cont.resume(snap.exists() && name.isNotBlank())
}
.addOnFailureListener { cont.resume(false) }
}
suspend fun storeFcmToken(uid: String, token: String): Unit =
suspendCancellableCoroutine { cont ->
userRef(uid).set(
mapOf("fcmToken" to token, "lastActiveAt" to System.currentTimeMillis()),
SetOptions.merge()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun clearCoupleId(uid: String): Unit =
suspendCancellableCoroutine { cont ->
userRef(uid).update(mapOf("coupleId" to null))
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun deleteUserData(uid: String): Unit =
suspendCancellableCoroutine { cont ->
userRef(uid).delete()
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
}

View File

@ -0,0 +1,32 @@
package app.closer.data.repository
import app.closer.data.remote.FirestoreCoupleDataSource
import app.closer.data.remote.FirestoreUserDataSource
import app.closer.domain.model.Couple
import app.closer.domain.repository.CoupleRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CoupleRepositoryImpl @Inject constructor(
private val coupleDataSource: FirestoreCoupleDataSource,
private val userDataSource: FirestoreUserDataSource
) : CoupleRepository {
override suspend fun getCoupleForUser(userId: String): Couple? {
val coupleId = runCatching { userDataSource.getUser(userId)?.coupleId }.getOrNull() ?: return null
return runCatching { coupleDataSource.getCoupleById(coupleId) }.getOrNull()
}
override suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): Result<String> = runCatching {
coupleDataSource.createCouple(inviterUserId, acceptorUserId, inviteCode)
}
override suspend fun updateStreak(coupleId: String): Result<Unit> = runCatching {
coupleDataSource.updateStreak(coupleId)
}
override suspend fun leaveCouple(userId: String): Result<Unit> = runCatching {
coupleDataSource.leaveCouple(userId)
}
}

View File

@ -0,0 +1,19 @@
package app.closer.data.repository
import app.closer.domain.model.Question
import app.closer.domain.model.QuestionCategory
import app.closer.domain.repository.QuestionRepository
class FakeQuestionRepository : QuestionRepository {
override suspend fun getDailyQuestion(): Question? = null
override suspend fun getQuestionById(id: String): Question? = null
override suspend fun getQuestionsByCategory(categoryId: String): List<Question> = emptyList()
override suspend fun getCategories(): List<QuestionCategory> = emptyList()
override suspend fun getCategoryById(id: String): QuestionCategory? = null
override suspend fun getQuestionCountByCategory(categoryId: String): Int = 0
}

View File

@ -0,0 +1,36 @@
package app.closer.data.repository
import app.closer.data.remote.FirebaseAuthDataSource
import app.closer.domain.model.AuthState
import app.closer.domain.repository.AuthRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FirebaseAuthRepositoryImpl @Inject constructor(
private val dataSource: FirebaseAuthDataSource
) : AuthRepository {
override val authState: Flow<AuthState> = dataSource.authState
override val currentUserId: String? get() = dataSource.currentUserId
override val currentUserEmail: String? get() = dataSource.currentUserEmail
override val isSignedIn: Boolean get() = dataSource.isSignedIn
override suspend fun signInAnonymously(): Result<String> =
runCatching { dataSource.signInAnonymously() }
override suspend fun signInWithEmail(email: String, password: String): Result<String> =
runCatching { dataSource.signInWithEmail(email, password) }
override suspend fun signUpWithEmail(email: String, password: String): Result<String> =
runCatching { dataSource.signUpWithEmail(email, password) }
override suspend fun sendPasswordResetEmail(email: String): Result<Unit> =
runCatching { dataSource.sendPasswordResetEmail(email) }
override suspend fun signOut() = dataSource.signOut()
override suspend fun deleteAccount(): Result<Unit> =
runCatching { dataSource.deleteAccount() }
}

View File

@ -0,0 +1,27 @@
package app.closer.data.repository
import app.closer.data.remote.FirestoreInviteDataSource
import app.closer.domain.model.Invite
import app.closer.domain.repository.InviteRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class InviteRepositoryImpl @Inject constructor(
private val dataSource: FirestoreInviteDataSource
) : InviteRepository {
override suspend fun createInvite(inviterUserId: String): Result<String> = runCatching {
val code = dataSource.generateCode()
dataSource.createInvite(code, inviterUserId)
code
}
override suspend fun getInviteByCode(code: String): Result<Invite?> = runCatching {
dataSource.getInviteByCode(code)
}
override suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result<Unit> = runCatching {
dataSource.markAccepted(code, acceptorUserId, coupleId)
}
}

View File

@ -0,0 +1,69 @@
package app.closer.data.repository
import app.closer.domain.model.QuestionSession
import app.closer.domain.repository.QuestionSessionRepository
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class QuestionSessionRepositoryImpl @Inject constructor(
private val firestore: FirebaseFirestore
) : QuestionSessionRepository {
override suspend fun saveSession(session: QuestionSession): Result<Unit> = runCatching {
val doc = if (session.id.isBlank()) {
firestore.collection("couples")
.document(session.coupleId)
.collection("sessions")
.document()
} else {
firestore.collection("couples")
.document(session.coupleId)
.collection("sessions")
.document(session.id)
}
val data = mapOf(
"id" to doc.id,
"coupleId" to session.coupleId,
"categoryId" to session.categoryId,
"questionIds" to session.questionIds,
"startedByUserId" to session.startedByUserId,
"startedAt" to session.startedAt,
"completedAt" to session.completedAt,
"isPremium" to session.isPremium,
"status" to session.status
)
doc.set(data).await()
}
override suspend fun getSessionsForCouple(coupleId: String): Result<List<QuestionSession>> =
runCatching {
firestore.collection("couples")
.document(coupleId)
.collection("sessions")
.orderBy("completedAt", com.google.firebase.firestore.Query.Direction.DESCENDING)
.limit(50)
.get()
.await()
.documents
.mapNotNull { doc ->
runCatching {
QuestionSession(
id = doc.getString("id") ?: doc.id,
coupleId = doc.getString("coupleId") ?: coupleId,
categoryId = doc.getString("categoryId") ?: "",
questionIds = (doc.get("questionIds") as? List<*>)
?.filterIsInstance<String>() ?: emptyList(),
startedByUserId = doc.getString("startedByUserId") ?: "",
startedAt = doc.getLong("startedAt") ?: 0L,
completedAt = doc.getLong("completedAt"),
isPremium = doc.getBoolean("isPremium") ?: false,
status = doc.getString("status") ?: "completed"
)
}.getOrNull()
}
}
}

View File

@ -0,0 +1,65 @@
package app.closer.data.repository
import app.closer.data.remote.FirestoreQuestionThreadDataSource
import app.closer.domain.model.QuestionAnswer
import app.closer.domain.model.QuestionMessage
import app.closer.domain.model.QuestionReaction
import app.closer.domain.model.QuestionThread
import app.closer.domain.model.QuestionThreadStatus
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.QuestionThreadRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class QuestionThreadRepositoryImpl @Inject constructor(
private val dataSource: FirestoreQuestionThreadDataSource,
private val coupleRepository: CoupleRepository
) : QuestionThreadRepository {
override suspend fun findOrCreateThreadId(
coupleId: String,
questionId: String,
categoryId: String
): String = dataSource.findThreadByQuestionId(coupleId, questionId)
?: dataSource.createThread(coupleId, questionId, categoryId)
override fun observeThread(coupleId: String, threadId: String): Flow<QuestionThread> =
dataSource.observeThread(coupleId, threadId)
override suspend fun submitAnswer(
coupleId: String,
threadId: String,
userId: String,
answer: QuestionAnswer
) {
val countBefore = dataSource.getAnswerCount(coupleId, threadId)
dataSource.submitAnswer(coupleId, threadId, userId, answer)
val countAfter = dataSource.getAnswerCount(coupleId, threadId)
val newStatus = when {
countAfter >= 2 -> QuestionThreadStatus.REVEALED
countAfter == 1 -> QuestionThreadStatus.ANSWERED_BY_ONE
else -> QuestionThreadStatus.NOT_STARTED
}
dataSource.updateThreadStatus(coupleId, threadId, newStatus)
if (countBefore < 2 && newStatus == QuestionThreadStatus.REVEALED) {
runCatching { coupleRepository.updateStreak(coupleId) }
}
}
override fun observeAnswers(coupleId: String, threadId: String): Flow<List<QuestionAnswer>> =
dataSource.observeAnswers(coupleId, threadId)
override suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) =
dataSource.sendMessage(coupleId, threadId, message)
override fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>> =
dataSource.observeMessages(coupleId, threadId)
override suspend fun addReaction(coupleId: String, threadId: String, reaction: QuestionReaction) =
dataSource.addReaction(coupleId, threadId, reaction)
override fun observeReactions(coupleId: String, threadId: String): Flow<List<QuestionReaction>> =
dataSource.observeReactions(coupleId, threadId)
}

View File

@ -0,0 +1,41 @@
package app.closer.data.repository
import app.closer.data.local.CategoryDao
import app.closer.data.local.QuestionDao
import app.closer.data.local.mapper.toQuestion
import app.closer.data.local.mapper.toQuestionCategory
import app.closer.domain.model.Question
import app.closer.domain.model.QuestionCategory
import app.closer.domain.repository.QuestionRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RoomQuestionRepository @Inject constructor(
private val questionDao: QuestionDao,
private val categoryDao: CategoryDao
) : QuestionRepository {
override suspend fun getDailyQuestion(): Question? {
return questionDao.getDailyQuestion()?.toQuestion()
}
override suspend fun getQuestionById(id: String): Question? {
return questionDao.getQuestionById(id)?.toQuestion()
}
override suspend fun getQuestionsByCategory(categoryId: String): List<Question> {
return questionDao.getQuestionsByCategory(categoryId).map { it.toQuestion() }
}
override suspend fun getCategories(): List<QuestionCategory> {
return categoryDao.getAllCategories().map { it.toQuestionCategory() }
}
override suspend fun getCategoryById(id: String): QuestionCategory? {
return categoryDao.getCategoryById(id)?.toQuestionCategory()
}
override suspend fun getQuestionCountByCategory(categoryId: String): Int {
return questionDao.getQuestionCountByCategory(categoryId)
}
}

View File

@ -0,0 +1,140 @@
package app.closer.data.repository
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import app.closer.domain.model.LocalAnswer
import app.closer.domain.repository.LocalAnswerRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import org.json.JSONArray
import org.json.JSONObject
@Singleton
class SharedPreferencesLocalAnswerRepository @Inject constructor(
@ApplicationContext context: Context
) : LocalAnswerRepository {
private val prefs: SharedPreferences = run {
// Remove legacy plaintext file on first migration
context.deleteSharedPreferences("local_answers")
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
try {
EncryptedSharedPreferences.create(
context,
"local_answers_secure",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (_: Exception) {
context.getSharedPreferences("local_answers_secure", Context.MODE_PRIVATE)
}
}
private val answers = MutableStateFlow(readAnswers())
override fun observeAnswers(): Flow<List<LocalAnswer>> = answers
override fun observeAnswer(questionId: String): Flow<LocalAnswer?> {
return answers.map { list -> list.firstOrNull { it.questionId == questionId } }
}
override suspend fun getAnswer(questionId: String): LocalAnswer? {
return answers.value.firstOrNull { it.questionId == questionId }
}
override suspend fun saveAnswer(answer: LocalAnswer) {
val existing = answers.value.firstOrNull { it.questionId == answer.questionId }
val saved = answer.copy(
createdAt = existing?.createdAt ?: answer.createdAt,
updatedAt = System.currentTimeMillis(),
isRevealed = existing?.isRevealed ?: answer.isRevealed
)
val updated = answers.value
.filterNot { it.questionId == saved.questionId }
.plus(saved)
.sortedByDescending { it.updatedAt }
persist(updated)
}
override suspend fun markRevealed(questionId: String) {
val updated = answers.value.map { answer ->
if (answer.questionId == questionId) {
answer.copy(isRevealed = true, updatedAt = System.currentTimeMillis())
} else {
answer
}
}
persist(updated)
}
override suspend fun deleteAnswer(questionId: String) {
persist(answers.value.filterNot { it.questionId == questionId })
}
private fun readAnswers(): List<LocalAnswer> {
val raw = prefs.getString(KEY_ANSWERS, null) ?: return emptyList()
return runCatching {
val array = JSONArray(raw)
(0 until array.length()).mapNotNull { index ->
array.optJSONObject(index)?.toLocalAnswer()
}
}.getOrDefault(emptyList())
}
private fun persist(updated: List<LocalAnswer>) {
prefs.edit()
.putString(KEY_ANSWERS, JSONArray(updated.map { it.toJson() }).toString())
.apply()
answers.value = updated
}
private fun JSONObject.toLocalAnswer(): LocalAnswer {
return LocalAnswer(
questionId = optString("questionId"),
questionText = optString("questionText"),
category = optString("category"),
answerType = optString("answerType"),
writtenText = optString("writtenText").takeIf { it.isNotBlank() },
selectedOptionIds = optStringList("selectedOptionIds"),
selectedOptionTexts = optStringList("selectedOptionTexts"),
scaleValue = if (has("scaleValue") && !isNull("scaleValue")) optInt("scaleValue") else null,
createdAt = optLong("createdAt", System.currentTimeMillis()),
updatedAt = optLong("updatedAt", System.currentTimeMillis()),
isRevealed = optBoolean("isRevealed", false)
)
}
private fun LocalAnswer.toJson(): JSONObject {
return JSONObject()
.put("questionId", questionId)
.put("questionText", questionText)
.put("category", category)
.put("answerType", answerType)
.put("writtenText", writtenText)
.put("selectedOptionIds", JSONArray(selectedOptionIds))
.put("selectedOptionTexts", JSONArray(selectedOptionTexts))
.put("scaleValue", scaleValue)
.put("createdAt", createdAt)
.put("updatedAt", updatedAt)
.put("isRevealed", isRevealed)
}
private fun JSONObject.optStringList(key: String): List<String> {
val array = optJSONArray(key) ?: return emptyList()
return (0 until array.length()).mapNotNull { index ->
array.optString(index).takeIf { it.isNotBlank() }
}
}
private companion object {
const val KEY_ANSWERS = "answers"
}
}

View File

@ -0,0 +1,29 @@
package app.closer.data.repository
import app.closer.data.remote.FirestoreUserDataSource
import app.closer.domain.model.User
import app.closer.domain.repository.UserRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UserRepositoryImpl @Inject constructor(
private val dataSource: FirestoreUserDataSource
) : UserRepository {
override suspend fun getUser(uid: String): User? = dataSource.getUser(uid)
override suspend fun createUser(user: User) = dataSource.createUser(user)
override suspend fun updateDisplayName(uid: String, displayName: String) =
dataSource.updateDisplayName(uid, displayName)
override suspend fun hasProfile(uid: String): Boolean = dataSource.hasProfile(uid)
override suspend fun storeFcmToken(uid: String, token: String) =
dataSource.storeFcmToken(uid, token)
override suspend fun clearCoupleId(uid: String) = dataSource.clearCoupleId(uid)
override suspend fun deleteUserData(uid: String) = dataSource.deleteUserData(uid)
}

View File

@ -0,0 +1,52 @@
package app.closer.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.room.Room
import app.closer.data.local.AppDatabase
import com.google.firebase.firestore.FirebaseFirestore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"app.db"
)
.createFromAsset("database/app.db")
.build()
}
@Provides
@Singleton
fun provideQuestionDao(db: AppDatabase) = db.questionDao()
@Provides
@Singleton
fun provideCategoryDao(db: AppDatabase) = db.categoryDao()
@Provides
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create {
context.preferencesDataStoreFile("settings")
}
@Provides
@Singleton
fun provideFirestore(): FirebaseFirestore = FirebaseFirestore.getInstance()
}

View File

@ -0,0 +1,32 @@
package app.closer.di
import android.content.Context
import app.closer.core.analytics.AnalyticsTracker
import app.closer.core.analytics.FirebaseAnalyticsTracker
import app.closer.core.crash.CrashReporter
import app.closer.core.crash.FirebaseCrashReporter
import com.google.firebase.analytics.FirebaseAnalytics
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class ObservabilityModule {
@Binds @Singleton
abstract fun bindAnalyticsTracker(impl: FirebaseAnalyticsTracker): AnalyticsTracker
@Binds @Singleton
abstract fun bindCrashReporter(impl: FirebaseCrashReporter): CrashReporter
companion object {
@Provides @Singleton
fun provideFirebaseAnalytics(@ApplicationContext context: Context): FirebaseAnalytics =
FirebaseAnalytics.getInstance(context)
}
}

View File

@ -0,0 +1,62 @@
package app.closer.di
import app.closer.core.billing.EntitlementChecker
import app.closer.core.billing.FirestoreEntitlementChecker
import app.closer.data.local.SettingsDataStore
import app.closer.data.repository.CoupleRepositoryImpl
import app.closer.data.repository.QuestionSessionRepositoryImpl
import app.closer.data.repository.FirebaseAuthRepositoryImpl
import app.closer.data.repository.InviteRepositoryImpl
import app.closer.data.repository.SharedPreferencesLocalAnswerRepository
import app.closer.data.repository.RoomQuestionRepository
import app.closer.data.repository.QuestionThreadRepositoryImpl
import app.closer.data.repository.UserRepositoryImpl
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.QuestionSessionRepository
import app.closer.domain.repository.InviteRepository
import app.closer.domain.repository.LocalAnswerRepository
import app.closer.domain.repository.QuestionRepository
import app.closer.domain.repository.QuestionThreadRepository
import app.closer.domain.repository.SettingsRepository
import app.closer.domain.repository.UserRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindAuthRepository(impl: FirebaseAuthRepositoryImpl): AuthRepository
@Binds @Singleton
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
@Binds @Singleton
abstract fun bindInviteRepository(impl: InviteRepositoryImpl): InviteRepository
@Binds @Singleton
abstract fun bindCoupleRepository(impl: CoupleRepositoryImpl): CoupleRepository
@Binds @Singleton
abstract fun bindQuestionThreadRepository(impl: QuestionThreadRepositoryImpl): QuestionThreadRepository
@Binds @Singleton
abstract fun bindQuestionRepository(impl: RoomQuestionRepository): QuestionRepository
@Binds @Singleton
abstract fun bindLocalAnswerRepository(impl: SharedPreferencesLocalAnswerRepository): LocalAnswerRepository
@Binds @Singleton
abstract fun bindEntitlementChecker(impl: FirestoreEntitlementChecker): EntitlementChecker
@Binds @Singleton
abstract fun bindSettingsRepository(impl: SettingsDataStore): SettingsRepository
@Binds @Singleton
abstract fun bindQuestionSessionRepository(impl: QuestionSessionRepositoryImpl): QuestionSessionRepository
}

View File

@ -0,0 +1,11 @@
package app.closer.domain.model
data class Answer(
val id: String = "",
val coupleId: String = "",
val questionId: String = "",
val userId: String = "",
val answerText: String = "",
val createdAt: Long = System.currentTimeMillis(),
val isRevealed: Boolean = false
)

View File

@ -0,0 +1,7 @@
package app.closer.domain.model
sealed class AuthState {
object Loading : AuthState()
data class Authenticated(val userId: String, val isAnonymous: Boolean) : AuthState()
object Unauthenticated : AuthState()
}

View File

@ -0,0 +1,12 @@
package app.closer.domain.model
data class Couple(
val id: String = "",
val userIds: List<String> = emptyList(),
val inviteCode: String = "",
val createdAt: Long = System.currentTimeMillis(),
val currentQuestionId: String? = null,
val streakCount: Int = 0,
val lastAnsweredAt: Long? = null,
val activePackId: String? = null
)

View File

@ -0,0 +1,11 @@
package app.closer.domain.model
data class Entitlement(
val id: String = "",
val userId: String = "",
val source: String = "",
val productId: String = "",
val isActive: Boolean = false,
val expiresAt: Long? = null,
val updatedAt: Long = System.currentTimeMillis()
)

View File

@ -0,0 +1,14 @@
package app.closer.domain.model
data class Invite(
val id: String = "",
val code: String = "",
val inviterUserId: String = "",
val inviteeEmail: String? = null,
val coupleId: String? = null,
val status: String = "pending",
val createdAt: Long = System.currentTimeMillis(),
val expiresAt: Long = System.currentTimeMillis() + 24 * 60 * 60 * 1000L,
val acceptedAt: Long? = null,
val acceptedByUserId: String? = null
)

View File

@ -0,0 +1,8 @@
package app.closer.domain.model
enum class InviteStatus {
PENDING,
ACCEPTED,
EXPIRED,
CANCELLED
}

View File

@ -0,0 +1,15 @@
package app.closer.domain.model
data class LocalAnswer(
val questionId: String,
val questionText: String,
val category: String,
val answerType: String,
val writtenText: String? = null,
val selectedOptionIds: List<String> = emptyList(),
val selectedOptionTexts: List<String> = emptyList(),
val scaleValue: Int? = null,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val isRevealed: Boolean = false
)

View File

@ -0,0 +1,68 @@
package app.closer.domain.model
// Answer config for different question types
data class WrittenAnswerConfig(
val minLength: Int = 1,
val maxLength: Int = 1000,
val placeholder: String = "Write your answer..."
)
data class ChoiceAnswerConfig(
val options: List<ChoiceOption>
)
data class ChoiceOption(
val id: String,
val text: String
)
data class ScaleAnswerConfig(
val minScale: Int,
val maxScale: Int,
val minLabel: String,
val maxLabel: String,
val scaleStep: Int = 1
)
data class ThisOrThatAnswerConfig(
val optionA: ChoiceOption,
val optionB: ChoiceOption
)
sealed interface AnswerConfig {
val type: String
}
data class WrittenAnswerConfigImpl(
override val type: String = "written",
val config: WrittenAnswerConfig
) : AnswerConfig
data class ChoiceAnswerConfigImpl(
override val type: String = "single_choice",
val config: ChoiceAnswerConfig
) : AnswerConfig
data class ScaleAnswerConfigImpl(
override val type: String = "scale",
val config: ScaleAnswerConfig
) : AnswerConfig
data class ThisOrThatAnswerConfigImpl(
override val type: String = "this_or_that",
val config: ThisOrThatAnswerConfig
) : AnswerConfig
data class Question(
val id: String = "",
val text: String = "",
val category: String = "",
val depthLevel: Int = 1,
val isPremium: Boolean = false,
val type: String = "written",
val tags: List<String> = emptyList(),
val answerConfig: AnswerConfig? = null,
val packId: String? = null,
val createdAt: Long = System.currentTimeMillis(),
val status: String = "active"
)

View File

@ -0,0 +1,12 @@
package app.closer.domain.model
data class QuestionAnswer(
val userId: String = "",
val questionId: String = "",
val answerType: String = "written",
val writtenText: String? = null,
val selectedOptionIds: List<String> = emptyList(),
val scaleValue: Int? = null,
val createdAt: Long = 0L,
val updatedAt: Long = 0L
)

View File

@ -0,0 +1,9 @@
package app.closer.domain.model
data class QuestionCategory(
val id: String = "",
val displayName: String = "",
val description: String = "",
val access: String = "free", // "free", "mixed", "premium", "later"
val iconName: String = ""
)

View File

@ -0,0 +1,8 @@
package app.closer.domain.model
data class QuestionMessage(
val id: String = "",
val userId: String = "",
val text: String = "",
val createdAt: Long = 0L
)

View File

@ -0,0 +1,10 @@
package app.closer.domain.model
data class QuestionPack(
val id: String = "",
val displayName: String = "",
val description: String = "",
val isPremium: Boolean = true,
val categoryIds: List<String> = emptyList(),
val iconName: String = ""
)

View File

@ -0,0 +1,9 @@
package app.closer.domain.model
data class QuestionReaction(
val id: String = "",
val userId: String = "",
val targetUserId: String = "",
val emoji: String = "",
val createdAt: Long = 0L
)

View File

@ -0,0 +1,13 @@
package app.closer.domain.model
data class QuestionSession(
val id: String = "",
val coupleId: String = "",
val categoryId: String = "",
val questionIds: List<String> = emptyList(),
val startedByUserId: String = "",
val startedAt: Long = System.currentTimeMillis(),
val completedAt: Long? = null,
val isPremium: Boolean = false,
val status: String = "active"
)

View File

@ -0,0 +1,6 @@
package app.closer.domain.model
enum class QuestionSessionStatus {
ACTIVE,
COMPLETED
}

View File

@ -0,0 +1,30 @@
package app.closer.domain.model
data class QuestionThread(
val id: String = "",
val coupleId: String = "",
val questionId: String = "",
val categoryId: String = "",
val status: QuestionThreadStatus = QuestionThreadStatus.NOT_STARTED,
val currentIndex: Int = 0,
val createdAt: Long = 0L,
val updatedAt: Long = 0L
)
enum class QuestionThreadStatus {
NOT_STARTED,
ANSWERED_BY_ONE,
REVEALED,
COMPLETED;
fun toFirestoreValue(): String = name.lowercase()
companion object {
fun fromFirestoreValue(value: String): QuestionThreadStatus = when (value) {
"answered_by_one" -> ANSWERED_BY_ONE
"revealed" -> REVEALED
"completed" -> COMPLETED
else -> NOT_STARTED
}
}
}

View File

@ -0,0 +1,13 @@
package app.closer.domain.model
data class User(
val id: String = "",
val email: String = "",
val displayName: String = "",
val photoUrl: String = "",
val partnerId: String? = null,
val coupleId: String? = null,
val plan: String = "free",
val createdAt: Long = System.currentTimeMillis(),
val lastActiveAt: Long = System.currentTimeMillis()
)

View File

@ -0,0 +1,17 @@
package app.closer.domain.repository
import app.closer.domain.model.AuthState
import kotlinx.coroutines.flow.Flow
interface AuthRepository {
val authState: Flow<AuthState>
val currentUserId: String?
val currentUserEmail: String?
val isSignedIn: Boolean
suspend fun signInAnonymously(): Result<String>
suspend fun signInWithEmail(email: String, password: String): Result<String>
suspend fun signUpWithEmail(email: String, password: String): Result<String>
suspend fun sendPasswordResetEmail(email: String): Result<Unit>
suspend fun signOut()
suspend fun deleteAccount(): Result<Unit>
}

View File

@ -0,0 +1,10 @@
package app.closer.domain.repository
import app.closer.domain.model.Couple
interface CoupleRepository {
suspend fun getCoupleForUser(userId: String): Couple?
suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): Result<String>
suspend fun updateStreak(coupleId: String): Result<Unit>
suspend fun leaveCouple(userId: String): Result<Unit>
}

View File

@ -0,0 +1,9 @@
package app.closer.domain.repository
import app.closer.domain.model.Invite
interface InviteRepository {
suspend fun createInvite(inviterUserId: String): Result<String>
suspend fun getInviteByCode(code: String): Result<Invite?>
suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result<Unit>
}

View File

@ -0,0 +1,13 @@
package app.closer.domain.repository
import app.closer.domain.model.LocalAnswer
import kotlinx.coroutines.flow.Flow
interface LocalAnswerRepository {
fun observeAnswers(): Flow<List<LocalAnswer>>
fun observeAnswer(questionId: String): Flow<LocalAnswer?>
suspend fun getAnswer(questionId: String): LocalAnswer?
suspend fun saveAnswer(answer: LocalAnswer)
suspend fun markRevealed(questionId: String)
suspend fun deleteAnswer(questionId: String)
}

View File

@ -0,0 +1,13 @@
package app.closer.domain.repository
import app.closer.domain.model.Question
import app.closer.domain.model.QuestionCategory
interface QuestionRepository {
suspend fun getDailyQuestion(): Question?
suspend fun getQuestionById(id: String): Question?
suspend fun getQuestionsByCategory(categoryId: String): List<Question>
suspend fun getCategories(): List<QuestionCategory>
suspend fun getCategoryById(id: String): QuestionCategory?
suspend fun getQuestionCountByCategory(categoryId: String): Int
}

View File

@ -0,0 +1,8 @@
package app.closer.domain.repository
import app.closer.domain.model.QuestionSession
interface QuestionSessionRepository {
suspend fun saveSession(session: QuestionSession): Result<Unit>
suspend fun getSessionsForCouple(coupleId: String): Result<List<QuestionSession>>
}

View File

@ -0,0 +1,18 @@
package app.closer.domain.repository
import app.closer.domain.model.QuestionAnswer
import app.closer.domain.model.QuestionMessage
import app.closer.domain.model.QuestionReaction
import app.closer.domain.model.QuestionThread
import kotlinx.coroutines.flow.Flow
interface QuestionThreadRepository {
suspend fun findOrCreateThreadId(coupleId: String, questionId: String, categoryId: String): String
fun observeThread(coupleId: String, threadId: String): Flow<QuestionThread>
suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer)
fun observeAnswers(coupleId: String, threadId: String): Flow<List<QuestionAnswer>>
suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage)
fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>>
suspend fun addReaction(coupleId: String, threadId: String, reaction: QuestionReaction)
fun observeReactions(coupleId: String, threadId: String): Flow<List<QuestionReaction>>
}

View File

@ -0,0 +1,20 @@
package app.closer.domain.repository
import kotlinx.coroutines.flow.Flow
data class AppSettings(
val dailyReminderEnabled: Boolean = true,
val partnerAnsweredEnabled: Boolean = true,
val streakReminderEnabled: Boolean = false,
val quietHoursEnabled: Boolean = false,
val onboardingComplete: Boolean = false
)
interface SettingsRepository {
val settings: Flow<AppSettings>
suspend fun setDailyReminder(enabled: Boolean)
suspend fun setPartnerAnswered(enabled: Boolean)
suspend fun setStreakReminder(enabled: Boolean)
suspend fun setQuietHours(enabled: Boolean)
suspend fun setOnboardingComplete(complete: Boolean)
}

View File

@ -0,0 +1,13 @@
package app.closer.domain.repository
import app.closer.domain.model.User
interface UserRepository {
suspend fun getUser(uid: String): User?
suspend fun createUser(user: User)
suspend fun updateDisplayName(uid: String, displayName: String)
suspend fun hasProfile(uid: String): Boolean
suspend fun storeFcmToken(uid: String, token: String)
suspend fun clearCoupleId(uid: String)
suspend fun deleteUserData(uid: String)
}

View File

@ -0,0 +1,250 @@
package app.closer.ui.answers
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.AppRoute
import app.closer.domain.model.LocalAnswer
import app.closer.ui.components.EmptyState
import app.closer.ui.questions.displayCategoryName
@Composable
fun AnswerHistoryScreen(
onNavigate: (String) -> Unit = {},
viewModel: AnswerHistoryViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
AnswerHistoryContent(
state = state,
onAnswerSelected = { onNavigate(AppRoute.answerReveal(it.questionId)) },
onDailyQuestion = { onNavigate(AppRoute.DAILY_QUESTION) },
onDelete = viewModel::deleteAnswer
)
}
@Composable
private fun AnswerHistoryContent(
state: AnswerHistoryUiState,
onAnswerSelected: (LocalAnswer) -> Unit,
onDailyQuestion: () -> Unit,
onDelete: (String) -> Unit
) {
var pendingDelete by remember { mutableStateOf<LocalAnswer?>(null) }
pendingDelete?.let { answer ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("Remove this answer?") },
text = {
Text("This removes the saved reflection from this device. The prompt itself will stay available.")
},
confirmButton = {
Button(
onClick = {
onDelete(answer.questionId)
pendingDelete = null
},
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF8D2D35),
contentColor = Color.White
)
) {
Text("Remove")
}
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) {
Text("Keep")
}
}
)
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFFFFFBFE), Color(0xFFF8F1FF), Color(0xFFFFEEF7)),
start = Offset.Zero,
end = Offset.Infinite
)
)
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
item {
Column(
modifier = Modifier.padding(top = 20.dp, bottom = 4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "What you have opened",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E)
)
Text(
text = "Private answers and revealed reflections, gathered in one place.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF5A5060)
)
}
}
if (state.answers.isEmpty()) {
item {
EmptyState(
title = "No answers saved yet",
body = "Answer a daily question or choose a prompt from a pack, and it will appear here.",
actionLabel = "Daily question",
onAction = onDailyQuestion
)
}
} else {
items(state.answers, key = { it.questionId }) { answer ->
AnswerHistoryCard(
answer = answer,
onClick = { onAnswerSelected(answer) },
onDelete = { pendingDelete = answer }
)
}
}
}
}
}
@Composable
private fun AnswerHistoryCard(
answer: LocalAnswer,
onClick: () -> Unit,
onDelete: () -> Unit
) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
) {
Column(
modifier = Modifier.padding(17.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
HistoryPill(if (answer.isRevealed) "Revealed" else "Private")
HistoryPill(answer.category.displayCategoryName())
}
Text(
text = answer.questionText,
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF261D2E),
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = if (answer.isRevealed) answer.revealSummary() else "Saved privately. Tap to reveal.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = if (answer.isRevealed) "Opened" else "Private",
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF56306F),
fontWeight = FontWeight.SemiBold
)
TextButton(onClick = onDelete) {
Text(
text = "Remove",
color = Color(0xFF8D2D35)
)
}
}
}
}
}
@Composable
private fun HistoryPill(label: String) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color(0xFFFFF8FC)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF261D2E)
)
}
}
@Preview
@Composable
fun AnswerHistoryScreenPreview() {
AnswerHistoryContent(
state = AnswerHistoryUiState(
answers = listOf(
LocalAnswer(
questionId = "demo",
questionText = "What helped you feel close this week?",
category = "gratitude",
answerType = "written",
writtenText = "The quiet walk after dinner.",
isRevealed = true
)
)
),
onAnswerSelected = {},
onDailyQuestion = {},
onDelete = {}
)
}

View File

@ -0,0 +1,41 @@
package app.closer.ui.answers
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.domain.model.LocalAnswer
import app.closer.domain.repository.LocalAnswerRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class AnswerHistoryUiState(
val answers: List<LocalAnswer> = emptyList()
)
@HiltViewModel
class AnswerHistoryViewModel @Inject constructor(
private val localAnswerRepository: LocalAnswerRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(AnswerHistoryUiState())
val uiState: StateFlow<AnswerHistoryUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
localAnswerRepository.observeAnswers().collect { answers ->
_uiState.value = AnswerHistoryUiState(
answers = answers.sortedByDescending { it.updatedAt }
)
}
}
}
fun deleteAnswer(questionId: String) {
viewModelScope.launch {
localAnswerRepository.deleteAnswer(questionId)
}
}
}

View File

@ -0,0 +1,386 @@
package app.closer.ui.answers
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.AppRoute
import app.closer.domain.model.LocalAnswer
import app.closer.domain.model.Question
import app.closer.ui.questions.displayCategoryName
import app.closer.ui.questions.displayQuestionType
@Composable
fun AnswerRevealScreen(
questionId: String,
onNavigate: (String) -> Unit = {},
viewModel: AnswerRevealViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
AnswerRevealContent(
state = state,
questionId = questionId,
onReveal = viewModel::revealAnswer,
onAnswerQuestion = {
onNavigate(AppRoute.questionThread("couple", questionId))
},
onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) },
onHome = { onNavigate(AppRoute.HOME) }
)
}
@Composable
private fun AnswerRevealContent(
state: AnswerRevealUiState,
questionId: String,
onReveal: () -> Unit,
onAnswerQuestion: () -> Unit,
onHistory: () -> Unit,
onHome: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFFFFFBFE), Color(0xFFF8F1FF), Color(0xFFFFEEF7)),
start = Offset.Zero,
end = Offset.Infinite
)
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
RevealHeader()
when {
state.isLoading -> RevealMessageCard {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
CircularProgressIndicator(color = Color(0xFFB98AF4))
Text("Loading reveal")
}
}
state.error != null -> RevealMessageCard {
Text(
text = state.error,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060)
)
}
state.answer == null -> NoAnswerState(
question = state.question,
questionId = questionId,
onAnswerQuestion = onAnswerQuestion,
onHome = onHome
)
state.answer.isRevealed -> RevealedState(
answer = state.answer,
question = state.question,
onHistory = onHistory,
onHome = onHome
)
else -> ReadyToRevealState(
answer = state.answer,
question = state.question,
onReveal = onReveal,
onHistory = onHistory
)
}
}
}
}
@Composable
private fun NoAnswerState(
question: Question?,
questionId: String,
onAnswerQuestion: () -> Unit,
onHome: () -> Unit
) {
RevealMessageCard {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
RevealPill("No answer yet")
Text(
text = question?.text ?: "This prompt is ready when you are.",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF261D2E),
fontWeight = FontWeight.SemiBold
)
Text(
text = "Answer privately first. Reveal can wait until there is something worth opening together.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060)
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onAnswerQuestion,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFB98AF4),
contentColor = Color(0xFF24122F)
)
) {
Text("Answer")
}
OutlinedButton(
onClick = onHome,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp)
) {
Text("Not now")
}
}
}
}
}
@Composable
private fun ReadyToRevealState(
answer: LocalAnswer,
question: Question?,
onReveal: () -> Unit,
onHistory: () -> Unit
) {
RevealMessageCard {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
RevealPill("Private answer saved")
Text(
text = question?.text ?: answer.questionText,
style = MaterialTheme.typography.titleLarge,
color = Color(0xFF261D2E),
fontWeight = FontWeight.SemiBold
)
AnswerPreview(answer = answer, revealed = false)
Text(
text = "No rush. Reveal this only when you want the conversation to open.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF5A5060)
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onReveal,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFB98AF4),
contentColor = Color(0xFF24122F)
)
) {
Text("Reveal answer")
}
OutlinedButton(
onClick = onHistory,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp)
) {
Text("Saved answers")
}
}
}
}
}
@Composable
private fun RevealedState(
answer: LocalAnswer,
question: Question?,
onHistory: () -> Unit,
onHome: () -> Unit
) {
RevealMessageCard {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RevealPill("Revealed")
RevealPill(answer.category.displayCategoryName())
RevealPill(answer.answerType.displayQuestionType())
}
Text(
text = question?.text ?: answer.questionText,
style = MaterialTheme.typography.titleLarge,
color = Color(0xFF261D2E),
fontWeight = FontWeight.SemiBold
)
AnswerPreview(answer = answer, revealed = true)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onHistory,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFB98AF4),
contentColor = Color(0xFF24122F)
)
) {
Text("Saved answers")
}
OutlinedButton(
onClick = onHome,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp)
) {
Text("Home")
}
}
}
}
}
@Composable
private fun RevealMessageCard(content: @Composable () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 10.dp)
) {
Box(modifier = Modifier.padding(20.dp)) {
content()
}
}
}
@Composable
private fun RevealHeader() {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = "Reveal together",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF261D2E)
)
Text(
text = "A saved answer can stay private, become a shared reflection, or simply wait for the right moment.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF5A5060)
)
}
}
@Composable
private fun AnswerPreview(
answer: LocalAnswer,
revealed: Boolean
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = if (revealed) Color(0xFFF4E8FF) else Color(0xFFFFF8FC)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = if (revealed) "Opened answer" else "Private preview",
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF56306F),
fontWeight = FontWeight.SemiBold
)
Text(
text = if (revealed) answer.revealSummary() else answer.privatePreview(),
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF3E3346)
)
}
}
}
@Composable
private fun RevealPill(label: String) {
Surface(
shape = RoundedCornerShape(999.dp),
color = Color(0xFFFFF8FC)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF261D2E)
)
}
}
fun LocalAnswer.revealSummary(): String {
return when (answerType) {
"written" -> writtenText.orEmpty()
"scale" -> "You chose ${scaleValue ?: "-"}."
"single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts
.ifEmpty { selectedOptionIds }
.joinToString()
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." }
}
}
private fun LocalAnswer.privatePreview(): String {
return when (answerType) {
"written" -> writtenText?.takeIf { it.isNotBlank() }?.let { "Your written answer is saved." }
?: "Your answer is saved."
"scale" -> "Your scale answer is saved."
"single_choice", "multi_choice", "this_or_that" -> "Your choice is saved."
else -> "Your answer is saved."
}
}
@Preview
@Composable
fun AnswerRevealScreenPreview() {
AnswerRevealContent(
state = AnswerRevealUiState(
isLoading = false,
answer = LocalAnswer(
questionId = "demo",
questionText = "What helped you feel close this week?",
category = "gratitude",
answerType = "written",
writtenText = "The quiet walk after dinner.",
isRevealed = true
)
),
questionId = "demo",
onReveal = {},
onAnswerQuestion = {},
onHistory = {},
onHome = {}
)
}

View File

@ -0,0 +1,73 @@
package app.closer.ui.answers
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.domain.model.LocalAnswer
import app.closer.domain.model.Question
import app.closer.domain.repository.LocalAnswerRepository
import app.closer.domain.repository.QuestionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class AnswerRevealUiState(
val isLoading: Boolean = true,
val error: String? = null,
val question: Question? = null,
val answer: LocalAnswer? = null
)
@HiltViewModel
class AnswerRevealViewModel @Inject constructor(
private val questionRepository: QuestionRepository,
private val localAnswerRepository: LocalAnswerRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val questionId: String = savedStateHandle["questionId"] ?: ""
private val _uiState = MutableStateFlow(AnswerRevealUiState())
val uiState: StateFlow<AnswerRevealUiState> = _uiState.asStateFlow()
init {
load()
observeAnswer()
}
private fun load() {
viewModelScope.launch {
_uiState.value = AnswerRevealUiState(isLoading = true)
try {
_uiState.value = AnswerRevealUiState(
isLoading = false,
question = questionRepository.getQuestionById(questionId),
answer = localAnswerRepository.getAnswer(questionId)
)
} catch (e: Exception) {
_uiState.value = AnswerRevealUiState(
isLoading = false,
error = e.message ?: "Could not load this reveal."
)
}
}
}
private fun observeAnswer() {
viewModelScope.launch {
localAnswerRepository.observeAnswer(questionId).collect { answer ->
_uiState.update { it.copy(answer = answer) }
}
}
}
fun revealAnswer() {
viewModelScope.launch {
localAnswerRepository.markRevealed(questionId)
}
}
}

View File

@ -0,0 +1,35 @@
package app.closer.ui.auth
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
internal val AuthBackgroundBrush: Brush
get() = Brush.linearGradient(
colors = listOf(Color(0xFFFFFBFE), Color(0xFFF8F1FF), Color(0xFFFFEEF7)),
start = Offset.Zero,
end = Offset.Infinite
)
internal val AuthInk = Color(0xFF261D2E)
internal val AuthMuted = Color(0xFF5A5060)
internal val AuthPrimary = Color(0xFFB98AF4)
internal val AuthPrimaryDeep = Color(0xFF56306F)
internal val AuthOnPrimary = Color(0xFF24122F)
@Composable
internal fun authTextFieldColors() = OutlinedTextFieldDefaults.colors(
focusedBorderColor = AuthPrimaryDeep,
unfocusedBorderColor = AuthMuted.copy(alpha = 0.24f),
focusedLabelColor = AuthPrimaryDeep,
unfocusedLabelColor = AuthMuted,
cursorColor = AuthPrimaryDeep,
focusedContainerColor = Color.White.copy(alpha = 0.92f),
unfocusedContainerColor = Color.White.copy(alpha = 0.78f),
focusedTextColor = AuthInk,
unfocusedTextColor = AuthInk,
focusedTrailingIconColor = AuthPrimaryDeep,
unfocusedTrailingIconColor = AuthMuted
)

View File

@ -0,0 +1,164 @@
package app.closer.ui.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.AppRoute
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ForgotPasswordScreen(
onNavigate: (String) -> Unit = {},
viewModel: ForgotPasswordViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
val snackbar = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current
LaunchedEffect(state.error) {
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
}
Scaffold(
snackbarHost = { SnackbarHost(snackbar) },
containerColor = Color.Transparent,
modifier = Modifier.background(AuthBackgroundBrush),
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = { onNavigate(AppRoute.LOGIN) }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = AuthInk
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(padding)
.padding(horizontal = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Spacer(Modifier.height(32.dp))
if (state.sent) {
Spacer(Modifier.height(48.dp))
Text("", style = MaterialTheme.typography.displayMedium, color = AuthPrimaryDeep)
Spacer(Modifier.height(16.dp))
Text(
"Reset email sent",
style = MaterialTheme.typography.headlineSmall,
color = AuthInk,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
Text(
"Check your inbox and follow the link to reset your password.",
style = MaterialTheme.typography.bodyMedium,
color = AuthMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(32.dp))
TextButton(onClick = { onNavigate(AppRoute.LOGIN) }) {
Text("Back to sign in", color = AuthPrimaryDeep)
}
} else {
Text(
"Reset your access",
style = MaterialTheme.typography.headlineMedium,
color = AuthInk,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
Text(
"Enter your email and we'll send a reset link.",
style = MaterialTheme.typography.bodyMedium,
color = AuthMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(32.dp))
OutlinedTextField(
value = state.email,
onValueChange = viewModel::updateEmail,
label = { Text("Email") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = authTextFieldColors(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); viewModel.sendReset() })
)
Spacer(Modifier.height(24.dp))
Button(
onClick = { focusManager.clearFocus(); viewModel.sendReset() },
enabled = !state.isLoading,
modifier = Modifier.fillMaxWidth().height(52.dp),
colors = ButtonDefaults.buttonColors(
containerColor = AuthPrimary,
contentColor = AuthOnPrimary
)
) {
if (state.isLoading) CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = AuthOnPrimary,
strokeWidth = 2.dp
)
else Text("Send reset email", style = MaterialTheme.typography.labelLarge)
}
}
}
}
}

View File

@ -0,0 +1,52 @@
package app.closer.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.domain.repository.AuthRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class ForgotPasswordUiState(
val email: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val sent: Boolean = false
)
@HiltViewModel
class ForgotPasswordViewModel @Inject constructor(
private val authRepository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ForgotPasswordUiState())
val uiState: StateFlow<ForgotPasswordUiState> = _uiState.asStateFlow()
fun updateEmail(v: String) = _uiState.update { it.copy(email = v, error = null) }
fun dismissError() = _uiState.update { it.copy(error = null) }
fun sendReset() {
val email = _uiState.value.email.trim()
if (email.isBlank()) {
_uiState.update { it.copy(error = "Please enter your email address.") }
return
}
_uiState.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
authRepository.sendPasswordResetEmail(email)
.onSuccess { _uiState.update { it.copy(isLoading = false, sent = true) } }
.onFailure { e ->
val msg = when {
e.message?.contains("no user record") == true -> "No account found with that email."
e.message?.contains("badly formatted") == true -> "Please enter a valid email address."
else -> e.message ?: "Something went wrong. Please try again."
}
_uiState.update { it.copy(isLoading = false, error = msg) }
}
}
}
}

View File

@ -0,0 +1,197 @@
package app.closer.ui.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.AppRoute
@Composable
fun LoginScreen(
onNavigate: (String) -> Unit = {},
viewModel: LoginViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
val snackbar = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current
LaunchedEffect(state.success) {
if (state.success) onNavigate(AppRoute.HOME)
}
LaunchedEffect(state.error) {
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
}
Scaffold(
snackbarHost = { SnackbarHost(snackbar) },
containerColor = Color.Transparent,
modifier = Modifier.background(AuthBackgroundBrush)
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(padding)
.padding(horizontal = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Spacer(Modifier.height(48.dp))
Text(
text = "Welcome back",
style = MaterialTheme.typography.displaySmall,
color = AuthInk,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
Text(
text = "Sign in to reconnect with your partner.",
style = MaterialTheme.typography.bodyMedium,
color = AuthMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(40.dp))
OutlinedTextField(
value = state.email,
onValueChange = viewModel::updateEmail,
label = { Text("Email") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = authTextFieldColors(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) })
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.password,
onValueChange = viewModel::updatePassword,
label = { Text("Password") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = authTextFieldColors(),
visualTransformation = if (state.isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); viewModel.signIn() }),
trailingIcon = {
IconButton(onClick = viewModel::togglePasswordVisibility) {
Icon(
imageVector = if (state.isPasswordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
contentDescription = if (state.isPasswordVisible) "Hide password" else "Show password"
)
}
}
)
Spacer(Modifier.height(4.dp))
TextButton(
onClick = { onNavigate(AppRoute.FORGOT_PASSWORD) },
modifier = Modifier.align(Alignment.End)
) {
Text(
"Forgot password?",
style = MaterialTheme.typography.bodySmall,
color = AuthPrimaryDeep
)
}
Spacer(Modifier.height(20.dp))
Button(
onClick = { focusManager.clearFocus(); viewModel.signIn() },
enabled = !state.isLoading,
modifier = Modifier.fillMaxWidth().height(52.dp),
colors = ButtonDefaults.buttonColors(
containerColor = AuthPrimary,
contentColor = AuthOnPrimary
)
) {
if (state.isLoading) CircularProgressIndicator(color = AuthOnPrimary, strokeWidth = 2.dp)
else Text("Sign in", style = MaterialTheme.typography.labelLarge)
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = viewModel::signInAnonymously,
enabled = !state.isLoading,
modifier = Modifier.fillMaxWidth().height(52.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = Color.White.copy(alpha = 0.62f),
contentColor = AuthPrimaryDeep
),
border = BorderStroke(1.dp, AuthPrimaryDeep.copy(alpha = 0.28f))
) {
Text("Try without account", style = MaterialTheme.typography.labelLarge)
}
Spacer(Modifier.height(28.dp))
TextButton(onClick = { onNavigate(AppRoute.SIGN_UP) }) {
Text(
"Don't have an account? Sign up",
style = MaterialTheme.typography.bodyMedium,
color = AuthPrimaryDeep
)
}
Spacer(Modifier.height(32.dp))
}
}
}

View File

@ -0,0 +1,66 @@
package app.closer.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.domain.repository.AuthRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class LoginUiState(
val email: String = "",
val password: String = "",
val isPasswordVisible: Boolean = false,
val isLoading: Boolean = false,
val error: String? = null,
val success: Boolean = false
)
@HiltViewModel
class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
fun updateEmail(email: String) = _uiState.update { it.copy(email = email, error = null) }
fun updatePassword(pw: String) = _uiState.update { it.copy(password = pw, error = null) }
fun togglePasswordVisibility() = _uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) }
fun dismissError() = _uiState.update { it.copy(error = null) }
fun signIn() {
val state = _uiState.value
if (state.email.isBlank() || state.password.isBlank()) {
_uiState.update { it.copy(error = "Please enter your email and password.") }
return
}
_uiState.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
authRepository.signInWithEmail(state.email.trim(), state.password)
.onSuccess { _uiState.update { it.copy(isLoading = false, success = true) } }
.onFailure { e -> _uiState.update { it.copy(isLoading = false, error = friendlyError(e)) } }
}
}
fun signInAnonymously() {
_uiState.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
authRepository.signInAnonymously()
.onSuccess { _uiState.update { it.copy(isLoading = false, success = true) } }
.onFailure { e -> _uiState.update { it.copy(isLoading = false, error = friendlyError(e)) } }
}
}
private fun friendlyError(e: Throwable): String = when {
e.message?.contains("no user record") == true -> "No account found with that email."
e.message?.contains("password is invalid") == true -> "Incorrect password."
e.message?.contains("badly formatted") == true -> "Please enter a valid email address."
e.message?.contains("network") == true -> "Check your connection and try again."
else -> e.message ?: "Something went wrong. Please try again."
}
}

View File

@ -0,0 +1,199 @@
package app.closer.ui.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.AppRoute
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SignUpScreen(
onNavigate: (String) -> Unit = {},
viewModel: SignUpViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
val snackbar = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current
LaunchedEffect(state.success) {
if (state.success) onNavigate(AppRoute.CREATE_PROFILE)
}
LaunchedEffect(state.error) {
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
}
Scaffold(
snackbarHost = { SnackbarHost(snackbar) },
containerColor = Color.Transparent,
modifier = Modifier.background(AuthBackgroundBrush),
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = { onNavigate(AppRoute.LOGIN) }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = AuthInk
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(padding)
.padding(horizontal = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Spacer(Modifier.height(16.dp))
Text(
text = "Create your account",
style = MaterialTheme.typography.headlineMedium,
color = AuthInk,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
Text(
text = "You'll set your name after signing up.",
style = MaterialTheme.typography.bodyMedium,
color = AuthMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(32.dp))
OutlinedTextField(
value = state.email,
onValueChange = viewModel::updateEmail,
label = { Text("Email") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = authTextFieldColors(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) })
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.password,
onValueChange = viewModel::updatePassword,
label = { Text("Password") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = authTextFieldColors(),
visualTransformation = if (state.isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
trailingIcon = {
IconButton(onClick = viewModel::togglePasswordVisibility) {
Icon(
if (state.isPasswordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
contentDescription = null
)
}
},
supportingText = {
Text(
"At least 6 characters",
style = MaterialTheme.typography.bodySmall,
color = AuthMuted
)
}
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.confirmPassword,
onValueChange = viewModel::updateConfirmPassword,
label = { Text("Confirm password") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = authTextFieldColors(),
visualTransformation = if (state.isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); viewModel.signUp() })
)
Spacer(Modifier.height(28.dp))
Button(
onClick = { focusManager.clearFocus(); viewModel.signUp() },
enabled = !state.isLoading,
modifier = Modifier.fillMaxWidth().height(52.dp),
colors = ButtonDefaults.buttonColors(
containerColor = AuthPrimary,
contentColor = AuthOnPrimary
)
) {
if (state.isLoading) CircularProgressIndicator(color = AuthOnPrimary, strokeWidth = 2.dp)
else Text("Create account", style = MaterialTheme.typography.labelLarge)
}
Spacer(Modifier.height(16.dp))
TextButton(onClick = { onNavigate(AppRoute.LOGIN) }) {
Text(
"Already have an account? Sign in",
style = MaterialTheme.typography.bodyMedium,
color = AuthPrimaryDeep
)
}
Spacer(Modifier.height(32.dp))
}
}
}

View File

@ -0,0 +1,59 @@
package app.closer.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.domain.repository.AuthRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class SignUpUiState(
val email: String = "",
val password: String = "",
val confirmPassword: String = "",
val isPasswordVisible: Boolean = false,
val isLoading: Boolean = false,
val error: String? = null,
val success: Boolean = false
)
@HiltViewModel
class SignUpViewModel @Inject constructor(
private val authRepository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(SignUpUiState())
val uiState: StateFlow<SignUpUiState> = _uiState.asStateFlow()
fun updateEmail(v: String) = _uiState.update { it.copy(email = v, error = null) }
fun updatePassword(v: String) = _uiState.update { it.copy(password = v, error = null) }
fun updateConfirmPassword(v: String) = _uiState.update { it.copy(confirmPassword = v, error = null) }
fun togglePasswordVisibility() = _uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) }
fun dismissError() = _uiState.update { it.copy(error = null) }
fun signUp() {
val state = _uiState.value
when {
state.email.isBlank() -> { _uiState.update { it.copy(error = "Please enter your email.") }; return }
state.password.length < 6 -> { _uiState.update { it.copy(error = "Password must be at least 6 characters.") }; return }
state.password != state.confirmPassword -> { _uiState.update { it.copy(error = "Passwords don't match.") }; return }
}
_uiState.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
authRepository.signUpWithEmail(state.email.trim(), state.password)
.onSuccess { _uiState.update { it.copy(isLoading = false, success = true) } }
.onFailure { e ->
val msg = when {
e.message?.contains("email address is already") == true -> "An account with this email already exists."
e.message?.contains("badly formatted") == true -> "Please enter a valid email address."
else -> e.message ?: "Something went wrong. Please try again."
}
_uiState.update { it.copy(isLoading = false, error = msg) }
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More