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

2
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.gradle
.kotlin

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)
}
}

View File

@@ -21,6 +21,7 @@ retrofit = "2.11.0"
retrofitKotlinxSerializationConverter = "1.0.0"
kotlinxSerializationJson = "1.7.3"
work = "2.9.1"
firebaseBom = "33.10.0"
truth = "1.4.4"
mockwebserver = "4.12.0"
@@ -57,6 +58,8 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" }
truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

99
iOS/.swiftlint.yml Normal file
View File

@@ -0,0 +1,99 @@
# SwiftLint configuration for Kordant iOS
# NASA Standards: Enforce quality, readability, consistency
included:
- iOS/Kordant
- iOS/KordantTests
- iOS/KordantUITests
excluded:
- iOS/Kordant.xcodeproj
- iOS/Kordant/.swiftpm
# Rule severity
opt_in_rules:
- closure_body_length
- closure_end_indentation
- closure_spacing
- collection_alignment
- contains_over_filter_count
- contains_over_filter_is_empty
- contains_over_first_not_equal
- contains_over_range_nil_comparison
- discouraged_object_literal
- empty_count
- fatal_error_message
- file_header
- force_unwrapping
- implicitly_unwrapped_optional
- large_tuple
- last_enum_element_closing_brace
- legacy_multiple
- legacy_random
- literal_expression_end_indentation
- modifier_order
- multiline_arguments
- multiline_arguments_brackets
- multiline_function_chains
- multiline_literal_brackets
- multiline_parameters
- multiline_parameters_brackets
- nslocalizedstring_key
- operator_usage_whitespace
- overridden_super_call
- prohibited_enum_element
- prohibited_interface_builder
- prohibited_super_call
- quick_look_alert
- redundant_nil_coalescing
- sorted_first_last
- toggle_all_bool
- trailing_closure
- unneeded_parentheses_in_closure_argument
- vertical_parameter_alignment_on_call
- vertical_whitespace_closing_braces
- vertical_whitespace_opening_braces
- yoda_condition
disabled_rules:
- todo
# Warning/Error thresholds
line_length:
warning: 120
error: 200
file_length:
warning: 500
error: 1000
type_body_length:
warning: 300
error: 500
function_body_length:
warning: 50
error: 100
closure_body_length:
warning: 20
error: 50
type_name:
min_length: 2
max_length:
warning: 40
error: 60
allowed_symbols: ["_"]
identifier_name:
min_length: 1
excluded:
- i
- id
- x
- y
- width
- height
reporter: "xcode"

1
iOS/Package.swift Normal file
View File

@@ -0,0 +1 @@
// Empty file for Swift package resolution

156
iOS/README.md Normal file
View File

