mostly android

This commit is contained in:
2026-05-26 09:38:54 -04:00
parent 9ee3d532be
commit 82815009c9
52 changed files with 3397 additions and 214 deletions

View File

@@ -76,6 +76,8 @@ dependencies {
implementation(libs.retrofit.kotlinx.serialization.converter)
implementation(libs.kotlinx.serialization.json)
implementation(libs.work.runtime.ktx)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)

View File

@@ -3,6 +3,12 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application
android:name=".KordantApp"
@@ -23,7 +29,32 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kordant" android:host="alert" />
<data android:scheme="kordant" android:host="service" />
</intent-filter>
</activity>
<service
android:name=".service.FCMService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service
android:name=".service.CallScreeningService"
android:exported="true"
android:permission="android.permission.BIND_CALL_SCREENING_SERVICE"
tools:targetApi="q">
<intent-filter>
<action android:name="android.telecom.CallScreeningService" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -55,6 +55,9 @@ interface TRPCApiService {
@POST("api/trpc/voice.analyze")
suspend fun voiceAnalyze(@Body body: JsonObject): TRPCResponse<VoiceAnalysis>
@POST("api/trpc/voice.analyses")
suspend fun voiceAnalyses(@Body body: JsonObject): TRPCResponse<List<VoiceAnalysis>>
@POST("api/trpc/spam.listRules")
suspend fun spamListRules(@Body body: JsonObject): TRPCResponse<List<SpamRule>>
@@ -75,4 +78,10 @@ interface TRPCApiService {
@POST("api/trpc/broker.listListings")
suspend fun brokerListListings(@Body body: JsonObject): TRPCResponse<List<BrokerListing>>
@POST("api/trpc/notification.registerDevice")
suspend fun registerDeviceToken(@Body body: JsonObject): TRPCResponse<Unit>
@POST("api/trpc/spam.checkNumber")
suspend fun spamCheckNumber(@Body body: JsonObject): TRPCResponse<JsonObject>
}

View File

@@ -55,6 +55,19 @@ class VoicePrintRepository(
}
}
suspend fun getAnalyses(): ApiResult<List<VoiceAnalysis>> {
val cached: List<VoiceAnalysis>? = CacheManager.load(context, "voice_analyses")
if (cached != null) {
return ApiResult.Success(cached)
}
return ErrorHandler.executeWithRetry {
val response = api.voiceAnalyses(TRPCRequest.body(buildJsonObject {}))
val analyses = response.result.data
CacheManager.save(context, "voice_analyses", analyses)
analyses
}
}
fun observeEnrollments(): Flow<List<VoiceEnrollment>> = _enrollments
private suspend fun refreshEnrollmentsCache() {

View File

@@ -0,0 +1,66 @@
package com.kordant.android.service
import android.os.Build
import android.telecom.Call
import android.telecom.CallScreeningService
import android.util.Log
import androidx.annotation.RequiresApi
import com.kordant.android.di.NetworkModule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* Call screening service that intercepts incoming calls and checks against SpamShield.
* Available on Android 10+ (API 29+).
*/
@RequiresApi(Build.VERSION_CODES.Q)
class CallScreeningService : CallScreeningService() {
companion object {
private const val TAG = "CallScreeningService"
}
override fun onScreenCall(details: Call.Details) {
val phoneNumber = details.handle?.schemeSpecificPart ?: return
Log.d(TAG, "Screening incoming call from: $phoneNumber")
val response = CallResponse.Builder()
.setDisallowCall(false)
.setRejectCall(false)
.setSkipCallLog(false)
.build()
CoroutineScope(Dispatchers.IO).launch {
try {
val api = NetworkModule.provideApiService(applicationContext)
val body = buildJsonObject {
put("json", buildJsonObject {
put("phoneNumber", phoneNumber)
})
}
val result = api.spamCheckNumber(body)
val screeningResponse = if (result is com.kordant.android.data.remote.ApiResult.Success<*> &&
result.data != null) {
val isSpam = false // Parse from result.data in production
CallResponse.Builder()
.setDisallowCall(isSpam)
.setRejectCall(isSpam)
.setSkipCallLog(false)
.build()
} else {
response
}
respondToCall(details, screeningResponse)
} catch (e: Exception) {
Log.e(TAG, "Failed to screen call", e)
respondToCall(details, response)
}
}
}
}

View File

@@ -0,0 +1,171 @@
package com.kordant.android.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessaging
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.kordant.android.MainActivity
import com.kordant.android.R
import com.kordant.android.di.NetworkModule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* Firebase Cloud Messaging service for push notifications.
* Handles incoming messages, token registration, and notification display.
*/
class FCMService : FirebaseMessagingService() {
companion object {
private const val CHANNEL_CRITICAL = "kordant_critical"
private const val CHANNEL_ALERTS = "kordant_alerts"
private const val CHANNEL_GENERAL = "kordant_general"
const val EXTRA_SCREEN = "screen"
const val EXTRA_ID = "id"
}
override fun onNewToken(token: String) {
super.onNewToken(token)
registerDeviceToken(token)
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
// Subscribe to broadcast alerts topic
subscribeToTopics()
message.notification?.let { notification ->
showNotification(
title = notification.title ?: "Kordant",
body = notification.body ?: "",
data = message.data,
priority = determinePriority(message.data)
)
} ?: run {
// Data-only message (silent push for background sync)
handleDataMessage(message.data)
}
}
private fun subscribeToTopics() {
FirebaseMessaging.getInstance().subscribeToTopic("alerts")
FirebaseMessaging.getInstance().subscribeToTopic("security")
}
private fun registerDeviceToken(token: String) {
CoroutineScope(Dispatchers.IO).launch {
try {
val api = NetworkModule.provideApiService(applicationContext)
val body = buildJsonObject {
put("json", buildJsonObject {
put("token", token)
put("platform", "android")
})
}
api.registerDeviceToken(body)
} catch (e: Exception) {
// Token registration failed; will retry on next token refresh
}
}
}
private fun determinePriority(data: Map<String, String>): Int {
return when (data["severity"]?.lowercase()) {
"critical" -> NotificationCompat.PRIORITY_HIGH
"high" -> NotificationCompat.PRIORITY_DEFAULT
else -> NotificationCompat.PRIORITY_LOW
}
}
private fun showNotification(
title: String,
body: String,
data: Map<String, String>,
priority: Int
) {
val channelId = when (priority) {
NotificationCompat.PRIORITY_HIGH -> CHANNEL_CRITICAL
NotificationCompat.PRIORITY_DEFAULT -> CHANNEL_ALERTS
else -> CHANNEL_GENERAL
}
createNotificationChannel(channelId, priority)
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra(EXTRA_SCREEN, data["screen"])
putExtra(EXTRA_ID, data["id"])
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(body)
.setPriority(priority)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setStyle(
NotificationCompat.BigTextStyle()
.bigText(body)
)
.build()
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(System.currentTimeMillis().toInt(), notification)
}
private fun createNotificationChannel(channelId: String, priority: Int) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val name = when (channelId) {
CHANNEL_CRITICAL -> "Critical Alerts"
CHANNEL_ALERTS -> "Alerts"
CHANNEL_GENERAL -> "General"
else -> "Notifications"
}
val description = when (channelId) {
CHANNEL_CRITICAL -> "Critical security threats requiring immediate attention"
CHANNEL_ALERTS -> "Security alerts and data exposure notifications"
CHANNEL_GENERAL -> "General Kordant notifications"
else -> "Notifications"
}
val channel = NotificationChannel(channelId, name, priority).apply {
this.description = description
enableVibration(true)
}
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
private fun handleDataMessage(data: Map<String, String>) {
// Handle silent push for background sync
val action = data["action"]
when (action) {
"sync" -> {
// Trigger background sync
}
"refresh" -> {
// Refresh dashboard data
}
}
}
}

View File

@@ -13,10 +13,6 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.unit.dp
import com.kordant.android.ui.theme.Error
import com.kordant.android.ui.theme.Success
@@ -26,22 +22,21 @@ import com.kordant.android.ui.theme.Warning
fun ThreatGauge(
score: Int,
modifier: Modifier = Modifier,
size: Int = 160
gaugeSize: Int = 160
) {
val (startColor, endColor) = when {
score <= 30 -> Success to Success.copy(alpha = 0.4f)
score <= 60 -> Warning to Warning.copy(alpha = 0.4f)
else -> Error to Error.copy(alpha = 0.4f)
}
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Canvas(
modifier = Modifier.size(size.dp)
modifier = Modifier.size(gaugeSize.dp)
) {
val center = Offset(size.toPx() / 2, size.toPx() / 2)
val startColor = when {
score <= 30 -> Success
score <= 60 -> Warning
else -> Error
}
val center = Offset(this.size.width / 2, this.size.height / 2)
val radius = center.x - 16.dp.toPx()
val strokeWidth = 16.dp.toPx()

View File

@@ -1,13 +1,28 @@
package com.kordant.android.ui.screens.auth
import android.content.Context
import android.security.identity.IdentityCredentialException
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
@Composable
@@ -20,6 +35,7 @@ fun BiometricAuthScreen(
) {
val context = LocalContext.current
val activity = context as? FragmentActivity
var status by remember { mutableStateOf<AuthStatus>(AuthStatus.Idle) }
val biometricManager = remember {
BiometricManager.from(context)
@@ -42,10 +58,13 @@ fun BiometricAuthScreen(
DisposableEffect(activity) {
if (activity != null && canAuthenticate == BiometricManager.BIOMETRIC_SUCCESS) {
status = AuthStatus.ShowingPrompt
val biometricPrompt = BiometricPrompt(
activity,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
status = AuthStatus.Authenticated
onAuthenticated()
}
@@ -53,21 +72,97 @@ fun BiometricAuthScreen(
if (errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON &&
errorCode != BiometricPrompt.ERROR_USER_CANCELED
) {
status = AuthStatus.Error(errString.toString())
onError(errString.toString())
} else {
status = AuthStatus.Idle
}
}
override fun onAuthenticationFailed() {
onError("Authentication failed")
status = AuthStatus.Failed
}
}
)
biometricPrompt.authenticate(promptInfo)
} else if (canAuthenticate != BiometricManager.BIOMETRIC_SUCCESS) {
status = AuthStatus.Unavailable
onError("Biometric authentication is not available on this device")
}
onDispose { }
}
BiometricAuthUI(status = status)
}
@Composable
private fun BiometricAuthUI(status: AuthStatus) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
when (status) {
is AuthStatus.Idle -> {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Preparing biometric authentication...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
is AuthStatus.ShowingPrompt -> {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Present your fingerprint or face",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
is AuthStatus.Authenticated -> {
Text(
text = "✓ Authenticated",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
}
is AuthStatus.Failed -> {
Text(
text = "Authentication failed. Try again.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
is AuthStatus.Error -> {
Text(
text = "Error: ${status.message}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
is AuthStatus.Unavailable -> {
Text(
text = "Biometric authentication is not available on this device.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
private sealed class AuthStatus {
data object Idle : AuthStatus()
data object ShowingPrompt : AuthStatus()
data object Authenticated : AuthStatus()
data object Failed : AuthStatus()
data class Error(val message: String) : AuthStatus()
data object Unavailable : AuthStatus()
}
fun canUseBiometric(context: Context): Boolean {

View File

@@ -1,6 +1,7 @@
package com.kordant.android.ui.screens.dashboard
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.Spacer
@@ -35,7 +36,7 @@ import com.kordant.android.ui.components.ShieldButton
import com.kordant.android.ui.components.ShieldButtonVariant
import com.kordant.android.ui.components.ShieldCard
import com.kordant.android.ui.components.ShieldEmptyState
import com.kordant.android.ui.viewmodel.AlertDetailViewModel
import com.kordant.android.viewmodel.AlertDetailViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable

View File

@@ -18,16 +18,13 @@ import androidx.compose.foundation.lazy.items
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.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxState
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -57,6 +54,12 @@ data class ServiceSummary(
val route: String
)
data class QuickAction(
val label: String,
val icon: ImageVector,
val route: String
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
@@ -69,7 +72,8 @@ fun DashboardScreen(
val scope = rememberCoroutineScope()
Box(
modifier = modifier.fillMaxSize()
modifier = modifier
.fillMaxSize()
) {
when {
uiState.isLoading && uiState.recentAlerts.isEmpty() -> {
@@ -104,14 +108,15 @@ fun DashboardScreen(
scope.launch {
viewModel.refresh()
}
}
},
isRefreshing = uiState.isLoading
)
}
}
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 16.dp),
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
color = MaterialTheme.colorScheme.primary
)
}
@@ -138,30 +143,39 @@ private fun DashboardLoadingState() {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DashboardContent(
uiState: DashboardViewModel.DashboardUiState,
onNavigateToAlert: (String) -> Unit,
onNavigateToService: (String) -> Unit,
onRefresh: () -> Unit
onRefresh: () -> Unit,
isRefreshing: Boolean
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
if (value == SwipeToDismissBoxValue.EndToStart) {
onRefresh()
true
} else {
false
}
}
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Dashboard",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
IconButton(onClick = onRefresh) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_dashboard),
contentDescription = "Refresh"
)
}
}
}
item {
DashboardHeader(uiState)
}
@@ -173,6 +187,12 @@ private fun DashboardContent(
)
}
item {
QuickActionsRow(
onNavigateToService = onNavigateToService
)
}
if (uiState.recentAlerts.isNotEmpty()) {
item {
Text(
@@ -283,6 +303,55 @@ private fun ServiceCard(
}
}
@Composable
private fun QuickActionsRow(
onNavigateToService: (String) -> Unit
) {
val actions = listOf(
QuickAction("DarkWatch", ImageVector.vectorResource(R.drawable.ic_services), "darkwatch"),
QuickAction("VoicePrint", ImageVector.vectorResource(R.drawable.ic_services), "voiceprint"),
QuickAction("SpamShield", ImageVector.vectorResource(R.drawable.ic_services), "spamshield"),
QuickAction("Settings", ImageVector.vectorResource(R.drawable.ic_settings), "settings")
)
Text(
text = "Quick Actions",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(actions) { action ->
ShieldCard(
onClick = { onNavigateToService(action.route) },
modifier = Modifier.width(100.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(8.dp)
) {
Icon(
imageVector = action.icon,
contentDescription = action.label,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = action.label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium
)
}
}
}
}
}
@Composable
private fun AlertCard(
alert: Alert,
@@ -319,7 +388,7 @@ private fun AlertCard(
}
@Composable
private fun AlertSeverityBadge(severity: String) {
fun AlertSeverityBadge(severity: String) {
val variant = when (severity.lowercase()) {
"critical" -> BadgeVariant.Error
"high" -> BadgeVariant.Warning

View File

@@ -1,5 +1,6 @@
package com.kordant.android.ui.screens.services
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -18,10 +19,13 @@ import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -37,6 +41,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.kordant.android.R
import com.kordant.android.ui.components.BadgeVariant
import com.kordant.android.ui.components.ShieldBadge
import com.kordant.android.ui.components.ShieldButton
import com.kordant.android.ui.components.ShieldButtonVariant
@@ -44,6 +49,7 @@ import com.kordant.android.ui.components.ShieldCard
import com.kordant.android.ui.components.ShieldEmptyState
import com.kordant.android.ui.components.ShieldTextField
import com.kordant.android.viewmodel.DarkWatchViewModel
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -110,6 +116,9 @@ fun DarkWatchScreen(
else -> {
DarkWatchContent(
uiState = uiState,
onDeleteWatchlistItem = { id ->
viewModel.removeWatchlistItem(id)
},
modifier = Modifier.padding(paddingValues)
)
}
@@ -143,6 +152,7 @@ fun DarkWatchScreen(
@Composable
private fun DarkWatchContent(
uiState: DarkWatchViewModel.DarkWatchUiState,
onDeleteWatchlistItem: (String) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
@@ -161,7 +171,10 @@ private fun DarkWatchContent(
}
items(uiState.watchlist) { item ->
WatchlistItemCard(item)
WatchlistItemWithDismiss(
item = item,
onDelete = { onDeleteWatchlistItem(item.id) }
)
Spacer(modifier = Modifier.height(8.dp))
}
}
@@ -185,6 +198,57 @@ private fun DarkWatchContent(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WatchlistItemWithDismiss(
item: com.kordant.android.data.model.WatchlistItem,
onDelete: () -> Unit
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
if (value == SwipeToDismissBoxValue.EndToStart) {
onDelete()
true
} else {
false
}
},
positionalThreshold = { it * 0.75f }
)
SwipeToDismissBox(
state = dismissState,
backgroundContent = {
SwipeToDeleteBackground(dismissState)
},
content = {
WatchlistItemCard(item)
}
)
}
@Composable
private fun SwipeToDeleteBackground(dismissState: androidx.compose.material3.SwipeToDismissBoxState) {
val color = MaterialTheme.colorScheme.error
val isDismissed = dismissState.currentValue == SwipeToDismissBoxValue.EndToStart
val isDragging = dismissState.dismissDirection == SwipeToDismissBoxValue.EndToStart
androidx.compose.foundation.layout.Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.background(color, MaterialTheme.shapes.medium),
contentAlignment = Alignment.CenterEnd
) {
androidx.compose.material3.Icon(
painter = painterResource(R.drawable.ic_alerts),
contentDescription = "Delete",
tint = if (isDismissed || isDragging) color else color.copy(alpha = 0.5f),
modifier = Modifier.padding(end = 16.dp)
)
}
}
@Composable
private fun WatchlistItemCard(item: com.kordant.android.data.model.WatchlistItem) {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
@@ -214,8 +278,8 @@ private fun WatchlistItemCard(item: com.kordant.android.data.model.WatchlistItem
}
ShieldBadge(
text = item.status,
variant = if (item.status == "active") com.kordant.android.ui.components.BadgeVariant.Success
else com.kordant.android.ui.components.BadgeVariant.Default
variant = if (item.status == "active") BadgeVariant.Success
else BadgeVariant.Default
)
}
}
@@ -238,9 +302,9 @@ private fun ExposureCard(exposure: com.kordant.android.data.model.Exposure) {
ShieldBadge(
text = exposure.severity,
variant = when (exposure.severity.lowercase()) {
"critical" -> com.kordant.android.ui.components.BadgeVariant.Error
"high" -> com.kordant.android.ui.components.BadgeVariant.Warning
else -> com.kordant.android.ui.components.BadgeVariant.Info
"critical" -> BadgeVariant.Error
"high" -> BadgeVariant.Warning
else -> BadgeVariant.Info
}
)
}

View File

@@ -9,12 +9,15 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
@@ -58,11 +61,24 @@ fun RemoveBrokersScreen(
var selectedListingId by remember { mutableStateOf("") }
var selectedListingName by remember { mutableStateOf("") }
var notes by remember { mutableStateOf("") }
var searchQuery by remember { mutableStateOf("") }
var selectedCategory by remember { mutableStateOf("All") }
val categories = listOf("All", "Zillow", "Realtor", "Redfin", "Other")
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState()
)
val filteredListings = uiState.listings.filter { listing ->
val matchesSearch = searchQuery.isEmpty() ||
listing.brokerName.contains(searchQuery, ignoreCase = true) ||
(listing.propertyAddress?.contains(searchQuery, ignoreCase = true) ?: false)
val matchesCategory = selectedCategory == "All" ||
listing.brokerName.contains(selectedCategory, ignoreCase = true)
matchesSearch && matchesCategory
}
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@@ -111,6 +127,12 @@ fun RemoveBrokersScreen(
else -> {
RemoveBrokersContent(
uiState = uiState,
filteredListings = filteredListings,
searchQuery = searchQuery,
onSearchQueryChange = { searchQuery = it },
selectedCategory = selectedCategory,
onCategoryChange = { selectedCategory = it },
categories = categories,
modifier = Modifier.padding(paddingValues)
)
}
@@ -144,6 +166,12 @@ fun RemoveBrokersScreen(
@Composable
private fun RemoveBrokersContent(
uiState: RemoveBrokersViewModel.RemoveBrokersUiState,
filteredListings: List<com.kordant.android.data.model.BrokerListing>,
searchQuery: String,
onSearchQueryChange: (String) -> Unit,
selectedCategory: String,
onCategoryChange: (String) -> Unit,
categories: List<String>,
modifier: Modifier = Modifier
) {
LazyColumn(
@@ -151,17 +179,40 @@ private fun RemoveBrokersContent(
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (uiState.listings.isNotEmpty()) {
item {
ShieldTextField(
value = searchQuery,
onValueChange = onSearchQueryChange,
label = "Search listings",
modifier = Modifier.fillMaxWidth()
)
}
item {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(categories) { category ->
FilterChip(
selected = selectedCategory == category,
onClick = { onCategoryChange(category) },
label = { Text(category) }
)
}
}
}
if (filteredListings.isNotEmpty()) {
item {
Text(
text = "Broker Listings (${uiState.listings.size})",
text = "Broker Listings (${filteredListings.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
}
items(uiState.listings) { listing ->
items(filteredListings) { listing ->
ListingCard(listing)
Spacer(modifier = Modifier.height(8.dp))
}
@@ -227,6 +278,13 @@ private fun ListingCard(listing: com.kordant.android.data.model.BrokerListing) {
@Composable
private fun RemovalRequestCard(request: com.kordant.android.data.model.RemovalRequest) {
val progress = when (request.status.lowercase()) {
"completed" -> 1f
"in_progress" -> 0.5f
"pending" -> 0.25f
else -> 0f
}
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column {
Row(
@@ -264,6 +322,11 @@ private fun RemovalRequestCard(request: com.kordant.android.data.model.RemovalRe
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth().height(4.dp)
)
}
}
}

View File

@@ -38,6 +38,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.kordant.android.R
import com.kordant.android.ui.components.BadgeVariant
import com.kordant.android.ui.components.ShieldBadge
import com.kordant.android.ui.components.ShieldButton
import com.kordant.android.ui.components.ShieldButtonVariant
@@ -46,6 +47,14 @@ import com.kordant.android.ui.components.ShieldEmptyState
import com.kordant.android.ui.components.ShieldTextField
import com.kordant.android.viewmodel.SpamShieldViewModel
data class NumberCheckResult(
val phoneNumber: String,
val isSpam: Boolean,
val spamScore: Int,
val carrier: String?,
val lineType: String?
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SpamShieldScreen(
@@ -58,6 +67,9 @@ fun SpamShieldScreen(
var newPattern by remember { mutableStateOf("") }
var newAction by remember { mutableStateOf("block") }
var newDescription by remember { mutableStateOf("") }
var checkNumber by remember { mutableStateOf("") }
var checkResult by remember { mutableStateOf<NumberCheckResult?>(null) }
var isChecking by remember { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState()
@@ -97,6 +109,24 @@ fun SpamShieldScreen(
else -> {
SpamShieldContent(
uiState = uiState,
checkNumber = checkNumber,
onCheckNumberChange = { checkNumber = it },
checkResult = checkResult,
isChecking = isChecking,
onCheckNumber = {
isChecking = true
checkResult = NumberCheckResult(
phoneNumber = checkNumber,
isSpam = checkNumber.contains("spam", ignoreCase = true),
spamScore = if (checkNumber.contains("spam", ignoreCase = true)) 85 else 15,
carrier = "Verizon",
lineType = "Mobile"
)
isChecking = false
},
onToggleRule = { id, enabled ->
viewModel.toggleRule(id, enabled)
},
modifier = Modifier.padding(paddingValues)
)
}
@@ -130,6 +160,12 @@ fun SpamShieldScreen(
@Composable
private fun SpamShieldContent(
uiState: SpamShieldViewModel.SpamShieldUiState,
checkNumber: String,
onCheckNumberChange: (String) -> Unit,
checkResult: NumberCheckResult?,
isChecking: Boolean,
onCheckNumber: () -> Unit,
onToggleRule: (String, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
@@ -145,6 +181,16 @@ private fun SpamShieldContent(
)
}
item {
NumberCheckSection(
number = checkNumber,
onNumberChange = onCheckNumberChange,
result = checkResult,
isChecking = isChecking,
onCheck = onCheckNumber
)
}
if (uiState.rules.isNotEmpty()) {
item {
Text(
@@ -156,9 +202,9 @@ private fun SpamShieldContent(
}
items(uiState.rules) { rule ->
RuleCard(rule) { enabled ->
viewModel.toggleRule(rule.id, enabled)
}
RuleCard(rule, onToggle = { enabled ->
onToggleRule(rule.id, enabled)
})
Spacer(modifier = Modifier.height(8.dp))
}
} else {
@@ -179,6 +225,103 @@ private fun SpamShieldContent(
}
}
@Composable
private fun NumberCheckSection(
number: String,
onNumberChange: (String) -> Unit,
result: NumberCheckResult?,
isChecking: Boolean,
onCheck: () -> Unit
) {
Column {
Text(
text = "Number Check",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
ShieldCard {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ShieldTextField(
value = number,
onValueChange = onNumberChange,
label = "Enter phone number",
modifier = Modifier.weight(1f)
)
ShieldButton(
text = "Check",
onClick = onCheck,
variant = ShieldButtonVariant.Primary,
enabled = number.isNotBlank(),
loading = isChecking
)
}
result?.let {
Spacer(modifier = Modifier.height(8.dp))
NumberCheckResultCard(it)
}
}
}
}
}
@Composable
private fun NumberCheckResultCard(result: NumberCheckResult) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = result.phoneNumber,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
ShieldBadge(
text = if (result.isSpam) "Likely Spam" else "Safe",
variant = if (result.isSpam) BadgeVariant.Error else BadgeVariant.Success
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
result.carrier?.let {
Text(
text = "Carrier: $it",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
result.lineType?.let {
Text(
text = "Type: $it",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
text = "Spam Score: ${result.spamScore}%",
style = MaterialTheme.typography.labelMedium,
color = if (result.isSpam) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun SpamStatsRow(
blocked: Int,
@@ -244,13 +387,13 @@ private fun RuleCard(
) {
ShieldBadge(
text = rule.action,
variant = if (rule.action == "block") com.kordant.android.ui.components.BadgeVariant.Error
else com.kordant.android.ui.components.BadgeVariant.Warning
variant = if (rule.action == "block") BadgeVariant.Error
else BadgeVariant.Warning
)
if (rule.priority > 0) {
ShieldBadge(
text = "P${rule.priority}",
variant = com.kordant.android.ui.components.BadgeVariant.Info
variant = BadgeVariant.Info
)
}
}

View File

@@ -37,6 +37,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.kordant.android.R
import com.kordant.android.data.model.VoiceAnalysis
import com.kordant.android.ui.components.BadgeVariant
import com.kordant.android.ui.components.ShieldBadge
import com.kordant.android.ui.components.ShieldButton
@@ -92,7 +93,7 @@ fun VoicePrintScreen(
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
uiState.enrollments.isEmpty() -> {
uiState.enrollments.isEmpty() && uiState.analyses.isEmpty() -> {
ShieldEmptyState(
title = "No enrollments",
description = "Enroll voice profiles to detect impersonation",
@@ -109,6 +110,9 @@ fun VoicePrintScreen(
else -> {
VoicePrintContent(
uiState = uiState,
onDeleteEnrollment = { id ->
viewModel.deleteEnrollment(id)
},
modifier = Modifier.padding(paddingValues)
)
}
@@ -136,6 +140,7 @@ fun VoicePrintScreen(
@Composable
private fun VoicePrintContent(
uiState: VoicePrintViewModel.VoicePrintUiState,
onDeleteEnrollment: (String) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
@@ -143,24 +148,46 @@ private fun VoicePrintContent(
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Text(
text = "Enrollments (${uiState.enrollments.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
if (uiState.enrollments.isNotEmpty()) {
item {
Text(
text = "Enrollments (${uiState.enrollments.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
}
items(uiState.enrollments) { enrollment ->
EnrollmentCard(enrollment, onDelete = { onDeleteEnrollment(enrollment.id) })
Spacer(modifier = Modifier.height(8.dp))
}
}
items(uiState.enrollments) { enrollment ->
EnrollmentCard(enrollment)
Spacer(modifier = Modifier.height(8.dp))
if (uiState.analyses.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Analysis History (${uiState.analyses.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
}
items(uiState.analyses) { analysis ->
AnalysisCard(analysis)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@Composable
private fun EnrollmentCard(enrollment: com.kordant.android.data.model.VoiceEnrollment) {
private fun EnrollmentCard(
enrollment: com.kordant.android.data.model.VoiceEnrollment,
onDelete: () -> Unit
) {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column {
Row(
@@ -200,6 +227,56 @@ private fun EnrollmentCard(enrollment: com.kordant.android.data.model.VoiceEnrol
}
}
@Composable
private fun AnalysisCard(analysis: VoiceAnalysis) {
val verdictText = when (analysis.result?.lowercase()) {
"match", "verified" -> "Verified"
"no_match", "impersonation" -> "Impersonation"
"unknown", "inconclusive" -> "Unknown"
else -> analysis.result?.uppercase() ?: "Pending"
}
val variant = when (analysis.result?.lowercase()) {
"match", "verified" -> BadgeVariant.Success
"no_match", "impersonation" -> BadgeVariant.Error
"unknown", "inconclusive" -> BadgeVariant.Warning
else -> BadgeVariant.Default
}
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Analysis #${analysis.id.take(8)}",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Text(
text = "Confidence: ${"%.1f".format(analysis.confidence * 100)}%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
ShieldBadge(
text = verdictText,
variant = variant
)
}
analysis.createdAt?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Date: $it",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EnrollSheet(

View File

@@ -1,6 +1,7 @@
package com.kordant.android.ui.screens.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -11,9 +12,12 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -40,9 +44,17 @@ import com.kordant.android.ui.components.ShieldButton
import com.kordant.android.ui.components.ShieldButtonVariant
import com.kordant.android.ui.components.ShieldCard
import com.kordant.android.ui.components.ShieldEmptyState
import com.kordant.android.ui.components.ShieldTextField
import com.kordant.android.viewmodel.AuthViewModel
import com.kordant.android.viewmodel.SettingsViewModel
data class FamilyMember(
val id: String,
val name: String,
val email: String,
val role: String = "member"
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
@@ -53,6 +65,8 @@ fun SettingsScreen(
) {
val uiState by viewModel.uiState.collectAsState()
var showLogoutDialog by remember { mutableStateOf(false) }
var showInviteDialog by remember { mutableStateOf(false) }
var inviteEmail by remember { mutableStateOf("") }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState()
@@ -99,6 +113,7 @@ fun SettingsScreen(
onToggleBiometric = { viewModel.toggleBiometric(it) },
onUpgradeSubscription = { viewModel.upgradeSubscription() },
onShowLogoutDialog = { showLogoutDialog = true },
onShowInviteDialog = { showInviteDialog = true },
modifier = Modifier.padding(paddingValues)
)
}
@@ -129,6 +144,21 @@ fun SettingsScreen(
}
)
}
if (showInviteDialog) {
InviteFamilyDialog(
onDismiss = {
showInviteDialog = false
inviteEmail = ""
},
onInvite = {
showInviteDialog = false
inviteEmail = ""
},
email = inviteEmail,
onEmailChange = { inviteEmail = it }
)
}
}
}
@@ -140,6 +170,7 @@ private fun SettingsContent(
onToggleBiometric: (Boolean) -> Unit,
onUpgradeSubscription: () -> Unit,
onShowLogoutDialog: () -> Unit,
onShowInviteDialog: () -> Unit,
modifier: Modifier = Modifier
) {
val user = uiState.user!!
@@ -171,6 +202,14 @@ private fun SettingsContent(
)
}
item {
ThemeSection()
}
item {
FamilySection(onInvite = onShowInviteDialog)
}
item {
Spacer(modifier = Modifier.height(16.dp))
ShieldButton(
@@ -197,10 +236,10 @@ private fun AccountSection(user: com.kordant.android.data.model.User) {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
ShieldAvatar(
name = user.name,
imageUrl = user.avatarUrl
)
ShieldAvatar(
name = user.name,
imageUrl = user.avatarUrl
)
Column {
Text(
text = user.name,
@@ -311,6 +350,120 @@ private fun PreferencesSection(
}
}
@Composable
private fun ThemeSection() {
var expanded by remember { mutableStateOf(false) }
var selectedTheme by remember { mutableStateOf("System") }
val themes = listOf("System", "Light", "Dark")
Column {
Text(
text = "Theme",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
ShieldCard {
Box {
ShieldTextField(
value = selectedTheme,
onValueChange = { },
label = "Theme",
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
readOnly = true
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
themes.forEach { theme ->
DropdownMenuItem(
text = { Text(theme) },
onClick = {
selectedTheme = theme
expanded = false
}
)
}
}
}
}
}
}
@Composable
private fun FamilySection(onInvite: () -> Unit) {
val familyMembers = listOf(
FamilyMember("1", "John Doe", "john@example.com", "admin"),
FamilyMember("2", "Jane Doe", "jane@example.com", "member")
)
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Family Group",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
TextButton(onClick = onInvite) {
Text(
text = "Invite",
color = MaterialTheme.colorScheme.primary
)
}
}
Spacer(modifier = Modifier.height(8.dp))
ShieldCard {
Column {
familyMembers.forEachIndexed { index, member ->
FamilyMemberRow(member)
if (index < familyMembers.size - 1) {
Divider()
}
}
}
}
}
}
@Composable
private fun FamilyMemberRow(member: FamilyMember) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = member.name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = member.email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
ShieldBadge(
text = member.role,
variant = if (member.role == "admin") com.kordant.android.ui.components.BadgeVariant.Info
else com.kordant.android.ui.components.BadgeVariant.Default
)
}
}
@Composable
private fun SettingRow(
title: String,
@@ -344,3 +497,40 @@ private fun SettingRow(
)
}
}
@Composable
private fun InviteFamilyDialog(
onDismiss: () -> Unit,
onInvite: () -> Unit,
email: String,
onEmailChange: (String) -> Unit
) {
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Invite Family Member") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Send an invitation to join your family group.")
ShieldTextField(
value = email,
onValueChange = onEmailChange,
label = "Email address",
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = onInvite,
enabled = email.isNotBlank()
) {
Text("Invite")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View File

@@ -0,0 +1,404 @@
package com.kordant.android.ui.screens.voiceprint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kordant.android.R
import com.kordant.android.ui.components.ShieldButton
import com.kordant.android.ui.components.ShieldButtonVariant
import com.kordant.android.ui.theme.Error
import com.kordant.android.ui.theme.Success
import com.kordant.android.util.PermissionManager
import com.kordant.android.util.rememberPermissionManager
import com.kordant.android.util.rememberPermissionLauncher
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.File
import kotlin.math.abs
/**
* Voice recording screen with real-time waveform visualization.
* Captures audio at 16kHz mono 16-bit PCM for VoicePrint enrollment.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecordingScreen(
enrollmentId: String,
onComplete: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val permissionManager = rememberPermissionManager()
var isRecording by remember { mutableStateOf(false) }
var isPaused by remember { mutableStateOf(false) }
var duration by remember { mutableStateOf(0) }
var amplitude by remember { mutableFloatStateOf(0f) }
var waveformData by remember { mutableStateOf<List<Float>>(emptyList()) }
var isUploading by remember { mutableStateOf(false) }
var hasPermission by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
val minDuration = 5
val maxDuration = 30
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState()
)
val requestMicPermission = rememberPermissionLauncher(
permission = PermissionManager.RECORD_AUDIO,
onGranted = { hasPermission = true },
onDenied = { errorMessage = "Microphone permission is required for voice recording" }
)
// Check permission on launch
if (!hasPermission && errorMessage == null) {
requestMicPermission()
}
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text("Voice Enrollment", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
TextButton(onClick = onBack) { Text("Back") }
},
scrollBehavior = scrollBehavior
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Waveform visualization
WaveformCanvas(
waveformData = waveformData,
amplitude = amplitude,
isRecording = isRecording,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
)
Spacer(modifier = Modifier.height(24.dp))
// Duration display
Text(
text = formatDuration(duration),
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold,
color = if (isRecording) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "min ${minDuration}s / max ${maxDuration}s",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
Spacer(modifier = Modifier.height(32.dp))
// Error message
errorMessage?.let { error ->
Text(
text = error,
color = Error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
}
// Recording controls
if (!hasPermission) {
Button(onClick = { requestMicPermission() }) {
Text("Grant Microphone Access")
}
} else if (!isRecording) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
ShieldButton(
text = "Start Recording",
onClick = {
isRecording = true
isPaused = false
duration = 0
waveformData = emptyList()
startRecording(scope, onAmplitude = { amp ->
amplitude = amp
waveformData = waveformData + amp
}, onDuration = { d ->
duration = d
if (d >= maxDuration) {
isRecording = false
}
})
},
variant = ShieldButtonVariant.Primary
)
}
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
ShieldButton(
text = if (isPaused) "Resume" else "Pause",
onClick = { isPaused = !isPaused },
variant = ShieldButtonVariant.Secondary
)
ShieldButton(
text = if (duration < minDuration) "Recording..." else "Stop & Submit",
onClick = {
if (duration >= minDuration) {
isRecording = false
isUploading = true
scope.launch {
try {
submitRecording(enrollmentId, context)
isUploading = false
onComplete()
} catch (e: Exception) {
errorMessage = "Upload failed: ${e.message}"
isUploading = false
}
}
}
},
variant = ShieldButtonVariant.Primary,
enabled = duration >= minDuration,
loading = isUploading
)
}
}
// Upload progress
if (isUploading) {
Spacer(modifier = Modifier.height(16.dp))
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp)
)
Text(
text = "Uploading enrollment...",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
}
}
/**
* Real-time waveform visualization using Canvas.
*/
@Composable
fun WaveformCanvas(
waveformData: List<Float>,
amplitude: Float,
isRecording: Boolean,
modifier: Modifier = Modifier
) {
Canvas(modifier = modifier) {
val width = size.width
val height = size.height
val centerY = height / 2
val maxPoints = 100
// Draw background
drawRect(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
cornerRadius = CornerRadius(8.dp.toPx())
)
// Draw center line
drawLine(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f),
start = Offset(0f, centerY),
end = Offset(width, centerY),
strokeWidth = 1.dp.toPx()
)
// Draw waveform
if (waveformData.isNotEmpty()) {
val data = waveformData.takeLast(maxPoints)
val step = width / maxPoints
for (i in data.indices) {
val x = i * step
val y = centerY + data[i] * centerY * 0.8f
val color = if (i == data.lastIndex) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
}
drawCircle(
color = color,
radius = 3.dp.toPx(),
center = Offset(x, y)
)
if (i > 0) {
drawLine(
color = color,
start = Offset((i - 1) * step, centerY + data[i - 1] * centerY * 0.8f),
end = Offset(x, y),
strokeWidth = 2.dp.toPx()
)
}
}
} else if (isRecording) {
// Pulsing animation when recording starts
drawCircle(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
radius = 24.dp.toPx(),
center = Offset(width / 2, centerY)
)
} else {
// Idle state
Text(
text = "Tap to start recording",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
modifier = Modifier.align(Alignment.Center)
)
}
}
}
/**
* Start audio recording at 16kHz mono 16-bit PCM.
*/
private fun startRecording(
scope: kotlinx.coroutines.CoroutineScope,
onAmplitude: (Float) -> Unit,
onDuration: (Int) -> Unit
) {
val sampleRate = 16000
val channelConfig = AudioFormat.CHANNEL_IN_MONO
val audioFormat = AudioFormat.ENCODING_PCM_16BIT
val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
val audioRecord = AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRate,
channelConfig,
audioFormat,
bufferSize
)
if (audioRecord.state != AudioRecord.STATE_INITIALIZED) {
return
}
audioRecord.startRecording()
scope.launch {
var seconds = 0
val buffer = ShortArray(bufferSize)
while (true) {
val read = audioRecord.read(buffer, 0, bufferSize)
if (read > 0) {
var sum = 0
for (i in 0 until read) {
sum += abs(buffer[i].toInt())
}
val rms = Math.sqrt((sum * sum / read).toDouble()).toFloat()
val normalized = (rms / 32768f).coerceIn(0f, 1f)
onAmplitude(normalized)
}
delay(50)
seconds++
onDuration(seconds)
if (seconds >= 30) {
break
}
}
audioRecord.stop()
audioRecord.release()
}
}
/**
* Submit recorded audio to the backend.
*/
private suspend fun submitRecording(enrollmentId: String, context: android.content.Context) {
// In a real implementation, this would upload the audio file
// For now, we simulate the upload
kotlinx.coroutines.delay(2000)
}
/**
* Format duration as MM:SS.
*/
private fun formatDuration(seconds: Int): String {
val minutes = seconds / 60
val secs = seconds % 60
return "%02d:%02d".format(minutes, secs)
}

View File

@@ -0,0 +1,131 @@
package com.kordant.android.util
// No Manifest import needed - use android.Manifest inline
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
/**
* Centralized manager for runtime permissions.
* Handles checking, requesting, rationale dialogs, and guiding to Settings.
*/
class PermissionManager(private val context: Context) {
companion object {
val RECORD_AUDIO = PermissionDef(
android.Manifest.permission.RECORD_AUDIO,
"Microphone",
"Kordant needs microphone access to record voice samples for VoicePrint enrollment and spam call analysis."
)
val CAMERA = PermissionDef(
android.Manifest.permission.CAMERA,
"Camera",
"Kordant needs camera access to capture photos for document verification."
)
val POST_NOTIFICATIONS = PermissionDef(
android.Manifest.permission.POST_NOTIFICATIONS,
"Notifications",
"Kordant needs notification access to alert you about security threats and data exposures in real time."
)
val READ_PHONE_STATE = PermissionDef(
android.Manifest.permission.READ_PHONE_STATE,
"Phone State",
"Kordant needs phone state access to screen incoming calls with SpamShield and detect spam calls."
)
val ANSWER_PHONE_CALLS = PermissionDef(
android.Manifest.permission.ANSWER_PHONE_CALLS,
"Call Screening",
"Kordant needs call screening permission to automatically block known spam numbers."
)
}
data class PermissionDef(
val name: String,
val label: String,
val rationale: String
)
/**
* Check if a permission is currently granted.
*/
fun isGranted(permission: PermissionDef): Boolean =
ContextCompat.checkSelfPermission(context, permission.name) == PackageManager.PERMISSION_GRANTED
/**
* Check if we should show a rationale dialog before requesting.
*/
fun shouldShowRationale(activity: Activity, permission: PermissionDef): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return activity.shouldShowRequestPermissionRationale(permission.name)
}
return false
}
/**
* Check if a permission is permanently denied (user selected "Don't ask again").
*/
fun isPermanentlyDenied(activity: Activity, permission: PermissionDef): Boolean =
!shouldShowRationale(activity, permission) && !isGranted(permission)
/**
* Open the app's Settings page so the user can manually grant permissions.
*/
fun openAppSettings() {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(this)
}
}
}
/**
* Composable that manages permission request lifecycle.
* Returns a callback that requests the permission and tracks the result.
*/
@Composable
fun rememberPermissionManager(): PermissionManager {
val context = LocalContext.current
return remember { PermissionManager(context) }
}
/**
* Composable helper that launches a permission request and tracks the result.
*/
@Composable
fun PermissionManager.rememberPermissionLauncher(
permission: PermissionManager.PermissionDef,
onGranted: () -> Unit,
onDenied: () -> Unit
): () -> Unit {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
onGranted()
} else {
onDenied()
}
}
return {
if (isGranted(permission)) {
onGranted()
} else {
launcher.launch(permission.name)
}
}
}

View File

@@ -12,15 +12,15 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class AlertDetailUiState(
val alert: Alert? = null,
val correlatedAlerts: List<Alert> = emptyList(),
val isLoading: Boolean = true,
val isResolving: Boolean = false,
val error: String? = null
)
class AlertDetailViewModel : ViewModel() {
data class AlertDetailUiState(
val alert: Alert? = null,
val correlatedAlerts: List<Alert> = emptyList(),
val isLoading: Boolean = true,
val isResolving: Boolean = false,
val error: String? = null
)
private val _uiState = MutableStateFlow(AlertDetailUiState())
val uiState: StateFlow<AlertDetailUiState> = _uiState.asStateFlow()

View File

@@ -13,19 +13,19 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class DarkWatchUiState(
val watchlist: List<WatchlistItem> = emptyList(),
val exposures: List<Exposure> = emptyList(),
val isLoading: Boolean = true,
val isAdding: Boolean = false,
val error: String? = null
)
class DarkWatchViewModel : ViewModel() {
data class DarkWatchUiState(
val watchlist: List<WatchlistItem> = emptyList(),
val exposures: List<Exposure> = emptyList(),
val isLoading: Boolean = true,
val isAdding: Boolean = false,
val error: String? = null
)
private val _uiState = MutableStateFlow(DarkWatchUiState())
val uiState: StateFlow<DarkWatchUiState> = _uiState.asStateFlow()
private val repo: DarkWatchRepository by lazy {
private val darkWatchRepo: DarkWatchRepository by lazy {
RepositoryModule.provideDarkWatchRepository(KordantApp.instance)
}
@@ -41,8 +41,8 @@ class DarkWatchViewModel : ViewModel() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = !forceRefresh, error = null)
try {
val watchlistResult = repo.getWatchlist(forceRefresh)
val exposuresResult = repo.getExposures(forceRefresh)
val watchlistResult = darkWatchRepo.getWatchlist(forceRefresh)
val exposuresResult = darkWatchRepo.getExposures(forceRefresh)
val watchlist = if (watchlistResult is com.kordant.android.data.remote.ApiResult.Success) {
watchlistResult.data
@@ -69,23 +69,34 @@ class DarkWatchViewModel : ViewModel() {
fun addWatchlistItem(type: String, value: String, label: String? = null) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isAdding = true, error = null)
val result = repo.addWatchlistItem(type, value, label)
if (result is com.kordant.android.data.remote.ApiResult.Error) {
try {
val result = darkWatchRepo.addWatchlistItem(type, value, label)
if (result is com.kordant.android.data.remote.ApiResult.Error) {
_uiState.value = _uiState.value.copy(
isAdding = false,
error = result.message
)
} else {
_uiState.value = _uiState.value.copy(isAdding = false)
loadData(forceRefresh = true)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isAdding = false,
error = result.message
error = e.message ?: "Failed to add watchlist item"
)
} else {
_uiState.value = _uiState.value.copy(isAdding = false)
loadData(forceRefresh = true)
}
}
}
fun removeWatchlistItem(id: String) {
viewModelScope.launch {
repo.removeWatchlistItem(id)
loadData(forceRefresh = true)
try {
darkWatchRepo.removeWatchlistItem(id)
loadData(forceRefresh = true)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}

View File

@@ -17,20 +17,20 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class DashboardUiState(
val isLoading: Boolean = false,
val threatScore: Int = 0,
val recentAlerts: List<Alert> = emptyList(),
val unreadCount: Int = 0,
val watchlistCount: Int = 0,
val enrollmentCount: Int = 0,
val spamRulesCount: Int = 0,
val propertiesCount: Int = 0,
val removalsCount: Int = 0,
val error: String? = null
)
class DashboardViewModel : ViewModel() {
data class DashboardUiState(
val isLoading: Boolean = false,
val threatScore: Int = 0,
val recentAlerts: List<Alert> = emptyList(),
val unreadCount: Int = 0,
val watchlistCount: Int = 0,
val enrollmentCount: Int = 0,
val spamRulesCount: Int = 0,
val propertiesCount: Int = 0,
val removalsCount: Int = 0,
val error: String? = null
)
private val _uiState = MutableStateFlow(DashboardUiState())
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
@@ -125,7 +125,11 @@ class DashboardViewModel : ViewModel() {
fun markAlertRead(alertId: String) {
viewModelScope.launch {
alertRepo.markRead(alertId)
try {
alertRepo.markRead(alertId)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}

View File

@@ -12,14 +12,14 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class HomeTitleUiState(
val properties: List<Property> = emptyList(),
val isLoading: Boolean = true,
val isAdding: Boolean = false,
val error: String? = null
)
class HomeTitleViewModel : ViewModel() {
data class HomeTitleUiState(
val properties: List<Property> = emptyList(),
val isLoading: Boolean = true,
val isAdding: Boolean = false,
val error: String? = null
)
private val _uiState = MutableStateFlow(HomeTitleUiState())
val uiState: StateFlow<HomeTitleUiState> = _uiState.asStateFlow()
@@ -60,15 +60,22 @@ class HomeTitleViewModel : ViewModel() {
fun addProperty(address: String, type: String = "residential") {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isAdding = true, error = null)
val result = repo.addProperty(address, type)
if (result is com.kordant.android.data.remote.ApiResult.Error) {
try {
val result = repo.addProperty(address, type)
if (result is com.kordant.android.data.remote.ApiResult.Error) {
_uiState.value = _uiState.value.copy(
isAdding = false,
error = result.message
)
} else {
_uiState.value = _uiState.value.copy(isAdding = false)
loadProperties(forceRefresh = true)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isAdding = false,
error = result.message
error = e.message ?: "Failed to add property"
)
} else {
_uiState.value = _uiState.value.copy(isAdding = false)
loadProperties(forceRefresh = true)
}
}
}

View File

@@ -13,15 +13,15 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class RemoveBrokersUiState(
val listings: List<BrokerListing> = emptyList(),
val removalRequests: List<RemovalRequest> = emptyList(),
val isLoading: Boolean = true,
val isCreating: Boolean = false,
val error: String? = null
)
class RemoveBrokersViewModel : ViewModel() {
data class RemoveBrokersUiState(
val listings: List<BrokerListing> = emptyList(),
val removalRequests: List<RemovalRequest> = emptyList(),
val isLoading: Boolean = true,
val isCreating: Boolean = false,
val error: String? = null
)
private val _uiState = MutableStateFlow(RemoveBrokersUiState())
val uiState: StateFlow<RemoveBrokersUiState> = _uiState.asStateFlow()
@@ -69,15 +69,22 @@ class RemoveBrokersViewModel : ViewModel() {
fun createRemovalRequest(listingId: String, notes: String? = null) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isCreating = true, error = null)
val result = repo.createRemovalRequest(listingId, notes)
if (result is com.kordant.android.data.remote.ApiResult.Error) {
try {
val result = repo.createRemovalRequest(listingId, notes)
if (result is com.kordant.android.data.remote.ApiResult.Error) {
_uiState.value = _uiState.value.copy(
isCreating = false,
error = result.message
)
} else {
_uiState.value = _uiState.value.copy(isCreating = false)
loadData(forceRefresh = true)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isCreating = false,
error = result.message
error = e.message ?: "Failed to create removal request"
)
} else {
_uiState.value = _uiState.value.copy(isCreating = false)
loadData(forceRefresh = true)
}
}
}

View File

@@ -14,17 +14,17 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class SettingsUiState(
val user: User? = null,
val subscription: Subscription? = null,
val isLoading: Boolean = true,
val notificationsEnabled: Boolean = true,
val darkModeEnabled: Boolean = false,
val biometricEnabled: Boolean = false,
val error: String? = null
)
class SettingsViewModel : ViewModel() {
data class SettingsUiState(
val user: User? = null,
val subscription: Subscription? = null,
val isLoading: Boolean = true,
val notificationsEnabled: Boolean = true,
val darkModeEnabled: Boolean = false,
val biometricEnabled: Boolean = false,
val error: String? = null
)
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()

View File

@@ -12,17 +12,17 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class SpamShieldUiState(
val rules: List<SpamRule> = emptyList(),
val totalBlocked: Int = 0,
val totalFlagged: Int = 0,
val activeRules: Int = 0,
val isLoading: Boolean = true,
val isCreating: Boolean = false,
val error: String? = null
)
class SpamShieldViewModel : ViewModel() {
data class SpamShieldUiState(
val rules: List<SpamRule> = emptyList(),
val totalBlocked: Int = 0,
val totalFlagged: Int = 0,
val activeRules: Int = 0,
val isLoading: Boolean = true,
val isCreating: Boolean = false,
val error: String? = null
)
private val _uiState = MutableStateFlow(SpamShieldUiState())
val uiState: StateFlow<SpamShieldUiState> = _uiState.asStateFlow()
@@ -67,23 +67,34 @@ class SpamShieldViewModel : ViewModel() {
fun createRule(pattern: String, action: String, description: String? = null) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isCreating = true, error = null)
val result = repo.createRule(pattern, action, description)
if (result is com.kordant.android.data.remote.ApiResult.Error) {
try {
val result = repo.createRule(pattern, action, description)
if (result is com.kordant.android.data.remote.ApiResult.Error) {
_uiState.value = _uiState.value.copy(
isCreating = false,
error = result.message
)
} else {
_uiState.value = _uiState.value.copy(isCreating = false)
loadRules(forceRefresh = true)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isCreating = false,
error = result.message
error = e.message ?: "Failed to create rule"
)
} else {
_uiState.value = _uiState.value.copy(isCreating = false)
loadRules(forceRefresh = true)
}
}
}
fun toggleRule(id: String, enabled: Boolean) {
viewModelScope.launch {
repo.toggleRule(id, enabled)
loadRules(forceRefresh = true)
try {
repo.toggleRule(id, enabled)
loadRules(forceRefresh = true)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(error = e.message)
}
}
}

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.kordant.android.KordantApp
import com.kordant.android.data.model.VoiceAnalysis
import com.kordant.android.data.model.VoiceEnrollment
import com.kordant.android.data.repository.VoicePrintRepository
import com.kordant.android.di.RepositoryModule
@@ -12,14 +13,15 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class VoicePrintUiState(
val enrollments: List<VoiceEnrollment> = emptyList(),
val isLoading: Boolean = true,
val isEnrolling: Boolean = false,
val error: String? = null
)
class VoicePrintViewModel : ViewModel() {
data class VoicePrintUiState(
val enrollments: List<VoiceEnrollment> = emptyList(),
val analyses: List<VoiceAnalysis> = emptyList(),
val isLoading: Boolean = true,
val isEnrolling: Boolean = false,
val error: String? = null
)
private val _uiState = MutableStateFlow(VoicePrintUiState())
val uiState: StateFlow<VoicePrintUiState> = _uiState.asStateFlow()
@@ -39,19 +41,22 @@ class VoicePrintViewModel : ViewModel() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = !forceRefresh, error = null)
try {
val result = if (forceRefresh) {
repo.getEnrollments()
} else {
repo.getEnrollments()
}
if (result is com.kordant.android.data.remote.ApiResult.Success) {
_uiState.value = _uiState.value.copy(
isLoading = false,
enrollments = result.data
)
} else {
_uiState.value = _uiState.value.copy(isLoading = false)
}
val enrollmentsResult = repo.getEnrollments()
val analysesResult = repo.getAnalyses()
val enrollments = if (enrollmentsResult is com.kordant.android.data.remote.ApiResult.Success) {
enrollmentsResult.data
} else emptyList()
val analyses = if (analysesResult is com.kordant.android.data.remote.ApiResult.Success) {
analysesResult.data
} else emptyList()
_uiState.value = _uiState.value.copy(
isLoading = false,
enrollments = enrollments,
analyses = analyses
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
@@ -64,15 +69,22 @@ class VoicePrintViewModel : ViewModel() {
fun createEnrollment(name: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isEnrolling = true, error = null)
val result = repo.createEnrollment(name)
if (result is com.kordant.android.data.remote.ApiResult.Error) {
try {
val result = repo.createEnrollment(name)
if (result is com.kordant.android.data.remote.ApiResult.Error) {
_uiState.value = _uiState.value.copy(
isEnrolling = false,
error = result.message
)
} else {
_uiState.value = _uiState.value.copy(isEnrolling = false)
loadEnrollments(forceRefresh = true)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isEnrolling = false,
error = result.message
error = e.message ?: "Failed to create enrollment"
)
} else {
_uiState.value = _uiState.value.copy(isEnrolling = false)
loadEnrollments(forceRefresh = true)
}
}
}

View File

@@ -2,6 +2,7 @@ package com.kordant.android.viewmodel
import com.kordant.android.data.model.Alert
import com.kordant.android.data.model.Exposure
import com.kordant.android.data.model.VoiceAnalysis
import com.kordant.android.data.model.WatchlistItem
import com.kordant.android.data.repository.AlertRepository
import com.kordant.android.data.repository.DarkWatchRepository
@@ -71,8 +72,20 @@ class DarkWatchViewModelTest {
viewModel.removeWatchlistItem("test-id")
testDispatcher.scheduler.advanceUntilIdle()
// In unit tests without app context, the repo call will fail gracefully
// The important thing is the operation completes without crashing
}
@Test
fun refresh_callsLoadData() = testScope.runTest {
val viewModel = DarkWatchViewModel()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.refresh()
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse("Should not have error from removing non-existent item", state.error != null)
// Should complete without error
}
}
@@ -97,6 +110,7 @@ class VoicePrintViewModelTest {
val state = viewModel.uiState.value
assertTrue("Initial state should be loading", state.isLoading)
assertTrue("Initial enrollments should be empty", state.enrollments.isEmpty())
assertTrue("Initial analyses should be empty", state.analyses.isEmpty())
}
@Test
@@ -122,6 +136,18 @@ class VoicePrintViewModelTest {
val state = viewModel.uiState.value
assertTrue("Should have no enrollments after deleting", state.enrollments.isEmpty())
}
@Test
fun refresh_callsLoadEnrollments() = testScope.runTest {
val viewModel = VoicePrintViewModel()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.refresh()
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
// Should complete without error
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -170,8 +196,20 @@ class SpamShieldViewModelTest {
viewModel.toggleRule("test-id", false)
testDispatcher.scheduler.advanceUntilIdle()
// In unit tests without app context, the repo call will fail gracefully
// The important thing is the operation completes without crashing
}
@Test
fun refresh_callsLoadRules() = testScope.runTest {
val viewModel = SpamShieldViewModel()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.refresh()
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse("Should have no error", state.error != null)
// Should complete without error
}
}
@@ -209,6 +247,18 @@ class HomeTitleViewModelTest {
val state = viewModel.uiState.value
assertFalse("Should not be adding after completion", state.isAdding)
}
@Test
fun refresh_callsLoadProperties() = testScope.runTest {
val viewModel = HomeTitleViewModel()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.refresh()
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
// Should complete without error
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -246,6 +296,18 @@ class RemoveBrokersViewModelTest {
val state = viewModel.uiState.value
assertFalse("Should not be creating after completion", state.isCreating)
}
@Test
fun refresh_callsLoadData() = testScope.runTest {
val viewModel = RemoveBrokersViewModel()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.refresh()
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
// Should complete without error
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -368,6 +430,18 @@ class SettingsViewModelTest {
assertTrue("Biometric should be enabled", viewModel.uiState.value.biometricEnabled)
}
@Test
fun refresh_callsLoadSettings() = testScope.runTest {
val viewModel = SettingsViewModel()
testDispatcher.scheduler.advanceUntilIdle()
viewModel.refresh()
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.uiState.value
// Should complete without error
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -417,4 +491,30 @@ class DashboardViewModelTest {
val state = viewModel.uiState.value
// Should complete without error
}
@Test
fun dashboardUiState_dataClass_properties() {
val state = DashboardViewModel.DashboardUiState(
threatScore = 50,
recentAlerts = listOf(
Alert("1", "test", "Test Alert", "Test message", "high"),
Alert("2", "test", "Another Alert", "Another message", "critical")
),
unreadCount = 2,
watchlistCount = 5,
enrollmentCount = 3,
spamRulesCount = 10,
propertiesCount = 2,
removalsCount = 1
)
assertEquals(50, state.threatScore)
assertEquals(2, state.recentAlerts.size)
assertEquals(2, state.unreadCount)
assertEquals(5, state.watchlistCount)
assertEquals(3, state.enrollmentCount)
assertEquals(10, state.spamRulesCount)
assertEquals(2, state.propertiesCount)
assertEquals(1, state.removalsCount)
}
}