chore(cleanup): remove verified dead files (Tier 1 dead-file sweep)
Removes files confirmed unreferenced by symbol-level git-grep and proven safe by a
clean :app:compileDebugKotlin + compileDebugUnitTestKotlin (no main/test references).
Dead code (every top-level type had 0 external references):
- core/notifications/NotificationHelper.kt — dead duplicate of the live
NotificationChannelSetup (which is what CloserApp/AppMessagingService actually call)
- core/notifications/NotificationPermissionHelper.kt — unused permission helper
- notifications/PartnerNotificationScheduler.kt — superseded by PartnerNotificationManager
- data/questions/QuestionJsonParser.kt — superseded by the Room/asset-DB path
- data/repository/FakeQuestionRepository.kt — orphaned fake, no test/DI consumer
- ui/questions/QuestionDetailViewModel.kt — superseded VM, no composable binds it
- domain/model/{Entitlement,InviteStatus,QuestionSessionStatus}.kt — unused models/enums
(entitlement state is read as Firestore booleans, not this model)
Stray artifacts:
- gitleaks-current.json / gitleaks-history.json — sanitized scan output (history = []),
referenced by no CI/config; regenerable
- 19 stale .gitkeep placeholders in directories that now hold real files
Deliberately KEPT (flagged but not dead): WheelHistoryScreen/ViewModel.kt (named
"WheelHistory" but declare the live, nav-wired GameHistoryScreen/GameHistoryViewModel).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
a8819650d2
commit
21392ec2f3
|
|
@ -1,70 +0,0 @@
|
||||||
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"
|
|
||||||
const val CHANNEL_GAME_ACTIVITY = "game_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 = "Gentle nudges for today's question and shared moments"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
nm.createNotificationChannel(
|
|
||||||
NotificationChannel(
|
|
||||||
CHANNEL_PARTNER,
|
|
||||||
"Partner activity",
|
|
||||||
NotificationManager.IMPORTANCE_HIGH
|
|
||||||
).apply {
|
|
||||||
description = "When your partner answers, reveals, or opens a conversation"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
nm.createNotificationChannel(
|
|
||||||
NotificationChannel(
|
|
||||||
CHANNEL_GAME_ACTIVITY,
|
|
||||||
"Games",
|
|
||||||
NotificationManager.IMPORTANCE_HIGH
|
|
||||||
).apply {
|
|
||||||
description = "When your partner starts or completes a game round"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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_notification_closer)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setContentText(body)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setContentIntent(pending)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
if (NotificationManagerCompat.from(context).areNotificationsEnabled()) {
|
|
||||||
NotificationManagerCompat.from(context).notify(id, notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
package app.closer.core.notifications
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.core.app.ActivityCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles Android 13+ (API 33) POST_NOTIFICATIONS permission requests.
|
|
||||||
*
|
|
||||||
* Callers should:
|
|
||||||
* 1. Check [isGranted] before asking.
|
|
||||||
* 2. If [shouldShowRationale] is true, show an in-app rationale first.
|
|
||||||
* 3. Invoke [requestPermission] from an Activity context.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class NotificationPermissionHelper @Inject constructor() {
|
|
||||||
|
|
||||||
val permission: String = Manifest.permission.POST_NOTIFICATIONS
|
|
||||||
|
|
||||||
fun isGranted(context: Context): Boolean {
|
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
|
||||||
} else {
|
|
||||||
// Pre-Android 13 notifications are granted at install time.
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun shouldShowRationale(activity: Activity): Boolean {
|
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun requestPermission(activity: Activity, requestCode: Int = REQUEST_CODE) {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
ActivityCompat.requestPermissions(
|
|
||||||
activity,
|
|
||||||
arrayOf(permission),
|
|
||||||
requestCode
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onRequestResult(
|
|
||||||
requestCode: Int,
|
|
||||||
permissions: Array<out String>,
|
|
||||||
grantResults: IntArray
|
|
||||||
): Boolean {
|
|
||||||
if (requestCode != REQUEST_CODE) return false
|
|
||||||
|
|
||||||
val index = permissions.indexOf(permission)
|
|
||||||
if (index < 0 || index >= grantResults.size) return false
|
|
||||||
|
|
||||||
return grantResults[index] == PackageManager.PERMISSION_GRANTED
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val REQUEST_CODE = 9001
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
package app.closer.data.questions
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import app.closer.BuildConfig
|
|
||||||
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 {
|
|
||||||
|
|
||||||
private const val TAG = "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) {
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
Log.e(TAG, "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) {
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
Log.e(TAG, "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" -> {
|
|
||||||
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(),
|
|
||||||
maxSelections = answerConfigObj.optInt("max_selections", 0)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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 getDailyQuestionForMode(modeTag: String, isPremium: Boolean): 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
|
|
||||||
|
|
||||||
override suspend fun getQuestionsByType(type: String): List<Question> = emptyList()
|
|
||||||
|
|
||||||
override suspend fun getQuestionsForPrediction(): List<Question> = emptyList()
|
|
||||||
|
|
||||||
override suspend fun getDesireSyncQuestions(sex: String): List<Question> = emptyList()
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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()
|
|
||||||
)
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
package app.closer.domain.model
|
|
||||||
|
|
||||||
enum class InviteStatus {
|
|
||||||
PENDING,
|
|
||||||
ACCEPTED,
|
|
||||||
EXPIRED,
|
|
||||||
CANCELLED
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
package app.closer.domain.model
|
|
||||||
|
|
||||||
enum class QuestionSessionStatus {
|
|
||||||
ACTIVE,
|
|
||||||
COMPLETED
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
package app.closer.notifications
|
|
||||||
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedules partner-trigger notifications in response to partner actions.
|
|
||||||
*
|
|
||||||
* This class is intentionally synchronous: it validates quiet hours, opt-outs,
|
|
||||||
* and rate limits at the moment the event is received. Background deferral is
|
|
||||||
* handled by the backend FCM delivery schedule and [PartnerNotificationManager].
|
|
||||||
*/
|
|
||||||
class PartnerNotificationScheduler @Inject constructor(
|
|
||||||
private val notificationManager: PartnerNotificationManager
|
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The partner answered today's question; prompt the user to answer too.
|
|
||||||
*/
|
|
||||||
suspend fun onPartnerAnswered(coupleId: String, questionId: String? = null) {
|
|
||||||
notificationManager.show(
|
|
||||||
PartnerNotificationType.PARTNER_ANSWERED,
|
|
||||||
coupleId,
|
|
||||||
PartnerNotificationPayload(questionId = questionId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Both partners have answered and the reveal is ready.
|
|
||||||
*/
|
|
||||||
suspend fun onRevealReady(coupleId: String, questionId: String) {
|
|
||||||
notificationManager.show(
|
|
||||||
PartnerNotificationType.REVEAL_READY,
|
|
||||||
coupleId,
|
|
||||||
PartnerNotificationPayload(questionId = questionId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The partner started a game for the couple.
|
|
||||||
*/
|
|
||||||
suspend fun onPartnerStartedGame(coupleId: String, gameSessionId: String? = null) {
|
|
||||||
notificationManager.show(
|
|
||||||
PartnerNotificationType.PARTNER_STARTED_GAME,
|
|
||||||
coupleId,
|
|
||||||
PartnerNotificationPayload(gameSessionId = gameSessionId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The partner completed their side of a two-player game.
|
|
||||||
*/
|
|
||||||
suspend fun onPartnerCompletedPart(coupleId: String, gameSessionId: String? = null) {
|
|
||||||
notificationManager.show(
|
|
||||||
PartnerNotificationType.PARTNER_COMPLETED_PART,
|
|
||||||
coupleId,
|
|
||||||
PartnerNotificationPayload(gameSessionId = gameSessionId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A daily connection challenge is waiting for the couple.
|
|
||||||
*/
|
|
||||||
suspend fun onChallengeWaiting(coupleId: String, challengeId: String? = null) {
|
|
||||||
notificationManager.show(
|
|
||||||
PartnerNotificationType.CHALLENGE_WAITING,
|
|
||||||
coupleId,
|
|
||||||
PartnerNotificationPayload(challengeId = challengeId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A previously sealed memory capsule reached its unlock date.
|
|
||||||
*/
|
|
||||||
suspend fun onCapsuleUnlocked(coupleId: String, capsuleId: String? = null) {
|
|
||||||
notificationManager.show(
|
|
||||||
PartnerNotificationType.CAPSULE_UNLOCKED,
|
|
||||||
coupleId,
|
|
||||||
PartnerNotificationPayload(capsuleId = capsuleId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
package app.closer.ui.questions
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import app.closer.domain.model.ChoiceAnswerConfigImpl
|
|
||||||
import app.closer.domain.repository.LocalAnswerRepository
|
|
||||||
import app.closer.domain.repository.QuestionRepository
|
|
||||||
import app.closer.ui.components.TextInputLimits
|
|
||||||
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
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class QuestionDetailViewModel @Inject constructor(
|
|
||||||
private val repository: QuestionRepository,
|
|
||||||
private val localAnswerRepository: LocalAnswerRepository,
|
|
||||||
savedStateHandle: SavedStateHandle
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val questionId: String = savedStateHandle["questionId"] ?: ""
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(LocalQuestionUiState())
|
|
||||||
val uiState: StateFlow<LocalQuestionUiState> = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
loadQuestion()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadQuestion() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_uiState.value = LocalQuestionUiState(isLoading = true)
|
|
||||||
try {
|
|
||||||
val question = repository.getQuestionById(questionId)
|
|
||||||
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
|
|
||||||
_uiState.value = LocalQuestionUiState(
|
|
||||||
isLoading = false,
|
|
||||||
question = question,
|
|
||||||
pendingScaleValue = defaultScaleValue(question)
|
|
||||||
).withLocalAnswer(answer)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value = LocalQuestionUiState(
|
|
||||||
isLoading = false,
|
|
||||||
error = e.message ?: "Could not load this question."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateWrittenText(text: String) {
|
|
||||||
_uiState.update { it.copy(pendingWrittenText = text.take(MAX_ANSWER_LENGTH), submitted = false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleOption(optionId: String) {
|
|
||||||
_uiState.update { state ->
|
|
||||||
val question = state.question ?: return@update state
|
|
||||||
val updated = if (question.type == "multi_choice") {
|
|
||||||
val maxSel = (question.answerConfig as? ChoiceAnswerConfigImpl)?.config?.maxSelections ?: 0
|
|
||||||
when {
|
|
||||||
optionId in state.pendingSelectedOptionIds -> state.pendingSelectedOptionIds - optionId
|
|
||||||
maxSel == 0 || state.pendingSelectedOptionIds.size < maxSel -> state.pendingSelectedOptionIds + optionId
|
|
||||||
else -> state.pendingSelectedOptionIds
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
listOf(optionId)
|
|
||||||
}
|
|
||||||
state.copy(pendingSelectedOptionIds = updated, submitted = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateScale(value: Int) {
|
|
||||||
_uiState.update { it.copy(pendingScaleValue = value, submitted = false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun submitAnswer() {
|
|
||||||
val state = _uiState.value
|
|
||||||
val question = state.question ?: return
|
|
||||||
if (!canSubmit(state)) return
|
|
||||||
viewModelScope.launch {
|
|
||||||
localAnswerRepository.saveAnswer(state.toLocalAnswer(question))
|
|
||||||
_uiState.update { it.copy(submitted = true) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun canSubmit(): Boolean = canSubmit(_uiState.value)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val MAX_ANSWER_LENGTH = TextInputLimits.WRITTEN_ANSWER
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun canSubmit(state: LocalQuestionUiState): Boolean {
|
|
||||||
val question = state.question ?: return false
|
|
||||||
return when (question.type) {
|
|
||||||
"written" -> state.pendingWrittenText.isNotBlank()
|
|
||||||
"single_choice", "multi_choice", "this_or_that" -> state.pendingSelectedOptionIds.isNotEmpty()
|
|
||||||
"scale" -> true
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"Description": "Generic API Key",
|
|
||||||
"StartLine": 31,
|
|
||||||
"EndLine": 31,
|
|
||||||
"StartColumn": 21,
|
|
||||||
"EndColumn": 67,
|
|
||||||
"Match": "key\": \"REDACTED\"",
|
|
||||||
"Secret": "REDACTED",
|
|
||||||
"File": "app/google-services.json",
|
|
||||||
"SymlinkFile": "",
|
|
||||||
"Commit": "",
|
|
||||||
"Entropy": 4.7851515,
|
|
||||||
"Author": "",
|
|
||||||
"Email": "",
|
|
||||||
"Date": "",
|
|
||||||
"Message": "",
|
|
||||||
"Tags": [],
|
|
||||||
"RuleID": "generic-api-key",
|
|
||||||
"Fingerprint": "app/google-services.json:generic-api-key:31"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Description": "Generic API Key",
|
|
||||||
"StartLine": 68,
|
|
||||||
"EndLine": 68,
|
|
||||||
"StartColumn": 21,
|
|
||||||
"EndColumn": 67,
|
|
||||||
"Match": "key\": \"REDACTED\"",
|
|
||||||
"Secret": "REDACTED",
|
|
||||||
"File": "app/google-services.json",
|
|
||||||
"SymlinkFile": "",
|
|
||||||
"Commit": "",
|
|
||||||
"Entropy": 4.7851515,
|
|
||||||
"Author": "",
|
|
||||||
"Email": "",
|
|
||||||
"Date": "",
|
|
||||||
"Message": "",
|
|
||||||
"Tags": [],
|
|
||||||
"RuleID": "generic-api-key",
|
|
||||||
"Fingerprint": "app/google-services.json:generic-api-key:68"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
[]
|
|
||||||
Loading…
Reference in New Issue