@@ -0,0 +1,156 @@
# Lendair iOS App
Native iOS SwiftUI application for the Lendair peer-to-peer micro lending platform.
## Setup Instructions
### Prerequisites
- macOS with Xcode 15.0+ installed
- Homebrew (for package management)
### Installation
1. **Install XcodeGen** (project generator):
```bash
brew install xcodegen
```
2. **Generate the Xcode project**:
```bash
cd /home/mike/code/lendair/iOS
./generate.sh
```
3. **Open the workspace in Xcode**:
```bash
open Lendair.xcworkspace
```
### Project Structure
```
iOS/
├── project.yml # XcodeGen configuration
├── generate.sh # Project generation script
├── README.md # This file
└── Lendair/
├── Lendair.xcodeproj # Generated Xcode project
├── Lendair/
│ ├── LendairApp.swift # App entry point
│ ├── ContentView.swift # Root view with auth routing
│ ├── Services/ # Business logic layer
│ │ ├── TRPCService.swift # tRPC client
│ │ ├── AuthService.swift # Authentication
│ │ ├── LoanService.swift # Loan operations
│ │ ├── TransactionService.swift
│ │ └── AppState.swift # Global state management
│ ├── Models/ # Data models
│ │ ├── User.swift
│ │ ├── Loan.swift
│ │ └── Transaction.swift
│ ├── Screens/ # Feature screens
│ │ ├── Auth/
│ │ │ ├── LoginView.swift
│ │ │ └── SignupView.swift
│ │ ├── Main/
│ │ │ └── MainTabView.swift
│ │ ├── Home/
│ │ │ └── HomeView.swift
│ │ ├── Loans/
│ │ │ └── LoansTabView.swift
│ │ ├── Activity/
│ │ │ └── ActivityTabView.swift
│ │ └── Profile/
│ │ └── ProfileTabView.swift
│ ├── Components/UI/ # Reusable components
│ │ ├── PrimaryButton.swift
│ │ ├── LendairTextField.swift
│ │ ├── BalanceCard.swift
│ │ ├── LoanCard.swift
│ │ ├── TransactionRow.swift
│ │ ├── StatusBadge.swift
│ │ ├── LoadingView.swift
│ │ ├── ErrorView.swift
│ │ └── EmptyStateView.swift
│ └── Assets.xcassets/ # App icons, colors, images
├── LendairTests/ # Unit tests
└── LendairUITests/ # UI tests
```
### Architecture
- **Pattern**: MVVM with `@Observable` (Swift 5.9+)
- **Navigation**: NavigationStack with programmatic navigation
- **Networking**: tRPC over HTTPS via URLSession
- **State Management**: Singleton `AppState` with `@Observable`
### Dependencies
Managed via Swift Package Manager:
- **swift-collections** (v1.x): OrderedDictionary and other collection types
- **swift-algorithms** (v1.x): Pagination helpers and algorithms
### Building
From Xcode or command line:
```bash
# Debug build
xcodebuild -workspace Lendair.xcworkspace -scheme Lendair -configuration Debug build
# Release build
xcodebuild -workspace Lendair.xcworkspace -scheme Lendair -configuration Release build
```
### Running Tests
```bash
# Run all tests
xcodebuild test -workspace Lendair.xcworkspace -scheme Lendair -configuration Debug
# With coverage
xcodebuild test -workspace Lendair.xcworkspace -scheme Lendair -configuration Debug \
-enableCodeCoverage YES
```
### Environment Configuration
The app supports multiple environments:
| Environment | Base URL |
|-------------|----------|
| Development | https://dev.lendair.local |
| Staging | https://staging.lendair.app |
| Production | https://api.lendair.app |
Configure via `TRPCEndpoint` enum in `TRPCService.swift`.
### API Endpoints
The iOS app communicates with the SolidStart backend via tRPC:
- **Auth**: `/auth/signin`, `/auth/signup`, `/auth/me`
- **Loans**: `/loans/available`, `/loans/my`, `/loans/create`, `/loans/accept`
- **Transactions**: `/transactions/recent`, `/transactions/list`
See [FRE-455](https://git.freno.me/Mike/Lendair/issues/FRE-455) for full API specification.
### Key Features
- ✅ Tab bar navigation (Home, Loans, Activity, Profile)
- ✅ Authentication screens (Login, Signup)
- ✅ Home dashboard with balance card
- ✅ Loan browsing and creation
- ✅ Transaction history
- ✅ Profile management
- ⏳ Create loan form (in progress)
- ⏳ Accept/repay loan flows (pending)
- ⏳ Unit tests at NASA standards (pending)
### References
- **Parent Task**: [FRE-457](https://git.freno.me/Mike/Lendair/issues/FRE-457)
- **Design Spec**: [FRE-452](https://git.freno.me/Mike/Lendair/issues/FRE-452)
- **Tech Plan**: [FRE-450](https://git.freno.me/Mike/Lendair/issues/FRE-450)

19
iOS/buildServer.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "xcode build server",
"version": "1.3.0",
"bspVersion": "2.2.0",
"languages": [
"c",
"cpp",
"objective-c",
"objective-cpp",
"swift"
],
"argv": [
"/opt/homebrew/bin/xcode-build-server"
],
"workspace": "/Users/mike/Code/Kordant/iOS/Kordant.xcodeproj/project.xcworkspace",
"build_root": "/Users/mike/Library/Developer/Xcode/DerivedData/Kordant-gkpnetnuxdeqhzegbngesmnbzwud",
"scheme": "Kordant",
"kind": "xcode"
}

106
iOS/project.yml Normal file
View File

@@ -0,0 +1,106 @@
# XcodeGen Configuration for Kordant iOS App
name: Kordant
options:
xcodeIndentationWidth: 4
tabWidth: 4
usesTabs: false
bundleIdPrefix: com.frenocorp
deploymentTarget:
iOS: "17.0"
settings:
base:
MARKETING_VERSION: 1.0.0
CURRENT_PROJECT_VERSION: 1
SWIFT_VERSION: "5.9"
ENABLE_PREVIEWS: YES
AUTOMATIC_SIGNING: NO
TARGETED_DEVICE_FAMILY: "1,2"
packages:
Collections:
url: https://github.com/apple/swift-collections
from: "1.0.0"
Algorithms:
url: https://github.com/apple/swift-algorithms
from: "1.0.0"
targets:
Kordant:
type: application
platform: iOS
deploymentTarget: "17.0"
sources:
- path: Kordant
excludes:
- "**/*.xcodeproj"
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.frenocorp.kordant
PRODUCT_NAME: Kordant
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES
INFOPLIST_FILE: Kordant/Info.plist
dependencies:
- package: Collections
product: Collections
- package: Algorithms
product: Algorithms
preBuildScripts:
- name: SwiftLint
script: |
if which swiftlint >/dev/null 2>&1; then
swiftlint lint --quiet || true
else
echo "warning: SwiftLint not installed, run 'brew install swiftlint' to enable linting"
fi
showEnvVarsInLog: false
basedOnDependencyAnalysis: false
KordantTests:
type: bundle.unit-test
platform: iOS
deploymentTarget: "17.0"
sources:
- path: KordantTests
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.frenocorp.KordantTests
GENERATE_INFOPLIST_FILE: YES
dependencies:
- target: Kordant
KordantUITests:
type: bundle.ui-testing
platform: iOS
deploymentTarget: "17.0"
sources:
- path: KordantUITests
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.frenocorp.KordantUITests
GENERATE_INFOPLIST_FILE: YES
dependencies:
- target: Kordant
schemes:
Kordant:
build:
targets:
Kordant: all
KordantTests: [test]
KordantUITests: [test]
run:
config: Debug
test:
config: Debug
targets:
- KordantTests
- KordantUITests
profile:
config: Release
analyze:
config: Debug
archive:
config: Release

357
iOS/run Executable file
View File

@@ -0,0 +1,357 @@
#!/bin/bash
# Build and run Kordant application
# Usage: ./run [build|test|run|lsp] [-v|--verbose] [-c|--coverage] [-p|--performance] [-o|--output <file>] [--no-lsp]
# Note: Default action (./run with no args) runs the app with verbose logging enabled
set -o pipefail
readonly PROJECT="Kordant.xcodeproj"
readonly SCHEME="Kordant"
readonly CONFIGURATION="Debug"
readonly APP_SUBSYSTEM="com.frenocorp.Kordant"
readonly BUNDLE_ID="com.frenocorp.lendair"
VERBOSE=false
OUTPUT_FILE=""
SKIP_LSP=false
PERFORMANCE=false
COVERAGE=false
build_xcodebuild_command() {
local action="$1"
local destination="${2:-generic/platform=iOS}"
local extra_flags="${3:-}"
local cmd="xcodebuild -project $PROJECT -scheme $SCHEME -configuration $CONFIGURATION -destination '$destination' $extra_flags $action"
if [ "$PERFORMANCE" = true ]; then
cmd="$cmd -enablePerformanceTestsDiagnostics YES"
fi
if [ "$COVERAGE" = true ]; then
cmd="$cmd -enableCodeCoverage YES"
fi
echo "$cmd"
}
kill_existing_lendair_processes() {
echo "Checking for existing Kordant processes..."
local pids
pids=$(pgrep -f "Kordant.app")
if [ -n "$pids" ]; then
echo "Killing existing Kordant processes (PID(s): $pids)..."
kill $pids 2>/dev/null
sleep 1
else
echo "No existing Kordant processes found"
fi
}
update_lsp_config() {
echo "Updating LSP configuration..."
if command -v xcode-build-server &> /dev/null; then
local build_root
build_root=$(ls -td "$HOME/Library/Developer/Xcode/DerivedData/${SCHEME}-"*/Build 2>/dev/null | head -1)
local exit_code
if [ -n "$build_root" ]; then
xcode-build-server config -project "$PROJECT" -scheme "$SCHEME" --build_root "$build_root" > /dev/null 2>&1
exit_code=$?
else
xcode-build-server config -project "$PROJECT" -scheme "$SCHEME" > /dev/null 2>&1
exit_code=$?
fi
if [ $exit_code -eq 0 ]; then
echo "LSP configuration updated (buildServer.json created)"
else
echo "Could not update LSP configuration"
fi
else
echo "xcode-build-server not found. Install with: brew install xcode-build-server"
echo " This helps Neovim's LSP recognize your Swift modules"
fi
}
get_build_directory() {
xcodebuild -project "$PROJECT" -scheme "$SCHEME" -configuration "$CONFIGURATION" \
-showBuildSettings 2>/dev/null | \
grep -m 1 "BUILT_PRODUCTS_DIR" | \
sed 's/.*= //'
}
handle_build_success() {
echo "Build succeeded!"
if [ "$SKIP_LSP" != true ]; then
update_lsp_config
fi
}
print_errors() {
local output="$1"
local action_type="$2"
echo ""
echo "${action_type} Errors:"
echo "================================================================================"
local errors
errors=$(echo "$output" | grep -E "error:|Error |failed|FAIL" | sed 's/^/ /')
if [ -n "$errors" ]; then
echo "$errors"
else
echo " No specific error messages found. See full output above."
fi
echo "================================================================================"
}
print_warnings() {
local output="$1"
echo ""
echo "Diagnostic Warnings:"
echo "================================================================================"
local warnings
warnings=$(echo "$output" | grep -E "\.swift:[0-9]+:[0-9]+: warning:" | sed 's/^/ /')
if [ -n "$warnings" ]; then
local count
count=$(echo "$warnings" | wc -l | tr -d ' ')
echo " Found $count warning(s):"
echo ""
echo "$warnings"
else
echo " No warnings found."
fi
echo "================================================================================"
}
get_booted_simulator() {
xcrun simctl list devices booted 2>/dev/null | grep -oE "[A-F0-9-]{36}" | head -1
}
ensure_simulator_booted() {
local simulator
simulator=$(get_booted_simulator)
if [ -z "$simulator" ]; then
echo "No booted simulator found, booting first available iPhone..." >&2
simulator=$(xcrun simctl list devices available 2>/dev/null | grep -i "iPhone" | grep -oE "[A-F0-9-]{36}" | head -1)
if [ -n "$simulator" ]; then
echo "Booting simulator $simulator..." >&2
xcrun simctl boot "$simulator"
sleep 5
open -a Simulator 2>/dev/null || true
fi
fi
echo "$simulator"
}
launch_app() {
local build_dir app_path simulator
build_dir=$(get_build_directory)
app_path="${build_dir}/Kordant.app"
simulator=$(ensure_simulator_booted)
if [ -z "$simulator" ]; then
echo "Error: No iOS simulator available. Boot one with: open -a Simulator"
exit 1
fi
if [ -d "$app_path" ]; then
echo "Installing on simulator $simulator..."
xcrun simctl install "$simulator" "$app_path"
echo "Launching app..."
xcrun simctl launch "$simulator" "$BUNDLE_ID"
sleep 2
echo "Streaming simulator logs (Ctrl+C to stop - app keeps running)..."
echo "================================================================"
xcrun simctl spawn "$simulator" log stream --level debug \
--predicate "subsystem contains \"$APP_SUBSYSTEM\"" \
--style compact 2>/dev/null
else
echo "App not found at expected location, trying fallback..."
local fallback
fallback=$(ls -dt "$HOME/Library/Developer/Xcode/DerivedData/Kordant-"*/Build/Products/Debug-iphonesimulator/Lendair.app 2>/dev/null | head -1)
if [ -d "$fallback" ]; then
echo "Found at: $fallback"
xcrun simctl install "$simulator" "$fallback"
xcrun simctl launch "$simulator" "$BUNDLE_ID"
sleep 2
xcrun simctl spawn "$simulator" log stream --level debug \
--predicate "subsystem contains \"$APP_SUBSYSTEM\"" \
--style compact 2>/dev/null
else
echo "No app bundle found"
exit 1
fi
fi
}
run_with_output() {
local cmd="$1"
local exit_code
if [ "$VERBOSE" = true ] && [ -n "$OUTPUT_FILE" ]; then
COMMAND_OUTPUT=$(eval "$cmd" 2>&1 | tee "$OUTPUT_FILE")
exit_code=${PIPESTATUS[0]}
elif [ "$VERBOSE" = true ]; then
if [ -t 1 ]; then
COMMAND_OUTPUT=$(eval "$cmd" 2>&1 | tee /dev/tty)
exit_code=${PIPESTATUS[0]}
else
COMMAND_OUTPUT=$(eval "$cmd" 2>&1)
exit_code=$?
echo "$COMMAND_OUTPUT"
fi
elif [ -n "$OUTPUT_FILE" ]; then
COMMAND_OUTPUT=$(eval "$cmd" 2>&1 | tee "$OUTPUT_FILE")
exit_code=${PIPESTATUS[0]}
else
COMMAND_OUTPUT=$(eval "$cmd" 2>&1)
exit_code=$?
fi
return $exit_code
}
show_usage() {
echo "Usage: $0 [build|test|run|lsp] [-v|--verbose] [-o|--output <file_name>] [--no-lsp] [-p|--performance] [-c|--coverage]"
echo ""
echo "Commands:"
echo " build - Build the application"
echo " test - Run unit tests"
echo " run - Build and run the application on simulator with logging (default)"
echo " launch - Launch last-built app on booted simulator"
echo " lsp - Update LSP configuration only (buildServer.json)"
echo ""
echo "Options:"
echo " -v, --verbose - Show output in stdout"
echo " -o, --output - Write output to log file"
echo " --no-lsp - Skip LSP configuration update"
echo " -p, --performance - Run tests with performance profiling"
echo " -c, --coverage - Run tests with code coverage analysis"
echo ""
echo "Note: Running './run' with no arguments defaults to 'run' action with verbose logging."
echo " Press Ctrl+C to stop log capture and keep the app running."
}
ACTION=""
while [[ $# -gt 0 ]]; do
case $1 in
-v|--verbose)
VERBOSE=true
shift
;;
-o|--output)
OUTPUT_FILE="$2"
VERBOSE=true
shift 2
;;
--no-lsp)
SKIP_LSP=true
shift
;;
-p|--performance)
PERFORMANCE=true
shift
;;
-c|--coverage)
COVERAGE=true
shift
;;
*)
if [ -z "$ACTION" ]; then
ACTION="$1"
fi
shift
;;
esac
done
if [ -z "$ACTION" ]; then
ACTION="run"
fi
echo "=== Kordant Application Script ==="
case "$ACTION" in
build)
echo "Building Kordant project..."
run_with_output "$(build_xcodebuild_command build)"
if [ $? -eq 0 ]; then
handle_build_success
echo "The app is located at: $(get_build_directory)/Kordant.app"
if [ "$VERBOSE" = true ]; then
print_warnings "$COMMAND_OUTPUT"
fi
else
echo "Build failed!"
print_errors "$COMMAND_OUTPUT" "Build"
exit 1
fi
;;
test)
echo "Running unit tests (parallel)..."
run_with_output "$(build_xcodebuild_command test "platform=iOS Simulator,name=iPhone 16" "-parallel-testing-enabled YES")"
if [ $? -eq 0 ]; then
echo "Tests passed!"
if [ "$VERBOSE" = true ]; then
print_warnings "$COMMAND_OUTPUT"
fi
else
echo "Tests failed!"
print_errors "$COMMAND_OUTPUT" "Test"
exit 1
fi
;;
run)
echo "Building and running Kordant application..."
kill_existing_lendair_processes
run_with_output "$(build_xcodebuild_command build "generic/platform=iOS Simulator")"
if [ $? -eq 0 ]; then
handle_build_success
if [ "$VERBOSE" = true ]; then
print_warnings "$COMMAND_OUTPUT"
fi
launch_app
else
echo "Build failed!"
print_errors "$COMMAND_OUTPUT" "Build"
exit 1
fi
;;
lsp)
echo "Updating LSP configuration only..."
update_lsp_config
;;
launch)
echo "Launching last-built app on simulator..."
launch_app
;;
*)
show_usage
exit 1
;;
esac

84
iOS/scripts/create_test_token Executable file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Generate a JWT token for testing Lendair API calls.
# Usage: ./scripts/create_test_token <user-id> [secret-env-var]
#
# Reads the JWT secret from the environment (default: CLERK_SECRET_KEY).
# Falls back to .env file in the project root.
#
# Example:
# CLERK_SECRET_KEY=sk_test_xxx ./scripts/create_test_token user_123
# ./scripts/create_test_token user_123 CLERK_SECRET_KEY
set -euo pipefail
if [ $# -lt 1 ]; then
echo "Usage: $(basename "$0") <user-id> [secret-env-var]" >&2
echo "" >&2
echo "Generates a JWT token with the given user-id as subject." >&2
echo "The secret is read from the environment variable (default: CLERK_SECRET_KEY)" >&2
echo "or from a .env file in the project root." >&2
exit 1
fi
USER_ID="$1"
SECRET_VAR="${2:-CLERK_SECRET_KEY}"
SECRET="${!SECRET_VAR:-}"
# Fallback: try loading from .env in project root
if [ -z "$SECRET" ]; then
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
ENV_FILE="$PROJECT_DIR/../.env"
if [ -f "$ENV_FILE" ]; then
set -a
source "$ENV_FILE" 2>/dev/null || true
set +a
SECRET="${!SECRET_VAR:-}"
fi
fi
if [ -z "$SECRET" ]; then
echo "Error: $SECRET_VAR is not set and no .env file found" >&2
echo "" >&2
echo "Set it inline:" >&2
echo " $SECRET_VAR=sk_test_xxx $(basename "$0") $USER_ID" >&2
echo "Or add to .env in the repo root:" >&2
echo " $SECRET_VAR=sk_test_xxx" >&2
exit 1
fi
generate_jwt_via_node() {
node --input-type=module - "$1" "$2" <<'JWTSCRIPT' 2>/dev/null
import { createHmac } from 'node:crypto';
const userId = process.argv[1];
const secret = process.argv[2];
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const now = Math.floor(Date.now() / 1000);
const payload = Buffer.from(JSON.stringify({
sub: userId,
iat: now,
exp: now + 2592000
})).toString('base64url');
const sig = createHmac('sha256', secret).update(header + '.' + payload).digest('base64url');
console.log(header + '.' + payload + '.' + sig);
JWTSCRIPT
}
generate_jwt_via_openssl() {
local now header payload sig
now=$(date +%s)
header=$(echo -n '{"alg":"HS256","typ":"JWT"}' | base64 | tr '+/' '-_' | tr -d '=')
payload=$(echo -n "{\"sub\":\"$USER_ID\",\"iat\":$now,\"exp\":$((now + 2592000))}" | base64 | tr '+/' '-_' | tr -d '=')
sig=$(echo -n "$header.$payload" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64 | tr '+/' '-_' | tr -d '=')
echo "$header.$payload.$sig"
}
if command -v node &>/dev/null; then
generate_jwt_via_node "$USER_ID" "$SECRET"
elif command -v openssl &>/dev/null; then
generate_jwt_via_openssl
else
echo "Error: need either node or openssl to generate JWT" >&2
exit 1
fi

41
iOS/scripts/get_coverage Executable file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
# Generate code coverage report for Lendair iOS project.
# Finds the most recent xcresult file and produces a JSON report.
#
# Usage: ./scripts/get_coverage
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
REPORTS_DIR="$PROJECT_DIR/reports"
XCRESULT=$(find ~/Library/Developer/Xcode/DerivedData -name "*Lendair*" -path "*/Test/*.xcresult" -type d 2>/dev/null | sort -r | head -1)
if [ -z "$XCRESULT" ]; then
echo "Error: No xcresult file found for Lendair project" >&2
echo "" >&2
echo "Make sure you've run tests with coverage enabled:" >&2
echo " ./run test -c" >&2
exit 1
fi
echo "Using xcresult: $XCRESULT"
TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")
mkdir -p "$REPORTS_DIR/$TIMESTAMP"
xcrun xccov view --report "$XCRESULT" --json > "$REPORTS_DIR/$TIMESTAMP/code_coverage.json"
echo ""
echo "Code coverage report generated:"
echo " $REPORTS_DIR/$TIMESTAMP/code_coverage.json"
# Also symlink latest
ln -sf "$TIMESTAMP" "$REPORTS_DIR/latest" 2>/dev/null || true
cp "$REPORTS_DIR/$TIMESTAMP/code_coverage.json" "$REPORTS_DIR/code_coverage.json" 2>/dev/null || true
# Print a quick summary
echo ""
echo "=== Coverage Summary ==="
xcrun xccov view --report "$XCRESULT" 2>/dev/null | head -30 || true

59
iOS/scripts/typecheck Executable file
View File

@@ -0,0 +1,59 @@
#!/bin/bash
# typecheck - Run a fast Swift typecheck on the Lendair iOS project via remote Mac build host.
#
# Usage (from any machine with SSH access to the build host):
# ./scripts/typecheck
#
# What it does:
# 1. SSHes to the build host (configurable via REMOTE_HOST env var)
# 2. Pulls latest code on the Mac
# 3. Runs xcodebuild build with output filtered to errors/warnings only
# 4. Exits 0 on clean typecheck, 1 on errors
#
# Configuration:
# REMOTE_HOST - SSH hostname (default: hermes)
# REMOTE_REPO - Path to repo on the Mac (default: ~/code/lendair)
# PROJECT_PATH - Project path relative to repo root (default: iOS/Lendair/Lendair.xcodeproj)
set -euo pipefail
REMOTE_HOST="${REMOTE_HOST:-hermes}"
REMOTE_REPO="${REMOTE_REPO:-$HOME/code/lendair}"
PROJECT_PATH="${PROJECT_PATH:-iOS/Lendair/Lendair.xcodeproj}"
SCHEME="Lendair"
echo "=== Typecheck: connecting to $REMOTE_HOST ==="
ssh "$REMOTE_HOST" bash <<REMOTE
set -euo pipefail
cd "$REMOTE_REPO"
echo "--- Pulling latest ---"
git stash --include-untracked -q 2>/dev/null || true
git pull --rebase origin master 2>&1 | tail -3 || echo "Already up to date or pull failed"
git stash pop -q 2>/dev/null || true
echo "--- Running typecheck ---"
set +e +o pipefail
BUILD_LOG=\$(mktemp)
xcodebuild \
-project "$PROJECT_PATH" \
-scheme "$SCHEME" \
-configuration Debug \
-destination "generic/platform=iOS Simulator" \
-jobs 4 \
CODE_SIGNING_ALLOWED=NO \
build > "\$BUILD_LOG" 2>&1
BUILD_EXIT=\$?
set -e -o pipefail
grep -E "\.swift:[0-9]+:[0-9]+: (error|warning):|^\*\* BUILD (SUCCEEDED|FAILED)" "\$BUILD_LOG" \
| sed "s|$REMOTE_REPO/||g" \
|| true
rm -f "\$BUILD_LOG"
if [ "\$BUILD_EXIT" = "0" ]; then
echo "=== PASSED ==="
else
echo "=== FAILED ==="
fi
exit \$BUILD_EXIT
REMOTE

View File

@@ -97,14 +97,14 @@ steps:
- E2E: Perform CRUD operations (add watchlist item, delete enrollment, create rule)
acceptance_criteria:
- [ ] Dashboard displays threat score, alerts, service summaries, and quick actions
- [ ] All 5 service screens (DarkWatch, VoicePrint, SpamShield, HomeTitle, RemoveBrokers) load and display data
- [ ] Each service screen supports core CRUD operations
- [ ] Alert detail shows full information and correlation group
- [ ] Settings screen allows managing account, preferences, and family
- [ ] Pull-to-refresh updates dashboard data
- [ ] All screens show loading skeletons and empty states appropriately
- [ ] Navigation between screens works with native Android transitions
- [x] Dashboard displays threat score, alerts, service summaries, and quick actions
- [x] All 5 service screens (DarkWatch, VoicePrint, SpamShield, HomeTitle, RemoveBrokers) load and display data
- [x] Each service screen supports core CRUD operations
- [x] Alert detail shows full information and correlation group
- [x] Settings screen allows managing account, preferences, and family
- [x] Pull-to-refresh updates dashboard data
- [x] All screens show loading skeletons and empty states appropriately
- [x] Navigation between screens works with native Android transitions
validation:
- Launch app, login, and verify dashboard renders with real data

View File

@@ -107,15 +107,15 @@ steps:
- E2E: Simulate incoming call and verify screening logic
acceptance_criteria:
- [ ] App registers for FCM and sends device token to backend
- [ ] Incoming push notifications display correctly with channels and actions
- [ ] Tapping a notification deep links to the correct screen
- [ ] Face/fingerprint authentication works for app unlock
- [ ] Voice recording captures audio, shows waveform, and submits enrollment
- [ ] Call screening intercepts incoming calls and blocks known spam
- [ ] All permission requests include explanatory rationale
- [ ] Denied permissions show helpful guidance to Settings app
- [ ] Native features work on phones with API 26+
- [x] App registers for FCM and sends device token to backend
- [x] Incoming push notifications display correctly with channels and actions
- [x] Tapping a notification deep links to the correct screen
- [x] Face/fingerprint authentication works for app unlock
- [x] Voice recording captures audio, shows waveform, and submits enrollment
- [x] Call screening intercepts incoming calls and blocks known spam
- [x] All permission requests include explanatory rationale
- [x] Denied permissions show helpful guidance to Settings app
- [x] Native features work on phones with API 26+
validation:
- Test push notifications using Firebase Console

View File

@@ -42,8 +42,8 @@ Tasks
- [x] 35 — Android App — Design System Components Matching Web Theme → `35-android-design-system.md`
- [x] 36 — Android App — Authentication, Onboarding, and Account Setup → `36-android-auth-onboarding.md`
- [x] 37 — Android App — API Client, tRPC Bridge, and Offline Support → `37-android-api-client.md`
- [ ] 38 — Android App — Dashboard and Service Screens → `38-android-service-screens.md`
- [ ] 39 — Android App — Push Notifications, Biometrics, Voice Enrollment, Call Screening → `39-android-native-features.md`
- [x] 38 — Android App — Dashboard and Service Screens → `38-android-service-screens.md`
- [x] 39 — Android App — Push Notifications, Biometrics, Voice Enrollment, Call Screening → `39-android-native-features.md`
- [ ] 40 — Shared Mobile Assets — Icons, Colors, Typography, and Brand Guidelines → `40-shared-mobile-assets.md`
- [ ] 41 — Cleanup — Remove Legacy packages/, services/, and server/ Directories → `41-cleanup-legacy.md`
- [ ] 42 — Deployment — Update Docker, CI/CD, and Environment Configuration → `42-deployment-config.md`

View File

@@ -0,0 +1,69 @@
# 01. Inline Index Page Sections
meta:
id: landing-pages-and-admin-01
feature: landing-pages-and-admin
priority: P2
depends_on: []
tags: [implementation, ui, landing-page]
objective:
- Refactor the landing page (`/web/src/routes/index.tsx`) to use an inline data-driven pattern like Lendair's index.tsx, where all section content is defined as typed data arrays at the top of the file and rendered directly in the component body, instead of being extracted into separate section components.
deliverables:
- Rewritten `/web/src/routes/index.tsx` with inline data arrays and layout
- Typed data structures for: hero, how-it-works steps, feature cards, for-users panels, why-kordant items, CTA
- All inline SVG icon helpers (like Lendair's `IconPath` and `CheckIcon`)
- PageContainer wrapper used consistently
- Removal or deprecation of extracted section components (`HeroSection`, `HowItWorksSection`, `FeaturesGridSection`, `ForUsersSection`, `WhyKordantSection`, `CTABannerSection`)
steps:
- Read Lendair's `/Users/mike/code/Lendair/web/src/routes/index.tsx` to understand the inline data pattern
- Read current Kordant landing section components to extract all content, structure, and styling
- Define typed data arrays in `index.tsx`:
- `steps` array for How It Works (step number, title, description)
- `platformFeatures` array for feature cards (icon, title, description)
- `forUsers` array for audience panels (icon, title, description, bullet points)
- `whyKordant` array for value propositions (title, description, bullet points)
- Hero data object (headline, subheadline, CTA buttons)
- Create inline SVG helper components (`IconPath`, `CheckIcon`, `ArrowIcon`) matching Lendair's pattern
- Rebuild the page layout inline using `PageContainer`, clip-path sections, and Tailwind classes
- Map existing feature icons to inline SVG path strings (DarkWatch, VoicePrint, SpamShield, HomeTitle, RemoveBrokers, Family Plans)
- Ensure `ColorWaveBackground` component is still imported and used at the top
- Update `@solidjs/meta` Title tag
- Test both desktop and mobile responsive layouts
- Verify dark/light theme compatibility
tests:
- Unit: Verify data arrays have correct shape and all required fields
- Integration: Page renders without errors at `/` route
- Visual: Compare rendered page against current landing page to ensure all sections present
- Responsive: Test at 320px, 768px, 1024px, 1440px breakpoints
acceptance_criteria:
- `index.tsx` contains all content as inline data arrays (no extracted section components)
- Page renders identical visual output to current landing page
- All 6 feature cards render with correct icons, titles, and descriptions
- How It Works section shows all 3 steps with alternating layout
- For Users section shows split panels (individual vs family)
- Why Kordant section shows all value proposition cards with bullet points
- CTA banner renders with correct buttons linking to `/signup` and `/login`
- `ColorWaveBackground` renders at page top
- No console errors or warnings
validation:
- `cd /Users/mike/Code/Kordant/web && pnpm dev` then navigate to `http://localhost:3000/`
- Visually compare each section against current landing page
- Toggle dark/light theme and verify all sections render correctly
- Resize browser to test responsive breakpoints
notes:
- Reference Lendair pattern: data arrays defined at module scope, rendered with `<For>` loops
- Existing section components live in `/web/src/components/landing/`
- `FeaturesGridSection.tsx` has 6 feature cards with specific icons and descriptions
- `HowItWorksSection.tsx` has 3 steps with alternating left/right layout
- `ForUsersSection.tsx` has individual vs family split panels
- `WhyKordantSection.tsx` has 3 value proposition cards
- `CTABannerSection.tsx` has CTA with two buttons
- Keep `ColorWaveBackground` as-is (it's a complex Three.js component)
- Use `PageContainer` component for consistent width and padding

View File

@@ -0,0 +1,95 @@
# 02. Admin Routes With Controls And Services Dashboard
meta:
id: landing-pages-and-admin-02
feature: landing-pages-and-admin
priority: P1
depends_on: []
tags: [implementation, routes, admin, dashboard, auth]
objective:
- Create admin-only routes at `/admin` with authentication guards, a main dashboard showing service health and metrics, and a blog management interface for creating/editing blog posts and marking featured posts.
deliverables:
- `/web/src/routes/(admin)/index.tsx` — Admin dashboard overview
- `/web/src/routes/(admin)/blog.tsx` — Blog post list and management
- `/web/src/routes/(admin)/blog/new.tsx` — Create new blog post
- `/web/src/routes/(admin)/blog/[slug].tsx` — Edit existing blog post
- `/web/src/routes/(admin)/services.tsx` — Services health/status dashboard
- `/web/src/routes/(admin)/users.tsx` — User management overview
- Admin layout wrapper with sidebar navigation
- tRPC router endpoints for admin CRUD operations
- Role-based access guard middleware
steps:
- Create `(admin)` route group directory at `/web/src/routes/(admin)/`
- Build admin layout component with sidebar navigation (Dashboard, Blog, Services, Users)
- Implement role-based access guard:
- Check user has `role` of `family_admin` or `support` in database
- Redirect unauthorized users to `/dashboard` with error toast
- Use existing Clerk auth + database role check
- Create admin dashboard page (`/admin`):
- Summary cards: total users, active subscriptions, blog posts count, alerts this week
- Services status overview (DarkWatch, VoicePrint, SpamShield, HomeTitle, RemoveBrokers)
- Recent activity feed (new signups, recent alerts, recent blog views)
- Create blog management pages:
- List view: table of all blog posts with slug, title, author, date, status, featured toggle
- New post form: title, slug (auto-generated), excerpt, content (markdown editor), author, cover image URL, tags, published toggle, featured toggle
- Edit post form: same fields pre-populated from database
- Featured post toggle: only one post can be featured at a time
- Create services dashboard page:
- Health status indicators for each service
- Metrics per service (usage counts, error rates, response times)
- Ability to toggle service maintenance mode
- Create users management page:
- Searchable/filterable user table
- User details: name, email, role, subscription tier, join date
- Role management (change user role)
- Wire up tRPC endpoints:
- `admin.blog.list` — fetch all blog posts with pagination
- `admin.blog.create` — create new blog post
- `admin.blog.update` — update existing blog post
- `admin.blog.delete` — soft delete blog post
- `admin.blog.toggleFeatured` — set/unset featured post
- `admin.dashboard.stats` — fetch dashboard statistics
- `admin.services.status` — fetch service health data
- `admin.users.list` — fetch user list with filters
- `admin.users.updateRole` — update user role
- Add admin guard middleware/procedure decorator to tRPC router
tests:
- Unit: Admin guard correctly blocks non-admin users
- Unit: Blog form validation (required fields, unique slug, single featured post)
- Integration: Admin routes accessible only by users with admin/support roles
- Integration: Blog CRUD operations persist correctly to database
- Integration: Featured post toggle enforces single-featured constraint
acceptance_criteria:
- Navigating to `/admin` redirects to `/login` if not authenticated
- Navigating to `/admin` redirects to `/dashboard` if authenticated but not admin
- Admin dashboard shows summary cards with real data from database
- Blog list page shows all posts from database (not hardcoded)
- Creating a blog post via admin form saves to `blogPosts` table
- Editing a blog post updates the database record
- Featured post toggle marks one post as featured and unmarks any previously featured post
- Services dashboard shows health status for each product service
- Users page shows searchable list with role management
- Admin sidebar navigation works correctly across all admin pages
validation:
- `cd /Users/mike/Code/Kordant/web && pnpm dev` then navigate to `/admin`
- Test with non-admin user: should redirect to `/dashboard`
- Test with admin user: should show dashboard
- Create a test blog post and verify it appears in the database
- Toggle featured post and verify only one is featured at a time
- Check tRPC endpoints return correct data
notes:
- Database schema already has `blogPosts` table in `marketing.ts` with fields: id, slug, title, excerpt, content, authorName, coverImageUrl, tags, published, publishedAt, viewCount
- Need to add `featured` boolean column to `blogPosts` table
- User roles defined in schema: `["user", "family_admin", "family_member", "support"]`
- Admin access should be granted to `family_admin` and `support` roles
- Use existing Clerk auth integration for session management
- Consider using a rich text editor or markdown editor for blog content (e.g., TipTap, Slate, or simple textarea)
- Admin route group `(admin)` follows SolidStart convention for route grouping
- Reference existing dashboard layout pattern in `/web/src/components/dashboard/Sidebar.tsx` for sidebar styling

View File

@@ -0,0 +1,89 @@
# 03. Blog Route With DB Integration, Featured Post, And Chronological Feed
meta:
id: landing-pages-and-admin-03
feature: landing-pages-and-admin
priority: P1
depends_on: [landing-pages-and-admin-02]
tags: [implementation, routes, blog, database, tRPC]
objective:
- Refactor the `/blog` route to fetch posts from the database via tRPC instead of hardcoded data, support a featured post display at the top, and show remaining posts in chronological order with tag filtering and pagination.
deliverables:
- Rewritten `/web/src/routes/blog.tsx` with database-backed data
- tRPC query `blog.list` for fetching posts with filtering, pagination, and featured flag
- Featured post hero section at top of blog listing
- Chronological feed with tag filtering and pagination
- Updated `/web/src/routes/blog/[slug].tsx` to fetch from database
- tRPC query `blog.bySlug` for fetching individual post
- tRPC mutation `blog.incrementViews` for tracking view counts
steps:
- Create tRPC router procedure `blog.list`:
- Accept optional `tag`, `limit`, `offset` parameters
- Query `blogPosts` table for published posts only
- Order by `publishedAt` descending (chronological)
- Return posts with all fields including `featured` flag
- Create tRPC router procedure `blog.bySlug`:
- Accept `slug` parameter
- Query `blogPosts` table for published post by slug
- Increment `viewCount` on each view
- Return post with related posts (same tags, excluding current)
- Create tRPC router procedure `blog.tags`:
- Return all unique tags from published posts with counts
- Refactor `/blog.tsx`:
- Replace hardcoded `blogPosts` array with tRPC query
- Add featured post section at top (large card with full excerpt, shown only if a post is marked featured)
- Show remaining posts in chronological grid below featured post
- Preserve tag filtering UI (fetch tags from database)
- Preserve pagination ("Load More Posts" button)
- Add loading states and error handling
- Refactor `/blog/[slug].tsx`:
- Replace hardcoded data with tRPC query by slug
- Preserve markdown rendering, author sidebar, related posts, social sharing
- Add 404 handling for non-existent slugs
- Track view count on page load
- Add database migration for `featured` column on `blogPosts` table
- Ensure `publishedAt` is properly set on all existing posts
tests:
- Unit: tRPC queries return correct data shapes
- Integration: Blog list page loads posts from database
- Integration: Featured post displays at top when one is marked featured
- Integration: Tag filtering correctly filters posts
- Integration: Pagination loads next batch of posts
- Integration: Individual post page loads by slug and increments view count
- Integration: 404 shown for non-existent slug
acceptance_criteria:
- `/blog` fetches posts from `blogPosts` database table (no hardcoded data)
- One post can be marked as `featured` and displays prominently at the top of the blog listing
- Remaining posts display in chronological order (newest first)
- Tag filtering works with tags sourced from the database
- Pagination ("Load More") loads additional posts in batches
- `/blog/[slug]` fetches individual post from database
- View count increments each time a post is viewed
- Related posts section shows posts with matching tags
- 404 page shown for non-existent blog slugs
- Loading states displayed while data is being fetched
validation:
- `cd /Users/mike/Code/Kordant/web && pnpm dev` then navigate to `/blog`
- Verify posts load from database (not hardcoded)
- Mark a post as featured via admin and verify it appears at top
- Test tag filtering by clicking different tag buttons
- Click "Load More" and verify additional posts load
- Navigate to individual post and verify content renders correctly
- Refresh post page and verify view count increments
- Navigate to non-existent slug and verify 404
notes:
- Current blog schema in `marketing.ts` has: id, slug, title, excerpt, content, authorName, coverImageUrl, tags (JSON), published, publishedAt, viewCount
- Need to add `featured` boolean column (default: false)
- `publishedAt` should be used for chronological ordering
- `tags` is stored as JSON array in database
- Existing markdown parser in `[slug].tsx` should be preserved
- Related posts logic: find other published posts sharing at least one tag
- Use `createQuery` from `@trpc/client` (SolidJS adapter) for data fetching
- Consider server-side rendering for initial blog list (SolidStart supports this)

View File

@@ -0,0 +1,74 @@
# 04. Create Blog Post Content
meta:
id: landing-pages-and-admin-04
feature: landing-pages-and-admin
priority: P2
depends_on: [landing-pages-and-admin-02, landing-pages-and-admin-03]
tags: [content, blog, database-seed]
objective:
- Create at least 4 substantive, well-written blog posts with practical advice on scam prevention, AI detection, identity theft recovery, and related topics. Seed these posts into the database via the admin interface or a seed script.
deliverables:
- At least 4 full blog post entries in the `blogPosts` database table
- Each post includes: title, slug, excerpt, full markdown content, author name, tags, published status, published date
- Content covers: scam prevention advice, AI detection tips, identity theft recovery steps, dark web safety, data broker removal
- At least one post marked as `featured`
steps:
- Research and write blog post content for each topic:
1. "How to Spot AI-Generated Scam Calls and Messages" — practical detection tips, red flags, what to do if targeted
2. "Identity Theft Recovery: Step-by-Step Guide" — actionable steps after discovering identity theft, agencies to contact, timeline
3. "Dark Web Exposure: What It Means and How to Respond" — explains data breaches, what info is exposed, protective measures
4. "Data Brokers Exposed: How to Remove Your Info From 20+ Sites" — comprehensive guide to opting out, tools, automation
5. "Deepfake Voice Scams: Protecting Your Family" — how voice cloning works, verification strategies, family safety protocols
- Format each post in markdown with:
- H1 title
- Intro paragraph (used as excerpt)
- Multiple H2 sections with detailed content
- Bullet points, numbered lists for actionable steps
- Internal links to Kordant product features where relevant
- Tags array (matching existing tag categories)
- Create database seed script or use admin interface to insert posts:
- Generate unique slugs from titles
- Set `published` to true
- Set `publishedAt` to appropriate dates (spread across recent months)
- Set `authorName` to realistic author names
- Mark one post as `featured`
- Verify all posts render correctly on `/blog` and `/blog/[slug]`
- Review and proofread all content for quality and accuracy
tests:
- Integration: All posts appear in blog listing page
- Integration: Each post renders correctly on individual page
- Integration: Featured post displays at top of blog listing
- Integration: Tags are properly associated and filterable
- Content: Each post is at least 800 words of substantive content
acceptance_criteria:
- At least 4 blog posts exist in the `blogPosts` database table
- Each post has: title, slug, excerpt, full markdown content (800+ words), author name, tags, published date
- Posts cover diverse topics: scam prevention, AI detection, identity theft recovery, dark web safety, data broker removal
- At least one post is marked as `featured`
- All posts render correctly on both listing and detail pages
- Content is well-written, accurate, and provides actionable advice
- Tags are properly categorized and filterable on the blog listing page
validation:
- `cd /Users/mike/Code/Kordant/web && pnpm dev` then navigate to `/blog`
- Verify all posts appear in chronological order
- Click each post and verify full content renders with proper markdown formatting
- Verify featured post appears at top of listing
- Test tag filtering with each tag category
- Verify view counts increment on page views
notes:
- Content should be educational and helpful, not overly promotional
- Reference real-world examples and statistics where possible
- Include actionable steps users can take immediately
- Link to relevant Kordant features naturally within content (not forced)
- Use existing tag categories: "Identity Theft", "AI Safety", "Privacy", "Deepfakes", "Dark Web", "Scam Alerts", "Product News"
- Author names should be consistent (use "Kordant Security Team" or specific editor names)
- Published dates should be spread across recent months for realistic timeline
- Consider creating a seed script at `/web/src/server/db/seed/blog.ts` for reproducibility

View File

@@ -0,0 +1,86 @@
# 05. Dedicated /pricing And /features Pages
meta:
id: landing-pages-and-admin-05
feature: landing-pages-and-admin
priority: P1
depends_on: [landing-pages-and-admin-06]
tags: [implementation, routes, marketing-pages]
objective:
- Create dedicated `/pricing` and `/features` route pages with full content. Currently the navbar links to these routes but they don't exist. The pricing content exists on `/ads` and features are shown as a section on the landing page. These need to be standalone, polished marketing pages.
deliverables:
- `/web/src/routes/pricing.tsx` — Full pricing page with tier comparison, FAQ, and CTAs
- `/web/src/routes/features.tsx` — Full features page with detailed product showcases
- Pricing data structured as typed arrays (consistent with Lendair inline pattern)
- Feature detail sections for each product (DarkWatch, VoicePrint, SpamShield, HomeTitle, RemoveBrokers, Family Plans)
steps:
- Create `/pricing.tsx`:
- Hero section: headline, subheadline, value proposition
- Pricing tiers card grid (3 tiers):
- Basic ($9/month): Dark web monitoring, email breach alerts, basic scam call blocking, monthly reports
- Plus ($19/month, "Most Popular"): Everything in Basic + VoicePrint, HomeTitle, RemoveBrokers, Family sharing up to 5
- Premium ($39/month): Everything in Plus + Unlimited family, 24/7 priority support, real-time alert correlation, advanced analytics, data broker suppression
- Feature comparison table (checkmarks per tier)
- FAQ section (accordion-style): billing, cancellation, family sharing, trial period
- CTA section with "Get Started" button linking to `/signup`
- Inline data arrays for tiers, features, and FAQ items
- Create `/features.tsx`:
- Hero section: headline, subheadline
- Feature detail sections for each product:
- DarkWatch: dark web monitoring, breach detection, exposure alerts
- VoicePrint: voice clone detection, deepfake voice identification
- SpamShield: scam call blocking, spam filtering, call analytics
- HomeTitle: property fraud alerts, title monitoring, ownership changes
- RemoveBrokers: data broker removal, opt-out automation, privacy reclamation
- Family Plans: family sharing, member management, unified dashboard
- Each feature section includes: icon, title, description, key benefits (bullet list), screenshot/mockup placeholder
- Alternating left-right layout for visual interest (like Lendair's split panels)
- Inline data arrays for all feature content
- Ensure both pages use `PageContainer` for consistent layout
- Add proper `<Title>` meta tags for SEO
- Ensure responsive design (mobile, tablet, desktop)
- Link pricing page CTA buttons to `/signup`
- Link features page cards to relevant dashboard routes (e.g., `/darkwatch`, `/voiceprint`)
tests:
- Integration: `/pricing` route loads without errors
- Integration: `/features` route loads without errors
- Integration: All pricing tiers display correctly with correct prices and features
- Integration: All 6 feature sections render with correct content
- Responsive: Pages render correctly at 320px, 768px, 1024px, 1440px
- Navigation: Navbar links to `/pricing` and `/features` work correctly
acceptance_criteria:
- `/pricing` page renders with 3 pricing tiers (Basic $9, Plus $19, Premium $39)
- Plus tier is visually highlighted as "Most Popular"
- Feature comparison table shows checkmarks per tier
- FAQ section has at least 5 questions with accordion toggle
- CTA buttons link to `/signup`
- `/features` page renders with all 6 product sections
- Each feature section has icon, title, description, and bullet-point benefits
- Pages use inline data arrays (not extracted components)
- Responsive layout works across all breakpoints
- Proper meta titles set for SEO
- No console errors or warnings
validation:
- `cd /Users/mike/Code/Kordant/web && pnpm dev` then navigate to `/pricing` and `/features`
- Verify all pricing tiers display correctly with accurate pricing
- Verify feature comparison table renders correctly
- Verify FAQ accordion works (toggle open/close)
- Verify all 6 feature sections render on `/features`
- Click CTA buttons and verify they navigate to correct routes
- Toggle dark/light theme and verify both pages render correctly
- Resize browser to test responsive breakpoints
notes:
- Pricing data currently lives in `/web/src/routes/ads.tsx` — extract and reuse
- Feature data currently lives in `/web/src/components/landing/FeaturesGridSection.tsx` — extract and expand
- Onboarding page (`/onboarding.tsx`) has slightly different pricing ($0 free, $9.99/mo plus, $19.99/mo premium) — use the `/ads` pricing as canonical for the marketing page
- Follow Lendair's inline data pattern for content organization
- Use existing UI components: `Button`, `Card`, `Badge` from `~/components/ui`
- Consider adding smooth scroll animations for feature sections
- Dashboard sidebar links to individual product pages — features page should link to those same routes

View File

@@ -0,0 +1,76 @@
# 06. Auth-Contextual Navbar With Dynamic Links
meta:
id: landing-pages-and-admin-06
feature: landing-pages-and-admin
priority: P1
depends_on: [landing-pages-and-admin-01]
tags: [implementation, ui, navbar, auth]
objective:
- Make the navbar show contextually appropriate navigation links based on authentication state: logged-out users see marketing links (Features, Pricing, Blog), while logged-in users see product-specific links (DarkWatch, VoicePrint, SpamShield, HomeTitle, RemoveBrokers) alongside Dashboard.
deliverables:
- Updated `/web/src/components/layout/Navbar.tsx` with auth-contextual link rendering
- Desktop and mobile nav menus both respect auth state
- Logged-out state: Features, Pricing, Blog links
- Logged-in state: Dashboard + product-specific links (DarkWatch, VoicePrint, SpamShield, HomeTitle, RemoveBrokers, Settings)
- Smooth transitions between auth states
steps:
- Analyze current `navLinks` array in `Navbar.tsx`:
- Current: `["Features" -> /features, "Pricing" -> /pricing, "Blog" -> /blog, "Dashboard" -> /dashboard]`
- Define two sets of nav links:
- `marketingLinks`: Features → `/features`, Pricing → `/pricing`, Blog → `/blog`
- `productLinks`: Dashboard → `/dashboard`, DarkWatch → `/darkwatch`, VoicePrint → `/voiceprint`, SpamShield → `/spamshield`, HomeTitle → `/hometitle`, RemoveBrokers → `/removebrokers`
- Update desktop nav section:
- Wrap `marketingLinks` in `<SignedOut>` component
- Wrap `productLinks` in `<SignedIn>` component
- Keep existing `SignedIn`/`SignedOut` button section (UserButton, Sign In, Get Started)
- Remove redundant "Dashboard" button when logged in (Dashboard is now a nav link)
- Update mobile nav section:
- Same auth-contextual link rendering for hamburger menu
- Preserve existing mobile button section
- Handle edge cases:
- Auth state changes (login/logout) should update nav links without page reload
- Active route highlighting should work for both link sets
- Subscription tier could affect which product links are visible (future consideration)
- Ensure styling consistency:
- Same link styles for both marketing and product links
- Active state indicator for current route
- Hover effects preserved
tests:
- Unit: Navbar renders correct links for signed-out state
- Unit: Navbar renders correct links for signed-in state
- Integration: Navbar updates links on auth state change without reload
- Integration: Desktop and mobile menus both show correct links
- Integration: All nav links navigate to correct routes
acceptance_criteria:
- Logged-out users see: Features, Pricing, Blog in desktop and mobile nav
- Logged-in users see: Dashboard, DarkWatch, VoicePrint, SpamShield, HomeTitle, RemoveBrokers in desktop and mobile nav
- Logged-in users still see: UserButton, RealtimeIndicator, theme toggle
- Logged-out users still see: Sign In, Get Started buttons, theme toggle
- Auth state changes (login/logout) update nav links without full page reload
- Active route highlighting works for all nav links
- Mobile hamburger menu shows correct links based on auth state
- No console errors or warnings
validation:
- `cd /Users/mike/Code/Kordant/web && pnpm dev` then navigate to `/`
- Test logged-out state: verify marketing links appear (Features, Pricing, Blog)
- Sign in and verify product links appear (Dashboard, DarkWatch, VoicePrint, etc.)
- Sign out and verify marketing links reappear
- Test mobile menu: toggle hamburger and verify correct links for current auth state
- Click each nav link and verify it navigates to the correct route
- Verify active route highlighting works
notes:
- Clerk's `SignedIn`/`SignedOut` components handle auth state reactively
- Current navbar already uses `SignedIn`/`SignedOut` for button section — extend same pattern to nav links
- Dashboard sidebar (`/web/src/components/dashboard/Sidebar.tsx`) has the product link structure to reference
- Product links should match dashboard sidebar: Overview, DarkWatch, VoicePrint, SpamShield, HomeTitle, RemoveBrokers, Settings
- Consider whether Settings should be in navbar or only in sidebar (recommend: keep Settings in sidebar only)
- If user's subscription doesn't include a product, consider graying out or hiding that link (future enhancement)
- Mobile menu should close after clicking a nav link (already implemented)

View File

@@ -0,0 +1,60 @@
# 07. Fix Apple Logo SVG In Social Auth Buttons
meta:
id: landing-pages-and-admin-07
feature: landing-pages-and-admin
priority: P3
depends_on: []
tags: [bugfix, ui, svg, auth]
objective:
- Fix the Apple logo SVG in `SocialAuthButtons.tsx` which renders incorrectly. Replace the malformed SVG path with Apple's official logo path that renders properly in both light and dark modes.
deliverables:
- Updated `/web/src/components/auth/SocialAuthButtons.tsx` with correct Apple logo SVG
- SVG renders correctly at all sizes (h-5 w-5)
- SVG displays properly in both light and dark themes
steps:
- Locate the Apple SVG in `SocialAuthButtons.tsx` (lines 39-41):
```svg
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.88..." />
</svg>
```
- Identify the issue: the current path data appears to be malformed or from an incorrect icon set
- Replace with Apple's official logo SVG path:
- Use the standard Apple logo path from Heroicons or similar reputable source
- Ensure `viewBox="0 0 24 24"` matches
- Ensure `fill="currentColor"` for theme compatibility
- Test rendering in both light and dark modes
- Verify the SVG displays correctly on both `/login` and `/signup` pages
tests:
- Visual: Apple logo renders as recognizable Apple logo shape
- Visual: Logo displays correctly in light mode (white button, dark logo)
- Visual: Logo displays correctly in dark mode (dark button, light logo)
- Integration: Login page renders without SVG errors
- Integration: Signup page renders without SVG errors
acceptance_criteria:
- Apple logo SVG renders as a recognizable Apple logo on both login and signup pages
- Logo scales correctly at h-5 w-5 size
- Logo color adapts to theme (via `currentColor`)
- No SVG rendering errors in browser console
- Logo is centered properly within the button
validation:
- `cd /Users/mike/Code/Kordant/web && pnpm dev` then navigate to `/login` and `/signup`
- Verify Apple logo renders correctly on both pages
- Toggle dark/light theme and verify logo displays correctly in both
- Check browser console for any SVG-related warnings or errors
- Inspect element to verify SVG path data is valid
notes:
- Current Apple SVG path: `M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0c-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.6 5.98.52 7.13-.62 1.28-1.4 2.55-2.57 3.08Zm-3.12-15.2c.03-1.14.44-2.23 1.07-3.03.82-.98 2.11-1.63 3.32-1.59.06 1.24-.4 2.45-1.12 3.3-.77.9-1.98 1.52-3.27 1.32Z`
- This path appears to be a leaf/plant shape, not the Apple logo
- Recommended replacement: use official Apple logo from Heroicons outline or solid set
- Apple logo path (Heroicons outline style): `M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11`
- Keep the same `viewBox="0 0 24 24"` and `fill="currentColor"` attributes
- The Google logo SVG above it appears correct — only the Apple logo needs fixing

View File

@@ -0,0 +1,28 @@
# Landing Pages & Admin
Objective: Restructure landing page to inline data pattern, build admin dashboard, dynamic blog from DB, pricing/features pages, auth-contextual navbar, and fix Apple SVG.
Status legend: [ ] todo, [~] in-progress, [x] done
Tasks
- [ ] 01 — Inline index page sections following Lendair pattern → `01-inline-index-sections.md`
- [ ] 02 — Admin routes with controls and services dashboard → `02-admin-routes-dashboard.md`
- [ ] 03 — Blog route with DB integration, featured post, and chronological feed → `03-blog-database-integration.md`
- [ ] 04 — Create blog post content (scam advice, AI detection, etc.) → `04-blog-content-creation.md`
- [ ] 05 — Dedicated /pricing and /features pages → `05-pricing-features-pages.md`
- [ ] 06 — Auth-contextual navbar with dynamic links → `06-auth-contextual-navbar.md`
- [ ] 07 — Fix Apple logo SVG in social auth buttons → `07-fix-apple-logo-svg.md`
Dependencies
- 03 depends on 02 (blog admin needs admin routes for managing featured posts)
- 05 depends on 06 (pricing/features pages need navbar links to resolve)
- 06 depends on 01 (navbar should reflect same inline data pattern)
Exit criteria
- Index page uses inline data arrays and layout (Lendair pattern) instead of extracted components
- Admin routes accessible at /admin with services dashboard and blog management
- /blog fetches posts from database with featured post support and chronological ordering
- At least 4 substantive blog posts created in the database with scam/AI/privacy content
- /pricing and /features routes exist with proper content
- Navbar shows different links based on auth state (logged-in sees product links, logged-out sees marketing links)
- Apple logo SVG renders correctly on login/signup pages

View File

@@ -1,3 +1,6 @@
- [ ] fix translation of index sections
to be like in
~/code/Lendair/web/src/routes/index.tsx
- [ ] admin routes with appropriate controls and services dashboard
- [ ] make a /blog route that shows a chronological feed with featured one (if one is marked as such in db - should be available to manage in admin route
- [ ] create actual blogs filled with good advice to avoid scams and what to do when one has happened, ai detection advice etc.

View File

@@ -37,7 +37,7 @@ export default function SocialAuthButtons(props: SocialAuthButtonsProps) {
class="flex items-center justify-center gap-3 w-full px-4 py-2.5 border border-(--color-border) rounded-lg text-sm font-medium text-white bg-black hover:bg-gray-900 transition-colors cursor-pointer"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.6 5.98.52 7.13-.62 1.28-1.4 2.55-2.57 3.08Zm-3.12-15.2c.03-1.14.44-2.23 1.07-3.03.82-.98 2.11-1.63 3.32-1.59.06 1.24-.4 2.45-1.12 3.3-.77.9-1.98 1.52-3.27 1.32Z" />
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11" />
</svg>
Continue with Apple
</button>

View File

@@ -20,7 +20,7 @@ export default function Home() {
</div>
<div
class="bg-dot-grid"
class="bg-dot-grid relative z-10"
style={{
"clip-path": "polygon(0 var(--cut), 100% 0, 100% 100%, 0 100%)",
}}