Compare commits
35 Commits
6033b12f05
...
ddfe9e250a
| Author | SHA1 | Date |
|---|---|---|
|
|
ddfe9e250a | |
|
|
c28ce9c58d | |
|
|
bd1ea5cecd | |
|
|
f45f8dd114 | |
|
|
e8274370d1 | |
|
|
afeb1a1a03 | |
|
|
84995641f3 | |
|
|
e42de938e7 | |
|
|
a870b17801 | |
|
|
99ff77a357 | |
|
|
71441bec14 | |
|
|
0f73c656d8 | |
|
|
56f2d8c045 | |
|
|
0e9606366b | |
|
|
888ffa3c1a | |
|
|
5302526d32 | |
|
|
c1548f28fb | |
|
|
342c3276a0 | |
|
|
db177bc792 | |
|
|
7a9d4c3b49 | |
|
|
bee617c493 | |
|
|
1a33d4f2b9 | |
|
|
11a81cb826 | |
|
|
88004cf219 | |
|
|
011745e7d4 | |
|
|
577d39ea11 | |
|
|
29d512c679 | |
|
|
6fe5e5048e | |
|
|
78e145352b | |
|
|
112de3398f | |
|
|
8bcb3308c1 | |
|
|
af7603d61c | |
|
|
5991acb283 | |
|
|
92a0e8f2eb | |
|
|
1c976935c9 |
|
|
@ -14,6 +14,7 @@ FUTURE.md
|
|||
HISTORY.md
|
||||
PROJECT.md
|
||||
STRUCTURE.md
|
||||
CONCERN.md
|
||||
project-requirements.md
|
||||
DEVELOPMENT_LOG.md
|
||||
BUILD_SUMMARY.md
|
||||
|
|
@ -34,3 +35,10 @@ out/
|
|||
.env
|
||||
.env.local
|
||||
*.env
|
||||
|
||||
# App module build
|
||||
app/build/
|
||||
SecurityReport.md
|
||||
|
||||
# Firebase config (contains project ID, app ID, OAuth client, API key)
|
||||
app/google-services.json
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"java.configuration.updateBuildConfiguration": "interactive"
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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.**
|
||||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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", ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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()
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package app.closer.domain.model
|
||||
|
||||
enum class InviteStatus {
|
||||
PENDING,
|
||||
ACCEPTED,
|
||||
EXPIRED,
|
||||
CANCELLED
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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 = ""
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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 = ""
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package app.closer.domain.model
|
||||
|
||||
enum class QuestionSessionStatus {
|
||||
ACTIVE,
|
||||
COMPLETED
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>>
|
||||
}
|
||||
|
|
@ -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>>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue