rebranding work

This commit is contained in:
2026-05-25 21:53:01 -04:00
parent 89822dedb8
commit c01c1a5636
52 changed files with 3090 additions and 70 deletions

View File

@@ -1,4 +1,4 @@
DATABASE_URL="postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
DATABASE_URL="postgresql://kordant:kordant_dev@localhost:5432/kordant"
REDIS_URL="redis://localhost:6379"
PORT=3000
LOG_LEVEL=info
@@ -7,7 +7,7 @@ RESEND_API_KEY=""
AWS_REGION="us-east-1"
# Datadog APM Configuration
DD_SERVICE="shieldai-api"
DD_SERVICE="kordant-api"
DD_ENV="development"
DD_VERSION="0.1.0"
DD_TRACE_ENABLED="true"

View File

@@ -7,7 +7,7 @@ RESEND_API_KEY=""
# Docker (for deployment)
DOCKER_TAG=latest
GITHUB_REPOSITORY_OWNER=shieldai
GITHUB_REPOSITORY_OWNER=kordant
# Server
PORT=3000

View File

@@ -58,13 +58,13 @@ jobs:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: shieldai
POSTGRES_USER: shieldai
POSTGRES_PASSWORD: shieldai_dev
POSTGRES_DB: kordant
POSTGRES_USER: kordant
POSTGRES_PASSWORD: kordant_dev
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U shieldai"
--health-cmd "pg_isready -U kordant"
--health-interval 5s
--health-timeout 5s
--health-retries 5
@@ -92,7 +92,7 @@ jobs:
- name: Run tests
run: pnpm test
env:
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
DATABASE_URL: "postgresql://kordant:kordant_dev@localhost:5432/kordant"
REDIS_URL: "redis://localhost:6379"
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4

View File

@@ -0,0 +1,61 @@
package com.shieldai.android.data.repository
import android.content.Context
import com.shieldai.android.data.local.CacheManager
import com.shieldai.android.data.model.Property
import com.shieldai.android.data.remote.ApiResult
import com.shieldai.android.data.remote.ErrorHandler
import com.shieldai.android.data.remote.TRPCApiService
import com.shieldai.android.data.remote.TRPCRequest
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
class HomeTitleRepository(
private val api: TRPCApiService,
private val context: Context,
) {
private val _properties = MutableStateFlow<List<Property>>(emptyList())
suspend fun getProperties(forceRefresh: Boolean = false): ApiResult<List<Property>> {
if (!forceRefresh) {
val cached: List<Property>? = CacheManager.load(context, "properties")
if (cached != null) {
_properties.value = cached
return ApiResult.Success(cached)
}
}
return ErrorHandler.executeWithRetry {
val response = api.propertyList(TRPCRequest.body(buildJsonObject {}))
val properties = response.result.data
CacheManager.save(context, "properties", properties)
_properties.value = properties
properties
}
}
suspend fun addProperty(address: String, type: String = "residential"): ApiResult<Property> {
return ErrorHandler.executeWithRetry {
val body = buildJsonObject {
put("address", address)
put("type", type)
}
val response = api.propertyAdd(TRPCRequest.body(body))
val property = response.result.data
refreshCache()
property
}
}
fun observeProperties(): Flow<List<Property>> = _properties
private suspend fun refreshCache() {
ErrorHandler.executeWithRetry {
val response = api.propertyList(TRPCRequest.body(buildJsonObject {}))
val properties = response.result.data
CacheManager.save(context, "properties", properties)
_properties.value = properties
}
}
}

View File

@@ -0,0 +1,81 @@
package com.shieldai.android.data.repository
import android.content.Context
import com.shieldai.android.data.local.CacheManager
import com.shieldai.android.data.model.BrokerListing
import com.shieldai.android.data.model.RemovalRequest
import com.shieldai.android.data.remote.ApiResult
import com.shieldai.android.data.remote.ErrorHandler
import com.shieldai.android.data.remote.TRPCApiService
import com.shieldai.android.data.remote.TRPCRequest
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
class RemoveBrokersRepository(
private val api: TRPCApiService,
private val context: Context,
) {
private val _listings = MutableStateFlow<List<BrokerListing>>(emptyList())
private val _removalRequests = MutableStateFlow<List<RemovalRequest>>(emptyList())
suspend fun getListings(forceRefresh: Boolean = false): ApiResult<List<BrokerListing>> {
if (!forceRefresh) {
val cached: List<BrokerListing>? = CacheManager.load(context, "broker_listings")
if (cached != null) {
_listings.value = cached
return ApiResult.Success(cached)
}
}
return ErrorHandler.executeWithRetry {
val response = api.brokerListListings(TRPCRequest.body(buildJsonObject {}))
val listings = response.result.data
CacheManager.save(context, "broker_listings", listings)
_listings.value = listings
listings
}
}
suspend fun getRemovalRequests(forceRefresh: Boolean = false): ApiResult<List<RemovalRequest>> {
if (!forceRefresh) {
val cached: List<RemovalRequest>? = CacheManager.load(context, "removal_requests")
if (cached != null) {
_removalRequests.value = cached
return ApiResult.Success(cached)
}
}
return ErrorHandler.executeWithRetry {
val response = api.removalList(TRPCRequest.body(buildJsonObject {}))
val requests = response.result.data
CacheManager.save(context, "removal_requests", requests)
_removalRequests.value = requests
requests
}
}
suspend fun createRemovalRequest(listingId: String, notes: String? = null): ApiResult<RemovalRequest> {
return ErrorHandler.executeWithRetry {
val body = buildJsonObject {
put("listingId", listingId)
notes?.let { put("notes", it) }
}
val response = api.removalCreate(TRPCRequest.body(body))
val request = response.result.data
refreshRemovalsCache()
request
}
}
fun observeListings(): Flow<List<BrokerListing>> = _listings
fun observeRemovalRequests(): Flow<List<RemovalRequest>> = _removalRequests
private suspend fun refreshRemovalsCache() {
ErrorHandler.executeWithRetry {
val response = api.removalList(TRPCRequest.body(buildJsonObject {}))
val requests = response.result.data
CacheManager.save(context, "removal_requests", requests)
_removalRequests.value = requests
}
}
}

View File

@@ -0,0 +1,86 @@
package com.shieldai.android.data.repository
import android.content.Context
import com.shieldai.android.data.local.CacheManager
import com.shieldai.android.data.model.SpamRule
import com.shieldai.android.data.remote.ApiResult
import com.shieldai.android.data.remote.ErrorHandler
import com.shieldai.android.data.remote.TRPCApiService
import com.shieldai.android.data.remote.TRPCRequest
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
class SpamShieldRepository(
private val api: TRPCApiService,
private val context: Context,
) {
private val _rules = MutableStateFlow<List<SpamRule>>(emptyList())
data class SpamStats(
val totalBlocked: Int = 0,
val totalFlagged: Int = 0,
val activeRules: Int = 0
)
suspend fun getRules(forceRefresh: Boolean = false): ApiResult<List<SpamRule>> {
if (!forceRefresh) {
val cached: List<SpamRule>? = CacheManager.load(context, "spam_rules")
if (cached != null) {
_rules.value = cached
return ApiResult.Success(cached)
}
}
return ErrorHandler.executeWithRetry {
val response = api.spamListRules(TRPCRequest.body(buildJsonObject {}))
val rules = response.result.data
CacheManager.save(context, "spam_rules", rules)
_rules.value = rules
rules
}
}
suspend fun createRule(pattern: String, action: String, description: String? = null): ApiResult<SpamRule> {
return ErrorHandler.executeWithRetry {
val body = buildJsonObject {
put("pattern", pattern)
put("action", action)
description?.let { put("description", it) }
}
val response = api.spamCreateRule(TRPCRequest.body(body))
val rule = response.result.data
refreshCache()
rule
}
}
suspend fun toggleRule(id: String, enabled: Boolean): ApiResult<Unit> {
return ErrorHandler.executeWithRetry {
_rules.value = _rules.value.map {
if (it.id == id) it.copy(enabled = enabled) else it
}
refreshCache()
}
}
fun getStats(): SpamStats {
val rules = _rules.value
return SpamStats(
totalBlocked = rules.count { it.action == "block" && it.enabled },
totalFlagged = rules.count { it.action == "flag" && it.enabled },
activeRules = rules.count { it.enabled }
)
}
fun observeRules(): Flow<List<SpamRule>> = _rules
private suspend fun refreshCache() {
ErrorHandler.executeWithRetry {
val response = api.spamListRules(TRPCRequest.body(buildJsonObject {}))
val rules = response.result.data
CacheManager.save(context, "spam_rules", rules)
_rules.value = rules
}
}
}

View File

@@ -3,6 +3,9 @@ package com.shieldai.android.di
import android.content.Context
import com.shieldai.android.data.repository.AlertRepository
import com.shieldai.android.data.repository.DarkWatchRepository
import com.shieldai.android.data.repository.HomeTitleRepository
import com.shieldai.android.data.repository.RemoveBrokersRepository
import com.shieldai.android.data.repository.SpamShieldRepository
import com.shieldai.android.data.repository.SubscriptionRepository
import com.shieldai.android.data.repository.UserRepository
import com.shieldai.android.data.repository.VoicePrintRepository
@@ -13,6 +16,9 @@ object RepositoryModule {
private var voicePrintRepository: VoicePrintRepository? = null
private var alertRepository: AlertRepository? = null
private var subscriptionRepository: SubscriptionRepository? = null
private var spamShieldRepository: SpamShieldRepository? = null
private var homeTitleRepository: HomeTitleRepository? = null
private var removeBrokersRepository: RemoveBrokersRepository? = null
fun provideUserRepository(context: Context): UserRepository {
return userRepository ?: synchronized(this) {
@@ -58,4 +64,31 @@ object RepositoryModule {
).also { subscriptionRepository = it }
}
}
fun provideSpamShieldRepository(context: Context): SpamShieldRepository {
return spamShieldRepository ?: synchronized(this) {
SpamShieldRepository(
api = NetworkModule.provideApiService(context),
context = context,
).also { spamShieldRepository = it }
}
}
fun provideHomeTitleRepository(context: Context): HomeTitleRepository {
return homeTitleRepository ?: synchronized(this) {
HomeTitleRepository(
api = NetworkModule.provideApiService(context),
context = context,
).also { homeTitleRepository = it }
}
}
fun provideRemoveBrokersRepository(context: Context): RemoveBrokersRepository {
return removeBrokersRepository ?: synchronized(this) {
RemoveBrokersRepository(
api = NetworkModule.provideApiService(context),
context = context,
).also { removeBrokersRepository = it }
}
}
}

View File

@@ -0,0 +1,107 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.Error
import com.shieldai.android.ui.theme.Success
import com.shieldai.android.ui.theme.TextPrimaryLight
import com.shieldai.android.ui.theme.Warning
@Composable
fun ThreatGauge(
score: Int,
modifier: Modifier = Modifier,
size: 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)
) {
val center = Offset(size / 2f, size / 2f)
val radius = (size / 2f - 16f) * dpiScale
val strokeWidth = 16f * dpiScale
drawArc(
color = Color(0xFFE2E8F0).copy(alpha = 0.3f),
startAngle = -135f,
sweepAngle = 270f,
useCenter = false,
topLeft = Offset(
center.x - radius,
center.y - radius
),
size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2),
style = Stroke(width = strokeWidth)
)
val sweepAngle = (score / 100f) * 270f
if (sweepAngle > 0) {
val gradient = Brush.linearGradient(
colors = listOf(startColor, endColor),
start = Offset(center.x - radius, center.y),
end = Offset(center.x + radius, center.y)
)
drawArc(
brush = gradient,
startAngle = -135f,
sweepAngle = sweepAngle,
useCenter = false,
topLeft = Offset(
center.x - radius,
center.y - radius
),
size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2),
style = Stroke(width = strokeWidth, cap = androidx.compose.ui.graphics.Paint.Cap.Round)
)
}
}
Text(
text = "$score",
style = MaterialTheme.typography.displayMedium,
color = when {
score <= 30 -> Success
score <= 60 -> Warning
else -> Error
},
modifier = Modifier.padding(top = 8.dp)
)
Text(
text = when {
score <= 30 -> "Low Risk"
score <= 60 -> "Medium Risk"
else -> "High Risk"
},
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
}
private val Float.dpiScale: Float
get() = 1f

View File

@@ -0,0 +1,281 @@
package com.shieldai.android.ui.screens.dashboard
import androidx.compose.foundation.layout.Arrangement
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.shieldai.android.data.model.Alert
import com.shieldai.android.ui.components.BadgeVariant
import com.shieldai.android.ui.components.ShieldBadge
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldButtonVariant
import com.shieldai.android.ui.components.ShieldCard
import com.shieldai.android.ui.components.ShieldEmptyState
import com.shieldai.android.ui.viewmodel.AlertDetailViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlertDetailScreen(
alertId: String,
onBack: () -> Unit = {},
modifier: Modifier = Modifier,
viewModel: AlertDetailViewModel = viewModel(factory = AlertDetailViewModel.Factory)
) {
val uiState by viewModel.uiState.collectAsState()
if (uiState.alert == null) {
viewModel.loadAlert(alertId)
}
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState()
)
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = {
Text(
text = uiState.alert?.title ?: "Alert Details",
fontWeight = FontWeight.SemiBold
)
},
navigationIcon = {
TextButton(onClick = onBack) {
Text("Back")
}
},
scrollBehavior = scrollBehavior
)
}
) { paddingValues ->
when {
uiState.isLoading -> {
Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
uiState.alert == null -> {
ShieldEmptyState(
title = "Alert not found",
description = "The requested alert could not be loaded",
actionButton = {
TextButton(onClick = onBack) {
Text("Go Back")
}
},
modifier = Modifier.padding(paddingValues)
)
}
else -> {
AlertDetailContent(
uiState = uiState,
onMarkResolved = { viewModel.markResolved() },
onMarkFalsePositive = { viewModel.markFalsePositive() },
modifier = Modifier.padding(paddingValues)
)
}
}
}
}
@Composable
private fun AlertDetailContent(
uiState: AlertDetailViewModel.AlertDetailUiState,
onMarkResolved: () -> Unit,
onMarkFalsePositive: () -> Unit,
modifier: Modifier = Modifier
) {
val alert = uiState.alert!!
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
AlertDetailHeader(alert)
}
item {
AlertDetailInfo(alert)
}
if (uiState.correlatedAlerts.isNotEmpty()) {
item {
Text(
text = "Correlated Alerts (${uiState.correlatedAlerts.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
}
items(uiState.correlatedAlerts) { correlated ->
CorrelatedAlertItem(correlated)
Spacer(modifier = Modifier.height(8.dp))
}
}
item {
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ShieldButton(
text = "Mark Resolved",
onClick = onMarkResolved,
variant = ShieldButtonVariant.Primary,
modifier = Modifier.weight(1f),
loading = uiState.isResolving
)
ShieldButton(
text = "False Positive",
onClick = onMarkFalsePositive,
variant = ShieldButtonVariant.Secondary,
modifier = Modifier.weight(1f)
)
}
}
}
}
@Composable
private fun AlertDetailHeader(alert: Alert) {
Column {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
val variant = when (alert.severity.lowercase()) {
"critical" -> BadgeVariant.Error
"high" -> BadgeVariant.Warning
"medium" -> BadgeVariant.Info
else -> BadgeVariant.Default
}
ShieldBadge(
text = "${alert.severity} severity",
variant = variant
)
if (!alert.read) {
ShieldBadge(
text = "Unread",
variant = BadgeVariant.Info
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = alert.title,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = alert.message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun AlertDetailInfo(alert: Alert) {
ShieldCard {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Details",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
InfoRow(label = "Type", value = alert.type)
InfoRow(label = "Severity", value = alert.severity)
InfoRow(label = "Status", value = if (alert.read) "Read" else "Unread")
alert.date?.let {
InfoRow(label = "Date", value = it)
}
alert.createdAt?.let {
InfoRow(label = "Created", value = it)
}
}
}
}
@Composable
private fun InfoRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
}
}
@Composable
private fun CorrelatedAlertItem(alert: Alert) {
ShieldCard(
modifier = Modifier.fillMaxWidth()
) {
Column {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = alert.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
AlertSeverityBadge(severity = alert.severity)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = alert.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
}
}

View File

@@ -0,0 +1,325 @@
package com.shieldai.android.ui.screens.dashboard
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.foundation.layout.width
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.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PullToRefreshBox
import androidx.compose.material3.Text
import androidx.compose.material3.pullToRefresh
import androidx.compose.material3.pullToRefreshDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.shieldai.android.R
import com.shieldai.android.data.model.Alert
import com.shieldai.android.ui.components.BadgeVariant
import com.shieldai.android.ui.components.ShieldBadge
import com.shieldai.android.ui.components.ShieldCard
import com.shieldai.android.ui.components.ShieldEmptyState
import com.shieldai.android.ui.components.ShieldSkeletonCard
import com.shieldai.android.ui.components.ThreatGauge
import com.shieldai.android.viewmodel.DashboardViewModel
import com.shieldai.android.viewmodel.DashboardViewModel as DashboardVM
data class ServiceSummary(
val name: String,
val count: Int,
val icon: ImageVector,
val route: String
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
onNavigateToAlert: (String) -> Unit = {},
onNavigateToService: (String) -> Unit = {},
modifier: Modifier = Modifier,
viewModel: DashboardViewModel = viewModel(factory = DashboardVM.Factory)
) {
val uiState by viewModel.uiState.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
var isRefreshingInternal by remember { mutableStateOf(false) }
if (isRefreshing) {
viewModel.refresh()
isRefreshing = false
}
PullToRefreshBox(
isRefreshing = isRefreshingInternal || uiState.isLoading,
onRefresh = {
isRefreshingInternal = false
viewModel.refresh()
},
modifier = modifier,
indicator = {
androidx.compose.material3.pullToRefreshDefaults.indicator(
parent,
transformer,
isRefreshingInternal || uiState.isLoading
)
}
) {
when {
uiState.isLoading && uiState.recentAlerts.isEmpty() -> {
DashboardLoadingState()
}
uiState.recentAlerts.isEmpty() && uiState.threatScore == 0 -> {
if (uiState.error != null) {
ShieldEmptyState(
title = "Failed to load",
description = uiState.error ?: "Unknown error",
actionButton = {
androidx.compose.material3.TextButton(onClick = { viewModel.refresh() }) {
Text("Retry")
}
}
)
} else {
ShieldEmptyState(
title = "No data",
description = "No dashboard data available"
)
}
}
else -> {
DashboardContent(
uiState = uiState,
onNavigateToAlert = onNavigateToAlert,
onNavigateToService = onNavigateToService
)
}
}
}
}
@Composable
private fun DashboardLoadingState() {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
item { ShieldSkeletonCard() }
item { ShieldSkeletonCard() }
item { ShieldSkeletonCard() }
}
}
@Composable
private fun DashboardContent(
uiState: DashboardViewModel.DashboardUiState,
onNavigateToAlert: (String) -> Unit,
onNavigateToService: (String) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
horizontal = 16.dp,
vertical = 16.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
DashboardHeader(uiState)
}
item {
ServiceSummaryRow(
uiState = uiState,
onNavigateToService = onNavigateToService
)
}
if (uiState.recentAlerts.isNotEmpty()) {
item {
Text(
text = "Recent Alerts",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
}
items(uiState.recentAlerts) { alert ->
AlertCard(alert, onNavigateToAlert)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@Composable
private fun DashboardHeader(uiState: DashboardViewModel.DashboardUiState) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
) {
Text(
text = "Threat Overview",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 16.dp)
)
ThreatGauge(score = uiState.threatScore)
if (uiState.unreadCount > 0) {
ShieldBadge(
text = "${uiState.unreadCount} unread alert${if (uiState.unreadCount > 1) "s" else ""}",
variant = BadgeVariant.Warning,
modifier = Modifier.padding(top = 12.dp)
)
}
}
}
@Composable
private fun ServiceSummaryRow(
uiState: DashboardViewModel.DashboardUiState,
onNavigateToService: (String) -> Unit
) {
val services = listOf(
ServiceSummary("DarkWatch", uiState.watchlistCount, ImageVector.vectorResource(R.drawable.ic_services), "darkwatch"),
ServiceSummary("VoicePrint", uiState.enrollmentCount, ImageVector.vectorResource(R.drawable.ic_services), "voiceprint"),
ServiceSummary("SpamShield", uiState.spamRulesCount, ImageVector.vectorResource(R.drawable.ic_services), "spamshield"),
ServiceSummary("HomeTitle", uiState.propertiesCount, ImageVector.vectorResource(R.drawable.ic_services), "hometitle"),
ServiceSummary("RemoveBrokers", uiState.removalsCount, ImageVector.vectorResource(R.drawable.ic_services), "removebrokers")
)
Text(
text = "Services",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(services) { service ->
ServiceCard(
service = service,
onClick = { onNavigateToService(service.route) }
)
}
}
}
@Composable
private fun ServiceCard(
service: ServiceSummary,
onClick: () -> Unit
) {
ShieldCard(
onClick = onClick,
modifier = Modifier.width(130.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(8.dp)
) {
Icon(
imageVector = service.icon,
contentDescription = service.name,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = service.name,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium
)
Text(
text = "${service.count}",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
}
@Composable
private fun AlertCard(
alert: Alert,
onClick: (String) -> Unit
) {
ShieldCard(
onClick = { onClick(alert.id) },
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = alert.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = alert.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
AlertSeverityBadge(severity = alert.severity)
}
}
}
@Composable
private fun AlertSeverityBadge(severity: String) {
val variant = when (severity.lowercase()) {
"critical" -> BadgeVariant.Error
"high" -> BadgeVariant.Warning
"medium" -> BadgeVariant.Info
else -> BadgeVariant.Default
}
ShieldBadge(
text = severity,
variant = variant
)
}

View File

@@ -0,0 +1,332 @@
package com.shieldai.android.ui.screens.services
import androidx.compose.foundation.layout.Arrangement
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.lazy.LazyColumn
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.Icon
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.shieldai.android.R
import com.shieldai.android.ui.components.ShieldBadge
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldButtonVariant
import com.shieldai.android.ui.components.ShieldCard
import com.shieldai.android.ui.components.ShieldEmptyState
import com.shieldai.android.ui.components.ShieldTextField
import com.shieldai.android.viewmodel.DarkWatchViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DarkWatchScreen(
onBack: () -> Unit = {},
modifier: Modifier = Modifier,
viewModel: DarkWatchViewModel = viewModel(factory = DarkWatchViewModel.Factory)
) {
val uiState by viewModel.uiState.collectAsState()
var showAddSheet by remember { mutableStateOf(false) }
var newType by remember { mutableStateOf("email") }
var newValue by remember { mutableStateOf("") }
var newLabel by remember { mutableStateOf("") }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState()
)
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text("DarkWatch", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
TextButton(onClick = onBack) { Text("Back") }
},
scrollBehavior = scrollBehavior
)
},
floatingActionButton = {
if (!showAddSheet) {
FloatingActionButton(onClick = { showAddSheet = true }) {
Icon(
painter = painterResource(R.drawable.ic_dashboard),
contentDescription = "Add to watchlist"
)
}
}
}
) { paddingValues ->
when {
uiState.isLoading && uiState.watchlist.isEmpty() -> {
androidx.compose.foundation.layout.Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
uiState.watchlist.isEmpty() && uiState.exposures.isEmpty() -> {
ShieldEmptyState(
title = "No watchlist items",
description = "Add people to monitor for data exposures",
actionButton = {
ShieldButton(
text = "Add to Watchlist",
onClick = { showAddSheet = true },
variant = ShieldButtonVariant.Primary
)
},
modifier = Modifier.padding(paddingValues)
)
}
else -> {
DarkWatchContent(
uiState = uiState,
modifier = Modifier.padding(paddingValues)
)
}
}
if (showAddSheet) {
AddWatchlistSheet(
onDismiss = {
showAddSheet = false
newValue = ""
newLabel = ""
},
onAdd = {
viewModel.addWatchlistItem(newType, newValue, newLabel.ifBlank { null })
showAddSheet = false
newValue = ""
newLabel = ""
},
type = newType,
onTypeChange = { newType = it },
value = newValue,
onValueChange = { newValue = it },
label = newLabel,
onLabelChange = { newLabel = it },
isLoading = uiState.isAdding
)
}
}
}
@Composable
private fun DarkWatchContent(
uiState: DarkWatchViewModel.DarkWatchUiState,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (uiState.watchlist.isNotEmpty()) {
item {
Text(
text = "Watchlist (${uiState.watchlist.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
}
items(uiState.watchlist) { item ->
WatchlistItemCard(item)
Spacer(modifier = Modifier.height(8.dp))
}
}
if (uiState.exposures.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Exposures (${uiState.exposures.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
}
items(uiState.exposures) { exposure ->
ExposureCard(exposure)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@Composable
private fun WatchlistItemCard(item: com.shieldai.android.data.model.WatchlistItem) {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.value,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
if (item.label != null) {
Text(
text = item.label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = item.type,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
ShieldBadge(
text = item.status,
variant = if (item.status == "active") com.shieldai.android.ui.components.BadgeVariant.Success
else com.shieldai.android.ui.components.BadgeVariant.Default
)
}
}
}
@Composable
private fun ExposureCard(exposure: com.shieldai.android.data.model.Exposure) {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = exposure.source,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
ShieldBadge(
text = exposure.severity,
variant = when (exposure.severity.lowercase()) {
"critical" -> com.shieldai.android.ui.components.BadgeVariant.Error
"high" -> com.shieldai.android.ui.components.BadgeVariant.Warning
else -> com.shieldai.android.ui.components.BadgeVariant.Info
}
)
}
exposure.details?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddWatchlistSheet(
onDismiss: () -> Unit,
onAdd: () -> Unit,
type: String,
onTypeChange: (String) -> Unit,
value: String,
onValueChange: (String) -> Unit,
label: String,
onLabelChange: (String) -> Unit,
isLoading: Boolean
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Add to Watchlist",
style = MaterialTheme.typography.titleLarge
)
androidx.compose.material3.ExposedDropdownMenuBox(
expanded = false,
onExpandedChange = {}
) {
ShieldTextField(
value = type,
onValueChange = onTypeChange,
label = "Type",
modifier = Modifier.fillMaxWidth()
)
}
ShieldTextField(
value = value,
onValueChange = onValueChange,
label = "Value (email, name, etc.)",
modifier = Modifier.fillMaxWidth()
)
ShieldTextField(
value = label,
onValueChange = onLabelChange,
label = "Label (optional)",
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ShieldButton(
text = "Cancel",
onClick = onDismiss,
variant = ShieldButtonVariant.Secondary,
modifier = Modifier.weight(1f)
)
ShieldButton(
text = "Add",
onClick = onAdd,
variant = ShieldButtonVariant.Primary,
modifier = Modifier.weight(1f),
enabled = value.isNotBlank(),
loading = isLoading
)
}
}
}
}

View File

@@ -0,0 +1,256 @@
package com.shieldai.android.ui.screens.services
import androidx.compose.foundation.layout.Arrangement
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.lazy.LazyColumn
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.Icon
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.shieldai.android.R
import com.shieldai.android.ui.components.BadgeVariant
import com.shieldai.android.ui.components.ShieldBadge
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldButtonVariant
import com.shieldai.android.ui.components.ShieldCard
import com.shieldai.android.ui.components.ShieldEmptyState
import com.shieldai.android.ui.components.ShieldTextField
import com.shieldai.android.viewmodel.VoicePrintViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VoicePrintScreen(
onBack: () -> Unit = {},
modifier: Modifier = Modifier,
viewModel: VoicePrintViewModel = viewModel(factory = VoicePrintViewModel.Factory)
) {
val uiState by viewModel.uiState.collectAsState()
var showEnrollSheet by remember { mutableStateOf(false) }
var enrollmentName by remember { mutableStateOf("") }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState()
)
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text("VoicePrint", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
TextButton(onClick = onBack) { Text("Back") }
},
scrollBehavior = scrollBehavior
)
},
floatingActionButton = {
if (!showEnrollSheet) {
FloatingActionButton(onClick = { showEnrollSheet = true }) {
Icon(
painter = painterResource(R.drawable.ic_dashboard),
contentDescription = "New enrollment"
)
}
}
}
) { paddingValues ->
when {
uiState.isLoading && uiState.enrollments.isEmpty() -> {
androidx.compose.foundation.layout.Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
uiState.enrollments.isEmpty() -> {
ShieldEmptyState(
title = "No enrollments",
description = "Enroll voice profiles to detect impersonation",
actionButton = {
ShieldButton(
text = "New Enrollment",
onClick = { showEnrollSheet = true },
variant = ShieldButtonVariant.Primary
)
},
modifier = Modifier.padding(paddingValues)
)
}
else -> {
VoicePrintContent(
uiState = uiState,
modifier = Modifier.padding(paddingValues)
)
}
}
if (showEnrollSheet) {
EnrollSheet(
onDismiss = {
showEnrollSheet = false
enrollmentName = ""
},
onEnroll = {
viewModel.createEnrollment(enrollmentName)
showEnrollSheet = false
enrollmentName = ""
},
name = enrollmentName,
onNameChange = { enrollmentName = it },
isLoading = uiState.isEnrolling
)
}
}
}
@Composable
private fun VoicePrintContent(
uiState: VoicePrintViewModel.VoicePrintUiState,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
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))
}
items(uiState.enrollments) { enrollment ->
EnrollmentCard(enrollment)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@Composable
private fun EnrollmentCard(enrollment: com.shieldai.android.data.model.VoiceEnrollment) {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = enrollment.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Text(
text = "${enrollment.sampleCount} samples",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
ShieldBadge(
text = enrollment.status,
variant = when (enrollment.status.lowercase()) {
"active" -> BadgeVariant.Success
"pending" -> BadgeVariant.Warning
else -> BadgeVariant.Default
}
)
}
enrollment.createdAt?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Created: $it",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EnrollSheet(
onDismiss: () -> Unit,
onEnroll: () -> Unit,
name: String,
onNameChange: (String) -> Unit,
isLoading: Boolean
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "New Voice Enrollment",
style = MaterialTheme.typography.titleLarge
)
Text(
text = "Enter a name for this voice profile. You will be able to record samples afterwards.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
ShieldTextField(
value = name,
onValueChange = onNameChange,
label = "Profile name",
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ShieldButton(
text = "Cancel",
onClick = onDismiss,
variant = ShieldButtonVariant.Secondary,
modifier = Modifier.weight(1f)
)
ShieldButton(
text = "Enroll",
onClick = onEnroll,
variant = ShieldButtonVariant.Primary,
modifier = Modifier.weight(1f),
enabled = name.isNotBlank(),
loading = isLoading
)
}
}
}
}

View File

@@ -0,0 +1,93 @@
package com.shieldai.android.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.shieldai.android.ShieldAIApp
import com.shieldai.android.data.model.Alert
import com.shieldai.android.data.repository.AlertRepository
import com.shieldai.android.di.RepositoryModule
import kotlinx.coroutines.flow.MutableStateFlow
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() {
private val _uiState = MutableStateFlow(AlertDetailUiState())
val uiState: StateFlow<AlertDetailUiState> = _uiState.asStateFlow()
private val alertRepo: AlertRepository by lazy {
RepositoryModule.provideAlertRepository(ShieldAIApp.instance)
}
fun loadAlert(alertId: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
try {
val result = alertRepo.getAlerts()
if (result is com.shieldai.android.data.remote.ApiResult.Success) {
val alert = result.data.find { it.id == alertId }
val correlated = alert?.let {
result.data.filter { a ->
a.id != alertId && a.type == it.type
}
} ?: emptyList()
_uiState.value = _uiState.value.copy(
isLoading = false,
alert = alert,
correlatedAlerts = correlated
)
alert?.let { markAlertRead(it.id) }
} else {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = "Failed to load alert"
)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to load alert"
)
}
}
}
fun markResolved() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isResolving = true)
val alert = _uiState.value.alert
alert?.let { markAlertRead(it.id) }
_uiState.value = _uiState.value.copy(isResolving = false)
}
}
fun markFalsePositive() {
viewModelScope.launch {
val alert = _uiState.value.alert
alert?.let { markAlertRead(it.id) }
}
}
private fun markAlertRead(alertId: String) {
alertRepo.markRead(alertId)
}
companion object {
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return AlertDetailViewModel() as T
}
}
}
}

View File

@@ -0,0 +1,100 @@
package com.shieldai.android.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.shieldai.android.ShieldAIApp
import com.shieldai.android.data.model.Exposure
import com.shieldai.android.data.model.WatchlistItem
import com.shieldai.android.data.repository.DarkWatchRepository
import com.shieldai.android.di.RepositoryModule
import kotlinx.coroutines.flow.MutableStateFlow
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() {
private val _uiState = MutableStateFlow(DarkWatchUiState())
val uiState: StateFlow<DarkWatchUiState> = _uiState.asStateFlow()
private val repo: DarkWatchRepository by lazy {
RepositoryModule.provideDarkWatchRepository(ShieldAIApp.instance)
}
init {
loadData()
}
fun refresh() {
loadData(forceRefresh = true)
}
private fun loadData(forceRefresh: Boolean = false) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = !forceRefresh, error = null)
try {
val watchlistResult = repo.getWatchlist(forceRefresh)
val exposuresResult = repo.getExposures(forceRefresh)
val watchlist = if (watchlistResult is com.shieldai.android.data.remote.ApiResult.Success) {
watchlistResult.data
} else emptyList()
val exposures = if (exposuresResult is com.shieldai.android.data.remote.ApiResult.Success) {
exposuresResult.data
} else emptyList()
_uiState.value = _uiState.value.copy(
isLoading = false,
watchlist = watchlist,
exposures = exposures
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to load data"
)
}
}
}
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.shieldai.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)
}
}
}
fun removeWatchlistItem(id: String) {
viewModelScope.launch {
repo.removeWatchlistItem(id)
loadData(forceRefresh = true)
}
}
companion object {
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return DarkWatchViewModel() as T
}
}
}
}

View File

@@ -0,0 +1,154 @@
package com.shieldai.android.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.shieldai.android.ShieldAIApp
import com.shieldai.android.data.model.Alert
import com.shieldai.android.data.repository.AlertRepository
import com.shieldai.android.data.repository.DarkWatchRepository
import com.shieldai.android.data.repository.HomeTitleRepository
import com.shieldai.android.data.repository.RemoveBrokersRepository
import com.shieldai.android.data.repository.SpamShieldRepository
import com.shieldai.android.data.repository.VoicePrintRepository
import com.shieldai.android.di.RepositoryModule
import kotlinx.coroutines.flow.MutableStateFlow
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() {
private val _uiState = MutableStateFlow(DashboardUiState())
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
private val alertRepo: AlertRepository by lazy {
RepositoryModule.provideAlertRepository(ShieldAIApp.instance)
}
private val darkWatchRepo: DarkWatchRepository by lazy {
RepositoryModule.provideDarkWatchRepository(ShieldAIApp.instance)
}
private val voicePrintRepo: VoicePrintRepository by lazy {
RepositoryModule.provideVoicePrintRepository(ShieldAIApp.instance)
}
private val spamShieldRepo: SpamShieldRepository by lazy {
RepositoryModule.provideSpamShieldRepository(ShieldAIApp.instance)
}
private val homeTitleRepo: HomeTitleRepository by lazy {
RepositoryModule.provideHomeTitleRepository(ShieldAIApp.instance)
}
private val removeBrokersRepo: RemoveBrokersRepository by lazy {
RepositoryModule.provideRemoveBrokersRepository(ShieldAIApp.instance)
}
init {
loadDashboardData()
}
fun refresh() {
loadDashboardData(true)
}
private fun loadDashboardData(forceRefresh: Boolean = false) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = !forceRefresh, error = null)
try {
val alertsResult = alertRepo.getAlerts()
val watchlistResult = darkWatchRepo.getWatchlist(forceRefresh)
val enrollmentsResult = voicePrintRepo.getEnrollments()
val rulesResult = spamShieldRepo.getRules()
val propertiesResult = homeTitleRepo.getProperties()
val removalsResult = removeBrokersRepo.getRemovalRequests()
val alerts = when (alertsResult) {
is com.shieldai.android.data.remote.ApiResult.Success -> alertsResult.data
else -> emptyList()
}
val watchlist = when (watchlistResult) {
is com.shieldai.android.data.remote.ApiResult.Success -> watchlistResult.data
else -> emptyList()
}
val enrollments = when (enrollmentsResult) {
is com.shieldai.android.data.remote.ApiResult.Success -> enrollmentsResult.data
else -> emptyList()
}
val rules = when (rulesResult) {
is com.shieldai.android.data.remote.ApiResult.Success -> rulesResult.data
else -> emptyList()
}
val properties = when (propertiesResult) {
is com.shieldai.android.data.remote.ApiResult.Success -> propertiesResult.data
else -> emptyList()
}
val removals = when (removalsResult) {
is com.shieldai.android.data.remote.ApiResult.Success -> removalsResult.data
else -> emptyList()
}
val threatScore = calculateThreatScore(alerts)
val unreadCount = alerts.count { !it.read }
val recentAlerts = alerts.sortedByDescending { it.createdAt }
.take(5)
_uiState.value = _uiState.value.copy(
isLoading = false,
threatScore = threatScore,
recentAlerts = recentAlerts,
unreadCount = unreadCount,
watchlistCount = watchlist.size,
enrollmentCount = enrollments.size,
spamRulesCount = rules.size,
propertiesCount = properties.size,
removalsCount = removals.size
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to load dashboard data"
)
}
}
}
fun markAlertRead(alertId: String) {
viewModelScope.launch {
alertRepo.markRead(alertId)
}
}
private fun calculateThreatScore(alerts: List<Alert>): Int {
if (alerts.isEmpty()) return 0
val score = alerts.sumOf {
when (it.severity.lowercase()) {
"critical" -> 25
"high" -> 15
"medium" -> 8
"low" -> 3
else -> 1
}
}
return minOf(score.coerceAtMost(100), 100)
}
companion object {
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return DashboardViewModel() as T
}
}
}
}

View File

@@ -0,0 +1,84 @@
package com.shieldai.android.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.shieldai.android.ShieldAIApp
import com.shieldai.android.data.model.Property
import com.shieldai.android.data.repository.HomeTitleRepository
import com.shieldai.android.di.RepositoryModule
import kotlinx.coroutines.flow.MutableStateFlow
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() {
private val _uiState = MutableStateFlow(HomeTitleUiState())
val uiState: StateFlow<HomeTitleUiState> = _uiState.asStateFlow()
private val repo: HomeTitleRepository by lazy {
RepositoryModule.provideHomeTitleRepository(ShieldAIApp.instance)
}
init {
loadProperties()
}
fun refresh() {
loadProperties(forceRefresh = true)
}
private fun loadProperties(forceRefresh: Boolean = false) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = !forceRefresh, error = null)
try {
val result = repo.getProperties(forceRefresh)
if (result is com.shieldai.android.data.remote.ApiResult.Success) {
_uiState.value = _uiState.value.copy(
isLoading = false,
properties = result.data
)
} else {
_uiState.value = _uiState.value.copy(isLoading = false)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to load properties"
)
}
}
}
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.shieldai.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)
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return HomeTitleViewModel() as T
}
}
}
}

View File

@@ -0,0 +1,93 @@
package com.shieldai.android.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.shieldai.android.ShieldAIApp
import com.shieldai.android.data.model.BrokerListing
import com.shieldai.android.data.model.RemovalRequest
import com.shieldai.android.data.repository.RemoveBrokersRepository
import com.shieldai.android.di.RepositoryModule
import kotlinx.coroutines.flow.MutableStateFlow
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() {
private val _uiState = MutableStateFlow(RemoveBrokersUiState())
val uiState: StateFlow<RemoveBrokersUiState> = _uiState.asStateFlow()
private val repo: RemoveBrokersRepository by lazy {
RepositoryModule.provideRemoveBrokersRepository(ShieldAIApp.instance)
}
init {
loadData()
}
fun refresh() {
loadData(forceRefresh = true)
}
private fun loadData(forceRefresh: Boolean = false) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = !forceRefresh, error = null)
try {
val listingsResult = repo.getListings(forceRefresh)
val requestsResult = repo.getRemovalRequests(forceRefresh)
val listings = if (listingsResult is com.shieldai.android.data.remote.ApiResult.Success) {
listingsResult.data
} else emptyList()
val requests = if (requestsResult is com.shieldai.android.data.remote.ApiResult.Success) {
requestsResult.data
} else emptyList()
_uiState.value = _uiState.value.copy(
isLoading = false,
listings = listings,
removalRequests = requests
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to load data"
)
}
}
}
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.shieldai.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)
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return RemoveBrokersViewModel() as T
}
}
}
}

View File

@@ -0,0 +1,109 @@
package com.shieldai.android.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.shieldai.android.ShieldAIApp
import com.shieldai.android.data.model.Subscription
import com.shieldai.android.data.model.User
import com.shieldai.android.data.repository.SubscriptionRepository
import com.shieldai.android.data.repository.UserRepository
import com.shieldai.android.di.RepositoryModule
import kotlinx.coroutines.flow.MutableStateFlow
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() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
private val userRepo: UserRepository by lazy {
RepositoryModule.provideUserRepository(ShieldAIApp.instance)
}
private val subscriptionRepo: SubscriptionRepository by lazy {
RepositoryModule.provideSubscriptionRepository(ShieldAIApp.instance)
}
init {
loadSettings()
}
fun refresh() {
loadSettings(forceRefresh = true)
}
private fun loadSettings(forceRefresh: Boolean = false) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
try {
val userResult = userRepo.getMe(forceRefresh)
val subResult = subscriptionRepo.getSubscription()
val user = if (userResult is com.shieldai.android.data.remote.ApiResult.Success) {
userResult.data
} else null
val subscription = if (subResult is com.shieldai.android.data.remote.ApiResult.Success) {
subResult.data
} else null
_uiState.value = _uiState.value.copy(
isLoading = false,
user = user,
subscription = subscription
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to load settings"
)
}
}
}
fun toggleNotifications(enabled: Boolean) {
_uiState.value = _uiState.value.copy(notificationsEnabled = enabled)
}
fun toggleDarkMode(enabled: Boolean) {
_uiState.value = _uiState.value.copy(darkModeEnabled = enabled)
}
fun toggleBiometric(enabled: Boolean) {
_uiState.value = _uiState.value.copy(biometricEnabled = enabled)
}
fun updateProfile(name: String? = null, phone: String? = null) {
viewModelScope.launch {
userRepo.updateProfile(name, phone)
loadSettings(forceRefresh = true)
}
}
fun upgradeSubscription() {
viewModelScope.launch {
subscriptionRepo.updateSubscription("Premium")
loadSettings(forceRefresh = true)
}
}
companion object {
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return SettingsViewModel() as T
}
}
}
}

View File

@@ -0,0 +1,98 @@
package com.shieldai.android.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.shieldai.android.ShieldAIApp
import com.shieldai.android.data.model.SpamRule
import com.shieldai.android.data.repository.SpamShieldRepository
import com.shieldai.android.di.RepositoryModule
import kotlinx.coroutines.flow.MutableStateFlow
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() {
private val _uiState = MutableStateFlow(SpamShieldUiState())
val uiState: StateFlow<SpamShieldUiState> = _uiState.asStateFlow()
private val repo: SpamShieldRepository by lazy {
RepositoryModule.provideSpamShieldRepository(ShieldAIApp.instance)
}
init {
loadRules()
}
fun refresh() {
loadRules(forceRefresh = true)
}
private fun loadRules(forceRefresh: Boolean = false) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = !forceRefresh, error = null)
try {
val result = repo.getRules(forceRefresh)
if (result is com.shieldai.android.data.remote.ApiResult.Success) {
val stats = repo.getStats()
_uiState.value = _uiState.value.copy(
isLoading = false,
rules = result.data,
totalBlocked = stats.totalBlocked,
totalFlagged = stats.totalFlagged,
activeRules = stats.activeRules
)
} else {
_uiState.value = _uiState.value.copy(isLoading = false)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to load rules"
)
}
}
}
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.shieldai.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)
}
}
}
fun toggleRule(id: String, enabled: Boolean) {
viewModelScope.launch {
repo.toggleRule(id, enabled)
loadRules(forceRefresh = true)
}
}
companion object {
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return SpamShieldViewModel() as T
}
}
}
}

View File

@@ -0,0 +1,96 @@
package com.shieldai.android.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.shieldai.android.ShieldAIApp
import com.shieldai.android.data.model.VoiceEnrollment
import com.shieldai.android.data.repository.VoicePrintRepository
import com.shieldai.android.di.RepositoryModule
import kotlinx.coroutines.flow.MutableStateFlow
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() {
private val _uiState = MutableStateFlow(VoicePrintUiState())
val uiState: StateFlow<VoicePrintUiState> = _uiState.asStateFlow()
private val repo: VoicePrintRepository by lazy {
RepositoryModule.provideVoicePrintRepository(ShieldAIApp.instance)
}
init {
loadEnrollments()
}
fun refresh() {
loadEnrollments(forceRefresh = true)
}
private fun loadEnrollments(forceRefresh: Boolean = false) {
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.shieldai.android.data.remote.ApiResult.Success) {
_uiState.value = _uiState.value.copy(
isLoading = false,
enrollments = result.data
)
} else {
_uiState.value = _uiState.value.copy(isLoading = false)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to load enrollments"
)
}
}
}
fun createEnrollment(name: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isEnrolling = true, error = null)
val result = repo.createEnrollment(name)
if (result is com.shieldai.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)
}
}
}
fun deleteEnrollment(id: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(
enrollments = _uiState.value.enrollments.filter { it.id != id }
)
}
}
companion object {
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return VoicePrintViewModel() as T
}
}
}
}

View File

@@ -1,5 +1,5 @@
{
"name": "@shieldai/browser-ext",
"name": "@kordant/browser-ext",
"version": "0.1.0",
"private": true,
"type": "module",

View File

@@ -1,5 +1,5 @@
{
"name": "shieldai",
"name": "kordant",
"version": "0.1.0",
"private": true,
"workspaces": [
@@ -9,11 +9,11 @@
"scripts": {
"dev": "pnpm --filter web dev",
"build": "pnpm --filter web build",
"build:ext": "pnpm --filter @shieldai/browser-ext build",
"build:ext": "pnpm --filter @kordant/browser-ext build",
"test": "pnpm --filter web test",
"test:ext": "pnpm --filter @shieldai/browser-ext test",
"test:ext": "pnpm --filter @kordant/browser-ext test",
"lint": "pnpm --filter web lint",
"lint:ext": "pnpm --filter @shieldai/browser-ext lint",
"lint:ext": "pnpm --filter @kordant/browser-ext lint",
"db:migrate": "pnpm --filter web db:migrate",
"db:seed": "pnpm --filter web db:seed"
},

View File

@@ -93,7 +93,7 @@
## Dependencies
- `@shieldai/db`: Database schemas (exists)
- `@kordant/db`: Database schemas (exists)
- `libphonenumber-js`: Phone validation (already in package.json)
- `ws`: WebSocket library (needs to be added to package.json)
- Twilio/Plivo SDKs: For carrier integration (using direct HTTP)

View File

@@ -11,7 +11,7 @@ Create a new `spam-rate-limit.middleware.ts` file that implements Redis-backed r
### Requirements
The middleware should:
1. Use the RedisService from `@shieldai/shared-notifications`
1. Use the RedisService from `@kordant/shared-notifications`
2. Implement per-minute AND daily rate limit tracking
3. Check rate limits before processing spam classification requests
4. Return appropriate HTTP 429 responses when limits are exceeded
@@ -46,7 +46,7 @@ if (rateLimitCheck.exceeded) {
## Acceptance Criteria
- [ ] Create `services/spamshield/src/middleware/spam-rate-limit.middleware.ts`
- [ ] Import and use RedisService from `@shieldai/shared-notifications`
- [ ] Import and use RedisService from `@kordant/shared-notifications`
- [ ] Implement `checkLimit(userId, tier)` method returning rate limit status
- [ ] Implement `incrementCounter(userId, tier)` method
- [ ] Support per-minute and per-day limit tracking
@@ -59,7 +59,7 @@ if (rateLimitCheck.exceeded) {
## Dependencies
- FRE-4522 (spamshield.config.ts with rate limit structure)
- `@shieldai/shared-notifications` (RedisService)
- `@kordant/shared-notifications` (RedisService)
## Priority
HIGH (Core middleware implementation)

View File

@@ -119,7 +119,7 @@ Get current rate limit status for a user.
## Dependencies
- FRE-4522 (spamshield.config.ts with rate limit structure)
- FRE-4523 (spam-rate-limit.middleware.ts)
- `@shieldai/types` (for type definitions)
- `@kordant/types` (for type definitions)
## Priority
MEDIUM (Depends on middleware implementation)

View File

@@ -52,7 +52,7 @@ This document describes how to integrate the waitlist email templates into the w
In `packages/api/src/routes/waitlist.routes.ts`, after `prisma.waitlistEntry.create()` succeeds:
```typescript
import { EmailService } from '@shieldai/shared-notifications';
import { EmailService } from '@kordant/shared-notifications';
// Send confirmation immediately
await EmailService.getInstance().sendWithTemplate(email, {

View File

@@ -0,0 +1,37 @@
# 01. Update Monorepo Foundation
meta:
id: rebrand-to-kordant-01
feature: rebrand-to-kordant
priority: P0
depends_on: []
tags: [infrastructure, configuration]
objective:
- Update the root package name, workspace metadata, and environment configuration files from ShieldAI to Kordant.
deliverables:
- Root package.json name changed from "shieldai" to "kordant"
- Turbo pipeline `build:ext` and `test:ext` script references updated
- Root .env.example updated (DB user, database name, DD_SERVICE)
- .env.prod.example updated (GITHUB_REPOSITORY_OWNER)
- .editorconfig: no changes needed (not brand-specific)
steps:
1. Edit `package.json` — change `"name": "shieldai"` to `"name": "kordant"`
2. Edit `package.json` — update `"build:ext"` script: `@shieldai/browser-ext` → needs package scope update (will be done in task 03; for now just the script name stays but the scope resolves later)
3. Edit `.env.example` — update DATABASE_URL user/db from `shieldai` to `kordant`, update `DD_SERVICE="shieldai-api"` to `DD_SERVICE="kordant-api"`
4. Edit `.env.prod.example` — update `GITHUB_REPOSITORY_OWNER=shieldai` to `GITHUB_REPOSITORY_OWNER=kordant`
tests:
- Unit: Verify package.json `name` equals "kordant"
- Unit: Verify .env.example has no "shieldai" references
acceptance_criteria:
- `cat package.json | jq .name` returns "kordant"
- `grep -rn "shieldai" .env.example .env.prod.example` returns empty
- `grep "DD_SERVICE" .env.example` shows "kordant-api"
validation:
- Run `pnpm build` from root — should not fail (may have pre-existing errors unrelated to this change)
- Verify `pnpm --filter kordant*` style commands work

View File

@@ -0,0 +1,39 @@
# 02. Update Database Connection Strings and DB Names
meta:
id: rebrand-to-kordant-02
feature: rebrand-to-kordant
priority: P1
depends_on: [rebrand-to-kordant-01]
tags: [infrastructure, database]
objective:
- Update all Turso database URLs and PostgreSQL connection strings from shieldai-* to kordant-* and database names from shieldai to kordant.
deliverables:
- web/src/server/db/index.ts — Turso URL updated
- web/drizzle.config.ts — Turso URL updated
- web/.env.development — DATABASE_URL updated
- web/.env.example — DATABASE_URL db name updated
- .env.example — DATABASE_URL user/db updated
- .github/workflows/ci.yml — PostgreSQL credentials and DB name updated
steps:
1. Edit `web/src/server/db/index.ts` — change `libsql://shieldai-dev-*` to `libsql://kordant-dev-*`
2. Edit `web/drizzle.config.ts` — same Turso URL update
3. Edit `web/.env.development` — same URL update
4. Edit `web/.env.example` — update `.../shieldai` postgres db name to `.../kordant`
5. Edit `.env.example` — update `POSTGRES_DB: shieldai`, `POSTGRES_USER: shieldai`, `POSTGRES_PASSWORD: shieldai_dev`, and `pg_isready -U shieldai`, and `DATABASE_URL` all to use `kordant`
6. Edit `.github/workflows/ci.yml` — update all `shieldai` references in db service config (POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD, pg_isready, DATABASE_URL)
tests:
- Unit: grep for any remaining `shieldai.*turso.io` patterns — should be zero
- Config: verify all DATABASE_URL references use `kordant` db name
acceptance_criteria:
- No `shieldai-dev-` or `shieldai` database name remains in any connection string
- CI workflow uses `kordant` as PostgreSQL database name
validation:
- Run `grep -rn "libsql://shieldai" web/` — expect zero results
- Run `grep -rn "POSTGRES_DB: shieldai" .github/` — expect zero results

View File

@@ -0,0 +1,39 @@
# 03. Update @shieldai/* Package Scopes and Web Imports
meta:
id: rebrand-to-kordant-03
feature: rebrand-to-kordant
priority: P0
depends_on: [rebrand-to-kordant-01]
tags: [infrastructure, packages]
objective:
- Rename all `@shieldai/*` package scopes to `@kordant/*` across the monorepo and update all corresponding import statements.
deliverables:
- browser-ext/package.json — `"name": "@shieldai/browser-ext"``"@kordant/browser-ext"`
- All files importing `@shieldai/*` — import paths updated to `@kordant/*`
- All plan files referencing `@shieldai/*` — updated
steps:
1. Edit `browser-ext/package.json` — change package scope from `@shieldai` to `@kordant`
2. Search and replace all `@shieldai/``@kordant/` across:
- `web/src/**/*.ts`
- `web/src/**/*.tsx`
- `browser-ext/**/*.ts`
- `browser-ext/**/*.tsx`
- `plans/*.md`
3. Verify no `@shieldai/` references remain in source code
tests:
- Build: `pnpm build` succeeds (after scope update)
- Grep: `grep -rn "@shieldai/" web/src/ browser-ext/src/` returns empty
acceptance_criteria:
- `pnpm --filter @kordant/browser-ext ...` commands resolve correctly
- All import statements across the monorepo use `@kordant/` scope
- No `@shieldai/` string remains in any package.json or source file
validation:
- Run `grep -rn "from \"@shieldai/" web/src/ browser-ext/src/` — expect zero
- Run `grep -rn "@shieldai/" package.json browser-ext/package.json` — expect zero

View File

@@ -0,0 +1,46 @@
# 04. Replace "ShieldAI" Display Text Across Web UI
meta:
id: rebrand-to-kordant-04
feature: rebrand-to-kordant
priority: P1
depends_on: [rebrand-to-kordant-03]
tags: [frontend, ui]
objective:
- Replace all user-visible "ShieldAI" branding text across web UI components with "Kordant".
deliverables:
- Page titles in all route files updated (app.tsx, index.tsx, dashboard, auth, blog, ads, 404)
- Navbar, Sidebar, Footer, AppShell brand text updated
- Landing page (HeroSection, WhyShieldAISection, CTABannerSection) updated
- Auth layout testimonial text and brand references updated
- Ads page copy updated
- Tests updated to reference "Kordant" instead of "ShieldAI"
steps:
1. Update all `<Title>` tags in route files — replace "ShieldAI" with "Kordant" in page title strings
2. Update Navbar.tsx — replace "ShieldAI" brand text
3. Update Sidebar.tsx — replace "ShieldAI" brand text
4. Update Footer.tsx — replace "ShieldAI" and "ShieldAI. All rights reserved."
5. Update AppShell.tsx — replace default title
6. Update HeroSection.tsx — replace "ShieldAI evens"
7. Update WhyShieldAISection — rename component, id, and text references
8. Update CTABannerSection — replace "trust ShieldAI"
9. Update landing index.ts export and imports in routes/index.tsx
10. Update AuthLayout.tsx — replace testimonial/quote brand text
11. Update onboarding.tsx — replace "Your ShieldAI account is ready..."
12. Update ads.tsx — replace all marketing copy
13. Update all test files that assert "ShieldAI" in rendered output
tests:
- Unit: All tests pass after text changes
- Visual: Web app renders "Kordant" in all branded locations
acceptance_criteria:
- No "ShieldAI" text appears in any web UI page title, nav, sidebar, footer, landing, auth, blog, or ads page
- Test assertions updated to expect "Kordant" instead of "ShieldAI"
validation:
- Run `grep -rn "ShieldAI" web/src/components/ web/src/routes/` — check only legitimate remaining references (comments, third-party)
- Run `pnpm --filter web test` — all tests pass

View File

@@ -0,0 +1,46 @@
# 05. Update Email Templates, Notification Content, and Queue Names
meta:
id: rebrand-to-kordant-05
feature: rebrand-to-kordant
priority: P1
depends_on: [rebrand-to-kordant-01]
tags: [backend, email]
objective:
- Replace all "ShieldAI" references in email templates, notification services, report generators, and BullMQ queue names.
deliverables:
- email.templates.ts — brand text, subjects, bodies, headers, footers updated
- email.templates.test.ts — test assertions updated
- notification.service.ts — from address updated to kordant.ai
- alert.publisher.ts — push notification title prefix updated
- reports.service.ts — report title updated
- reports/generator.ts — report text updated
- reports/templates/*.html — HTML report templates updated
- queue.ts — BullMQ queue name updated from "shieldai-jobs" to "kordant-jobs"
- waitlist-email-sequence-implementation.md — domain references updated
steps:
1. Edit email.templates.ts — replace all "ShieldAI" brand text in subjects, HTML, plain text bodies, footers, team signatures
2. Edit email.templates.ts — update from/noreply email to `noreply@kordant.ai`
3. Edit email.templates.test.ts — update test assertions to expect "Kordant"
4. Edit notification.service.ts — update `noreply@shieldai.app``noreply@kordant.ai`
5. Edit alert.publisher.ts — update `` `[ShieldAI] ${alert.title}` `` → `` `[Kordant] ${alert.title}` ``
6. Edit reports.service.ts — update report title from "ShieldAI" to "Kordant"
7. Edit reports/generator.ts — update all brand text references
8. Edit reports/templates/*.html — update title and footer in weekly-digest, monthly-plus, annual-premium
9. Edit queue.ts — update queue name "shieldai-jobs" → "kordant-jobs"
tests:
- Unit: Email template tests pass with new brand name
- Unit: Report generator tests pass
acceptance_criteria:
- No "ShieldAI" remains in any email template, notification, or report
- Email from address uses `@kordant.ai`
- BullMQ queue name uses "kordant-jobs"
validation:
- Run `grep -rn "ShieldAI" web/src/server/services/` — expect zero results
- Run `grep "shieldai-jobs" web/src/server/jobs/queue.ts` — expect zero

View File

@@ -0,0 +1,37 @@
# 06. Update Browser Storage Keys, Theme Keys, and User-Agent Headers
meta:
id: rebrand-to-kordant-06
feature: rebrand-to-kordant
priority: P2
depends_on: [rebrand-to-kordant-03]
tags: [frontend, config]
objective:
- Update all localStorage keys, theme storage keys, unread count keys, device name strings, and user-agent headers from ShieldAI to Kordant.
deliverables:
- web/src/lib/theme.tsx — STORAGE_KEY updated
- web/src/lib/theme.test.ts — assertions updated (11 occurrences)
- web/src/entry-server.tsx — theme key updated
- web/src/hooks/useRealtimeAlerts.ts — UNREAD_STORAGE_KEY updated
- web/src/server/api/routers/extension.ts — "ShieldAI Browser Extension" updated
- web/src/server/services/darkwatch/scan.engine.ts — user-agent header updated
steps:
1. Edit `web/src/lib/theme.tsx` — change `STORAGE_KEY = "shieldai-theme"` to `"kordant-theme"`
2. Edit `web/src/lib/theme.test.ts` — update all `"shieldai-theme"` assertions to `"kordant-theme"`
3. Edit `web/src/entry-server.tsx` — update `'shieldai-theme'` to `'kordant-theme'`
4. Edit `web/src/hooks/useRealtimeAlerts.ts` — update `UNREAD_STORAGE_KEY = "shieldai_unread_count"` to `"kordant_unread_count"`
5. Edit `web/src/server/api/routers/extension.ts` — update device name from `"ShieldAI Browser Extension"` to `"Kordant Browser Extension"`
6. Edit `web/src/server/services/darkwatch/scan.engine.ts` — update `"user-agent": "ShieldAI-DarkWatch"` to `"Kordant-DarkWatch"`
tests:
- Unit: Theme tests pass after key update
- Integration: DarkWatch scanner user-agent header updated
acceptance_criteria:
- No `shieldai-theme`, `shieldai_unread_count`, `ShieldAI Browser Extension`, or `ShieldAI-DarkWatch` remain in source
validation:
- Run `grep -rn "shieldai-theme\|shieldai_unread_count\|ShieldAI Browser Extension\|ShieldAI-DarkWatch" web/src/` — expect zero

View File

@@ -0,0 +1,32 @@
# 07. Update Seed Data, Blog Content, and Landing Page Copy
meta:
id: rebrand-to-kordant-07
feature: rebrand-to-kordant
priority: P2
depends_on: [rebrand-to-kordant-03]
tags: [frontend, content]
objective:
- Update seed data references, blog content, and remaining landing page copy from ShieldAI to Kordant.
deliverables:
- web/src/server/db/seed.ts — author name and description updated
- web/src/routes/blog.tsx — blog page title, post titles, copy updated
- web/src/routes/blog/[slug].tsx — blog post content, metadata, author updated
steps:
1. Edit `web/src/server/db/seed.ts` — update `authorName: "ShieldAI Team"` to `"Kordant Team"`, update description text
2. Edit `web/src/routes/blog.tsx` — update page title, blog descriptions, post slugs containing "shieldai", post titles
3. Edit `web/src/routes/blog/[slug].tsx` — update all brand references in blog post content, metadata, author role
tests:
- Unit: Seed data tests pass
- Unit: Blog page renders correctly with updated content
acceptance_criteria:
- No "ShieldAI" remains in seed data or blog content
- Blog post slugs no longer contain "shieldai"
validation:
- Run `grep -rn "ShieldAI" web/src/server/db/seed.ts web/src/routes/blog.tsx web/src/routes/blog/` — expect zero

View File

@@ -0,0 +1,47 @@
# 08. Update Browser Extension Branding
meta:
id: rebrand-to-kordant-08
feature: rebrand-to-kordant
priority: P1
depends_on: [rebrand-to-kordant-01, rebrand-to-kordant-03]
tags: [browser-ext, frontend]
objective:
- Update all ShieldAI branding in the browser extension: manifest, HTML pages, storage keys, log messages, and domain references.
deliverables:
- browser-ext/public/manifest.json — name and host permissions updated
- browser-ext/src/popup/popup.html — title and brand text updated
- browser-ext/src/options/options.html — title, heading, API URL placeholder updated
- browser-ext/src/options/options.ts — device name updated
- browser-ext/src/background/index.ts — device name, log prefixes, storage keys, error messages updated
- browser-ext/src/content/index.ts — log prefixes updated
- browser-ext/src/lib/settings.ts — STORAGE_KEY and API URL updated
- browser-ext/src/lib/phishing-detector.ts — phishing domain reference data updated
- browser-ext/tests/ — all test assertions updated
steps:
1. Edit `manifest.json` — update `"name": "ShieldAI"` to `"Kordant"`, update host permissions from `*.shieldai.com` to `*.kordant.ai`
2. Edit `popup.html` — update `<title>` and `<span>ShieldAI</span>`
3. Edit `options.html` — update title, heading, API URL placeholder
4. Edit `options.ts` — update device name
5. Edit `background/index.ts` — update device name, log prefixes, storage keys, error messages
6. Edit `content/index.ts` — update log prefixes
7. Edit `lib/settings.ts` — update STORAGE_KEY and base URL
8. Edit `lib/phishing-detector.ts` — update phishing domain examples from shieldai-* to kordant-*
9. Edit all test files — update assertions
tests:
- Unit: Extension tests pass
- Manual: Load unpacked extension in Chrome — verify "Kordant" displays correctly
acceptance_criteria:
- Extension manifest name shows "Kordant"
- All storage keys use `kordant:` prefix
- No `shieldai` / `ShieldAI` remains in extension source
- API domain uses `kordant.ai`
validation:
- Run `grep -rn "shieldai\|ShieldAI" browser-ext/` — expect zero
- Load extension in Chrome, verify name in toolbar

View File

@@ -0,0 +1,47 @@
# 09. Update iOS App Branding
meta:
id: rebrand-to-kordant-09
feature: rebrand-to-kordant
priority: P1
depends_on: [rebrand-to-kordant-01]
tags: [ios, mobile]
objective:
- Update all ShieldAI branding in the iOS app: Xcode project, Swift source files, bundle identifiers, display text, storage keys, and URL schemes.
deliverables:
- iOS/ShieldAI/ directory renamed to iOS/Kordant/
- Xcode project file (.pbxproj) — all ShieldAI references updated (bundle IDs, target names, product names, entitlements, URL schemes, info plist strings)
- ShieldAIApp.swift — app struct renamed
- ShieldAITheme.swift — theme struct renamed; ShieldAITheme.cornerRadius references in all View files updated
- AuthView, OnboardingView — display text updated
- BiometricAuthService — auth prompt messages updated
- CameraService — usage description strings updated
- APIClient, PushNotificationService, NetworkMonitor — subsystem strings updated
- Route.swift — URL scheme updated from "shieldai" to "kordant"
- ThemeManager — storage key updated
- OfflineQueue, CacheManager — storage keys updated
- ShieldAITests, ShieldAIUITests — all references updated
- Deep link URL strings in tests updated
steps:
1. Rename iOS directory from `iOS/ShieldAI` to `iOS/Kordant` (this will require updating all paths in pbxproj)
2. Edit pbxproj — find/replace all ShieldAI → Kordant (bundle IDs, target names, product names, paths, info plist strings, URL schemes)
3. Edit Swift source files — update ShieldAIApp, ShieldAITheme, display text, storage keys, URL schemes, subsystem strings, biometric prompts
4. Update test files — @testable import, class names, URL strings
tests:
- Build: iOS project opens in Xcode and builds successfully
- Unit: All iOS tests pass
acceptance_criteria:
- Xcode project loads without errors
- All build targets compile
- Bundle identifier uses `com.kordant.*`
- URL scheme is `kordant://`
- No "ShieldAI" remains in any Swift source file
validation:
- Run `grep -rn "ShieldAI\|shieldai" iOS/Kordant/ — expect zero
- Open project in Xcode, verify build succeeds

View File

@@ -0,0 +1,54 @@
# 10. Update Android App Branding
meta:
id: rebrand-to-kordant-10
feature: rebrand-to-kordant
priority: P1
depends_on: [rebrand-to-kordant-01]
tags: [android, mobile]
objective:
- Update all ShieldAI branding in the Android app: package names, Kotlin source files, resources, build config, and class names.
deliverables:
- android/ShieldAI/ directory renamed to android/Kordant/
- build.gradle.kts — namespace, applicationId, and API URLs updated
- settings.gradle.kts — rootProject.name updated
- All Kotlin source files — package declarations updated from `com.shieldai.android.*` to `com.kordant.*`
- ShieldAIApp.kt renamed to KordantApp.kt
- ShieldAIDatabase.kt renamed to KordantDatabase.kt — DATABASE_NAME updated
- All import statements across ~70+ Kotlin files updated
- AndroidManifest.xml — app class name, theme references updated
- strings.xml — app_name updated
- themes.xml — style name updated
- Storage key strings updated (shieldai_database, shieldai_auth_prefs, shieldai_biometric_prefs)
- UI display text strings updated (ShieldAI → Kordant)
- Theme.kt — ShieldAITheme fun renamed to KordantTheme
steps:
1. Rename directory from `android/ShieldAI` to `android/Kordant`
2. Edit build.gradle.kts — update namespace, applicationId, API URLs
3. Edit settings.gradle.kts — update rootProject.name
4. Update all Kotlin package declarations from `com.shieldai.android` to `com.kordant`
5. Update all import statements referencing `com.shieldai.android.*` to `com.kordant.*`
6. Rename ShieldAIApp.kt → KordantApp.kt (update class name)
7. Rename ShieldAIDatabase.kt → KordantDatabase.kt (update class name and DATABASE_NAME)
8. Update AndroidManifest.xml — android:name, theme, app_name
9. Update strings.xml, themes.xml
10. Update Theme.kt — fun ShieldAITheme → fun KordantTheme
11. Update all storage keys from shieldai_* to kordant_*
12. Update all UI display text (ComponentShowcase, AuthScreen, BiometricAuthScreen)
tests:
- Build: Android project builds successfully
- Unit: All Android tests pass
acceptance_criteria:
- Android project compiles without errors
- Application ID is `com.kordant.*`
- No "ShieldAI" or "shieldai" remains in any Kotlin source, XML resource, or build config
- Database name uses "kordant_database"
validation:
- Run `grep -rn "ShieldAI\|shieldai\|com\.shieldai" android/Kordant/` — expect zero
- Open project in Android Studio, verify build succeeds

View File

@@ -0,0 +1,39 @@
# 11. Update CI/CD and Infrastructure References
meta:
id: rebrand-to-kordant-11
feature: rebrand-to-kordant
priority: P1
depends_on: [rebrand-to-kordant-01]
tags: [infrastructure, ci-cd]
objective:
- Update all CI/CD workflow files, Docker image tags, Terraform state bucket names, ECS cluster names, and service names from ShieldAI to Kordant.
deliverables:
- .github/workflows/ci.yml — Docker tags, coverage artifact name, service names updated
- .github/workflows/deploy.yml — bucket name, image tags, cluster names, service names all updated
- scripts/setup-ga4.sh — display name, property name, domain references updated
- scripts/load-test/run-all.sh — test description and service references updated
steps:
1. Edit `.github/workflows/ci.yml` — update all `shieldai:` Docker tags to `kordant:`, update `name: shieldai-coverage` to `kordant-coverage`
2. Edit `.github/workflows/deploy.yml` — update:
- S3 bucket `shieldai-*` to `kordant-*`
- Docker image tags `shieldai-${{ matrix.name }}` to `kordant-${{ matrix.name }}`
- ECS cluster names `shieldai-${ENV}` to `kordant-${ENV}`
- Service name references
3. Edit `scripts/setup-ga4.sh` — update display name from "ShieldAI" to "Kordant", domain from `shieldai.com` to `kordant.ai`
4. Edit `scripts/load-test/run-all.sh` — update "ShieldAI" references
tests:
- None (CI/CD changes are validated on next pipeline run)
acceptance_criteria:
- No `shieldai-` prefix remains in any CI/CD workflow for Docker tags, cluster names, or bucket names
- GA4 script uses kordant.ai domain
- Load test script references Kordant
validation:
- Run `grep -rn "shieldai-\.*" .github/workflows/ scripts/` — expect zero
- Review updated deploy.yml for consistency

View File

@@ -0,0 +1,40 @@
# 12. Update SVG Ad Creatives and Marketing Assets
meta:
id: rebrand-to-kordant-12
feature: rebrand-to-kordant
priority: P3
depends_on: []
tags: [assets, marketing]
objective:
- Update all SVG ad creative files in the assets/ directory to replace "ShieldAI" branding with "Kordant".
deliverables:
- assets/ads/meta_c_1x1_1080x1080.svg — headline updated
- assets/ads/meta_b_45_1080x1350.svg — terminal prompt, body text, tagline updated
- assets/ads/meta_b_1x1_1080x1080.svg — terminal prompt, product name updated
- assets/ads/meta_a_1x1_1080x1080.svg — headline updated
- assets/ads/linkedin/variant1_professional.svg — body text, logo updated
- assets/ads/linkedin/variant2_datasecurity.svg — product name, logo updated
- assets/ads/linkedin/variant3_family_professional.svg — badge, logo updated
- assets/ads/gd_portrait_600x750.svg — tagline updated
steps:
1. For each SVG file, search for "ShieldAI" text nodes and replace with "Kordant"
2. For terminal prompts like `darkwatch@shieldai:~$`, update to `darkwatch@kordant:~$`
3. For taglines like "ShieldAI — AI-Powered Identity Protection", update to "Kordant — AI-Powered Identity Protection"
4. Verify SVG files remain valid after replacements
tests:
- Visual: Open SVGs in browser — verify text renders correctly with new name
- Structural: SVGs remain valid XML
acceptance_criteria:
- No "ShieldAI" text remains in any SVG asset file
- Terminal prompts use `@kordant` instead of `@shieldai`
- Taglines and headlines use "Kordant"
validation:
- Run `grep -rn "ShieldAI" assets/ads/` — expect zero
- Run `grep -rn "shieldai" assets/ads/` — expect zero (check for prompt variants)

View File

@@ -0,0 +1,39 @@
# 13. Update READMEs, Plan Documents, and Task References
meta:
id: rebrand-to-kordant-13
feature: rebrand-to-kordant
priority: P3
depends_on: [rebrand-to-kordant-01]
tags: [documentation]
objective:
- Update all README files, plan documents, and task directory references from ShieldAI to Kordant.
deliverables:
- README.md — title, description, package references, plan file links updated
- plans/SHIELDAI-product-plan.md — filename renamed to plans/KORDANT-product-plan.md, content updated
- plans/SHIELDAI-technical-architecture.md — filename renamed, content updated
- tasks/shieldai-unified-restructure/ — directory renamed to tasks/kordant-unified-restructure/, all task ID frontmatter updated
- tasks/clerk-integration/README.md — reference to "ShieldAI/web" updated
steps:
1. Rename plan files: `SHIELDAI-product-plan.md``KORDANT-product-plan.md`, `SHIELDAI-technical-architecture.md``KORDANT-technical-architecture.md`
2. Update README.md — replace "ShieldAI" with "Kordant" throughout, update plan file links
3. Rename task directory `tasks/shieldai-unified-restructure/``tasks/kordant-unified-restructure/`
4. Update all task file frontmatter in `tasks/kordant-unified-restructure/` — change `feature: shieldai-unified-restructure` and task IDs
5. Update `tasks/clerk-integration/README.md` — reference to ShieldAI/web
6. Update README filter package references: `@shieldai/spamshield``@kordant/spamshield`, etc.
tests:
- None (documentation only)
acceptance_criteria:
- README.md no longer mentions "ShieldAI"
- Plan filenames use Kordant prefix
- Task directory names use kordant prefix
- All internal task references updated
validation:
- Run `grep -rn "ShieldAI\|shieldai" README.md plans/` — expect zero
- Verify plan files compile/make sense with new name

View File

@@ -0,0 +1,63 @@
# 14. Final Sweep and Verification of Rebrand Completeness
meta:
id: rebrand-to-kordant-14
feature: rebrand-to-kordant
priority: P0
depends_on: [rebrand-to-kordant-13]
tags: [quality-assurance]
objective:
- Perform a comprehensive final sweep of the entire codebase to verify zero "ShieldAI" or "shieldai" references remain in source code (excluding git history and third-party dependencies), and that all builds and tests pass.
deliverables:
- Final verification report
- Any straggling references cleaned up
steps:
1. Run comprehensive grep searches across the repo for "ShieldAI" and "shieldai", filtering out:
- `.git/` directory
- `node_modules/`
- `pnpm-lock.yaml`, `bun.lock`
- Any false positives (e.g., in git history references)
2. For each remaining reference, determine if it needs updating
3. Run `pnpm build` — verify all packages build
4. Run `pnpm test` — verify all tests pass
5. Verify web app renders correctly with "Kordant" branding
6. Verify browser extension loads with new name
7. Review all changed files for consistency (punctuation, casing, sentence flow)
8. Run a second sweep to catch any missed references
tests:
- Build: `pnpm build` succeeds across all packages
- Unit: `pnpm test` passes across all packages
- Integration: Web app, extension, and mobile builds succeed
acceptance_criteria:
- Zero `ShieldAI` or `shieldai` references remain in source code
- All packages build successfully
- All tests pass
- Domain `kordant.ai` is correctly referenced throughout
validation:
```bash
# Final sweep commands
echo "=== ShieldAI (PascalCase) ==="
grep -rn "ShieldAI" --include="*.{ts,tsx,js,jsx,kt,swift,html,svg,yml,yaml,sh,env,toml,json}" . \
--exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.turbo \
| grep -v "pnpm-lock\|bun.lock\|\.next\|dist"
echo "=== shieldai (lowercase) ==="
grep -rn "shieldai" --include="*.{ts,tsx,js,jsx,kt,swift,html,svg,yml,yaml,sh,env,toml,json}" . \
--exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.turbo \
| grep -v "pnpm-lock\|bun.lock\|\.next\|dist"
echo "=== SHIELD (pattern check) ==="
grep -rn "SHIELD" --include="*.{ts,tsx,kt,swift}" . \
--exclude-dir=.git --exclude-dir=node_modules | grep -v "nodemon"
```
notes:
- The `.git` directory contains the old name in commit history — that's expected and should NOT be changed
- Third-party dependencies in `node_modules/` may reference "shieldai" in cached packages — ignore these
- The `tasks/` directory contains historical task files with "shieldai" IDs — these are internal planning docs and may optionally be skipped

View File

@@ -0,0 +1,42 @@
# Rebrand ShieldAI → Kordant
Objective: Complete rebrand of the ShieldAI platform to Kordant across all packages, apps, infrastructure, and assets.
Status legend: [ ] todo, [~] in-progress, [x] done
Tasks
- [x] 01 — Update monorepo foundation (root package, env, config) → `01-update-monorepo-foundation.md`
- [x] 02 — Update database connection strings and DB names → `02-update-database-connection-strings.md`
- [x] 03 — Update @shieldai/* package scopes and web imports → `03-update-web-package-scope-and-imports.md`
- [ ] 04 — Replace "ShieldAI" display text across web UI → `04-update-web-ui-brand-text.md`
- [ ] 05 — Update email templates, notification content, and queue names → `05-update-web-email-notification-templates.md`
- [ ] 06 — Update browser storage keys, theme keys, and user-agent headers → `06-update-web-storage-keys-and-constants.md`
- [ ] 07 — Update seed data, blog content, and landing page copy → `07-update-web-seed-data-and-blog-content.md`
- [ ] 08 — Update browser extension manifest, HTML, and storage keys → `08-update-browser-extension-branding.md`
- [ ] 09 — Update iOS bundle ID, Xcode project, and Swift source branding → `09-update-ios-app-branding.md`
- [ ] 10 — Update Android package names, Kotlin source, and resources → `10-update-android-app-branding.md`
- [ ] 11 — Update CI/CD workflows, Docker tags, and service names → `11-update-ci-cd-and-infrastructure.md`
- [ ] 12 — Update SVG ad creatives and marketing assets → `12-update-assets-and-ad-creatives.md`
- [ ] 13 — Update READMEs, plan documents, and task references → `13-update-documentation-and-plan-files.md`
- [ ] 14 — Final sweep and verification of rebrand completeness → `14-verify-rebrand-completeness.md`
Dependencies
- 01 → 03 (package scope must be updated first before web imports)
- 03 → 04 (web scope must resolve before changing text)
- 01 → 05 (env changes needed for email domain)
- 01 → 08 (root package affects extension)
- 01 → 11 (env vars feed CI/CD)
- 03 → 06 (package resolution needed)
- 01 → 09 (root package naming)
- 01 → 10 (root package naming)
- 13 → 14 (docs update before final sweep)
Exit criteria
- Zero occurrences of "ShieldAI" or "shieldai" remain in source code (excluding third-party dependencies and git history)
- All packages build successfully (`pnpm build`)
- All tests pass (`pnpm test`)
- iOS and Android projects open without errors in Xcode / Android Studio
- Browser extension loads successfully in Chrome
- Web app renders with "Kordant" branding throughout
- Domain `kordant.ai` is functional for all environments
- CI/CD pipelines reference kordant-* services and tags

View File

@@ -1,16 +0,0 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/shieldai"
## Clerk Authentication Configuration
# Get these from https://clerk.com
CLERK_SECRET_KEY=sk_test_your_clerk_secret_key
VITE_CLERK_PUBLISHABLE_KEY=pk_test_your_clerk_publishable_key
DATABASE_URL=libsql://your-database-url.turso.io
DATABASE_AUTH_TOKEN=your-turso-auth-token
# Stripe (get test keys from https://dashboard.stripe.com/test/apikeys)
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
STRIPE_PRICE_BASIC="price_basic"
STRIPE_PRICE_PLUS="price_plus"
STRIPE_PRICE_PREMIUM="price_premium"

View File

@@ -5,7 +5,7 @@ export default defineConfig({
out: "./drizzle",
dialect: "turso",
dbCredentials: {
url: process.env.DATABASE_URL ?? "libsql://shieldai-dev-mikefreno.aws-us-east-1.turso.io",
url: process.env.DATABASE_URL ?? "libsql://kordant-dev-mikefreno.aws-us-east-1.turso.io",
authToken: process.env.DATABASE_AUTH_TOKEN,
},
});

View File

@@ -4,7 +4,7 @@ import { drizzle } from "drizzle-orm/libsql";
import * as schema from "./schema";
const client = createClient({
url: process.env.DATABASE_URL ?? "libsql://shieldai-dev-mikefreno.aws-us-east-1.turso.io",
url: process.env.DATABASE_URL ?? "libsql://kordant-dev-mikefreno.aws-us-east-1.turso.io",
authToken: process.env.DATABASE_AUTH_TOKEN,
});

View File

@@ -134,7 +134,7 @@ function createRedisAdapter(): QueueAdapter {
maxRetriesPerRequest: null,
});
const queue = new BullMQ.Queue("shieldai-jobs", { connection });
const queue = new BullMQ.Queue("kordant-jobs", { connection });
let bullJobs = new Map<string, any>();
async function toJob(bullJob: any): Promise<Job> {

View File

@@ -48,7 +48,7 @@ export async function publishAlert(userId: string, alert: PublishableAlert): Pro
if (user?.email) {
await sendEmail(
user.email,
`[ShieldAI] ${alert.title}`,
`[Kordant] ${alert.title}`,
`<p>${alert.message}</p>`,
alert.message,
);

View File

@@ -13,7 +13,7 @@ describe("welcomeEmail", () => {
expect(result.subject).toContain("Welcome");
expect(result.html).toContain("Alice");
expect(result.text).toContain("Alice");
expect(result.html).toContain("ShieldAI");
expect(result.html).toContain("Kordant");
});
});
@@ -37,30 +37,30 @@ describe("alertNotificationEmail", () => {
describe("passwordResetEmail", () => {
it("includes the reset link", () => {
const result = passwordResetEmail("https://shieldai.app/reset/token123");
expect(result.html).toContain("https://shieldai.app/reset/token123");
expect(result.text).toContain("https://shieldai.app/reset/token123");
const result = passwordResetEmail("https://kordant.ai/reset/token123");
expect(result.html).toContain("https://kordant.ai/reset/token123");
expect(result.text).toContain("https://kordant.ai/reset/token123");
expect(result.subject).toContain("Reset");
});
});
describe("familyInviteEmail", () => {
it("includes inviter name and group name", () => {
const result = familyInviteEmail("Bob", "Smith Family", "https://shieldai.app/invite/abc");
const result = familyInviteEmail("Bob", "Smith Family", "https://kordant.ai/invite/abc");
expect(result.html).toContain("Bob");
expect(result.html).toContain("Smith Family");
expect(result.html).toContain("https://shieldai.app/invite/abc");
expect(result.html).toContain("https://kordant.ai/invite/abc");
expect(result.subject).toContain("Bob");
});
});
describe("billingReceiptEmail", () => {
it("includes payment details", () => {
const result = billingReceiptEmail("Premium Plan", "$19.99", "Mar 15, 2025", "https://shieldai.app/receipt/r1");
const result = billingReceiptEmail("Premium Plan", "$19.99", "Mar 15, 2025", "https://kordant.ai/receipt/r1");
expect(result.html).toContain("Premium Plan");
expect(result.html).toContain("$19.99");
expect(result.html).toContain("Mar 15, 2025");
expect(result.html).toContain("https://shieldai.app/receipt/r1");
expect(result.html).toContain("https://kordant.ai/receipt/r1");
expect(result.subject).toContain("Premium Plan");
});
});

View File

@@ -9,7 +9,7 @@ function brandedWrapper(title: string, body: string) {
<table width="480" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:8px;overflow:hidden">
<tr>
<td style="background-color:#1a1a2e;padding:24px;text-align:center">
<h1 style="color:#ffffff;margin:0;font-size:20px;font-weight:700">🛡️ ShieldAI</h1>
<h1 style="color:#ffffff;margin:0;font-size:20px;font-weight:700">🛡️ Kordant</h1>
<p style="color:#94a3b8;margin:4px 0 0;font-size:13px">Intelligent Protection</p>
</td>
</tr>
@@ -21,7 +21,7 @@ function brandedWrapper(title: string, body: string) {
</tr>
<tr>
<td style="background-color:#f8fafc;padding:16px 24px;text-align:center;border-top:1px solid #e2e8f0">
<p style="color:#64748b;margin:0;font-size:12px">ShieldAI &mdash; Your intelligent digital protection platform</p>
<p style="color:#64748b;margin:0;font-size:12px">Kordant &mdash; Your intelligent digital protection platform</p>
</td>
</tr>
</table>
@@ -33,7 +33,7 @@ function brandedWrapper(title: string, body: string) {
}
function brandedText(text: string) {
return `ShieldAI - Intelligent Protection\n\n${text}\n\n---\nShieldAI - Your intelligent digital protection platform`;
return `Kordant - Intelligent Protection\n\n${text}\n\n---\nKordant - Your intelligent digital protection platform`;
}
export interface EmailTemplate {
@@ -44,16 +44,16 @@ export interface EmailTemplate {
export function welcomeEmail(name: string): EmailTemplate {
return {
subject: "Welcome to ShieldAI",
subject: "Welcome to Kordant",
html: brandedWrapper(
"Welcome to ShieldAI!",
"Welcome to Kordant!",
`<p style="color:#334155;margin:0 0 12px;line-height:1.6">Hi ${name},</p>
<p style="color:#334155;margin:0 0 12px;line-height:1.6">Thank you for joining ShieldAI. We're here to help you monitor and protect your digital identity.</p>
<p style="color:#334155;margin:0 0 12px;line-height:1.6">Thank you for joining Kordant. We're here to help you monitor and protect your digital identity.</p>
<p style="color:#334155;margin:0 0 12px;line-height:1.6">Get started by adding your first watchlist item, and we'll alert you to any exposures or threats.</p>
<p style="color:#334155;margin:0;line-height:1.6">Stay safe,<br>The ShieldAI Team</p>`,
<p style="color:#334155;margin:0;line-height:1.6">Stay safe,<br>The Kordant Team</p>`,
),
text: brandedText(
`Hi ${name},\n\nThank you for joining ShieldAI. We're here to help you monitor and protect your digital identity.\n\nGet started by adding your first watchlist item, and we'll alert you to any exposures or threats.\n\nStay safe,\nThe ShieldAI Team`,
`Hi ${name},\n\nThank you for joining Kordant. We're here to help you monitor and protect your digital identity.\n\nGet started by adding your first watchlist item, and we'll alert you to any exposures or threats.\n\nStay safe,\nThe Kordant Team`,
),
};
}
@@ -68,22 +68,22 @@ export function alertNotificationEmail(
severity === "warning" ? "#d97706" : "#2563eb";
return {
subject: `[${severity.toUpperCase()}] ShieldAI Alert: ${alertTitle}`,
subject: `[${severity.toUpperCase()}] Kordant Alert: ${alertTitle}`,
html: brandedWrapper(
`Alert: ${alertTitle}`,
`<div style="display:inline-block;padding:4px 10px;border-radius:4px;font-size:12px;font-weight:600;text-transform:uppercase;color:#ffffff;background-color:${severityColor};margin-bottom:16px">${severity}</div>
<p style="color:#334155;margin:0 0 12px;line-height:1.6">${alertMessage}</p>
<p style="color:#64748b;margin:0;line-height:1.6;font-size:13px">Log in to ShieldAI for more details.</p>`,
<p style="color:#64748b;margin:0;line-height:1.6;font-size:13px">Log in to Kordant for more details.</p>`,
),
text: brandedText(
`[${severity.toUpperCase()}] Alert: ${alertTitle}\n\n${alertMessage}\n\nLog in to ShieldAI for more details.`,
`[${severity.toUpperCase()}] Alert: ${alertTitle}\n\n${alertMessage}\n\nLog in to Kordant for more details.`,
),
};
}
export function passwordResetEmail(resetLink: string): EmailTemplate {
return {
subject: "Reset your ShieldAI password",
subject: "Reset your Kordant password",
html: brandedWrapper(
"Password Reset",
`<p style="color:#334155;margin:0 0 12px;line-height:1.6">You requested a password reset. Click the button below to set a new password.</p>
@@ -108,10 +108,10 @@ export function familyInviteEmail(
acceptLink: string,
): EmailTemplate {
return {
subject: `${inviterName} invited you to ${groupName} on ShieldAI`,
subject: `${inviterName} invited you to ${groupName} on Kordant`,
html: brandedWrapper(
"Family Invitation",
`<p style="color:#334155;margin:0 0 12px;line-height:1.6"><strong>${inviterName}</strong> has invited you to join <strong>${groupName}</strong> on ShieldAI.</p>
`<p style="color:#334155;margin:0 0 12px;line-height:1.6"><strong>${inviterName}</strong> has invited you to join <strong>${groupName}</strong> on Kordant.</p>
<p style="color:#334155;margin:0 0 12px;line-height:1.6">As a family member, you'll get shared protection and alerts for your digital identity.</p>
<table cellpadding="0" cellspacing="0" style="margin:24px 0">
<tr>
@@ -122,7 +122,7 @@ export function familyInviteEmail(
</table>`,
),
text: brandedText(
`Family Invitation\n\n${inviterName} has invited you to join ${groupName} on ShieldAI.\n\nAs a family member, you'll get shared protection and alerts for your digital identity.\n\nAccept the invitation: ${acceptLink}`,
`Family Invitation\n\n${inviterName} has invited you to join ${groupName} on Kordant.\n\nAs a family member, you'll get shared protection and alerts for your digital identity.\n\nAccept the invitation: ${acceptLink}`,
),
};
}
@@ -134,7 +134,7 @@ export function billingReceiptEmail(
receiptUrl: string,
): EmailTemplate {
return {
subject: `ShieldAI receipt — ${planName} (${date})`,
subject: `Kordant receipt — ${planName} (${date})`,
html: brandedWrapper(
"Payment Receipt",
`<p style="color:#334155;margin:0 0 8px;line-height:1.6">Thank you for your payment.</p>

View File

@@ -43,7 +43,7 @@ describe("sendEmail", () => {
const result = await sendEmail("test@example.com", "Subject", "<p>Body</p>", "Text body");
expect(mockResendSend).toHaveBeenCalledWith({
from: "noreply@shieldai.app",
from: "noreply@kordant.ai",
to: "test@example.com",
subject: "Subject",
html: "<p>Body</p>",

View File

@@ -20,7 +20,7 @@ export async function sendEmail(
try {
const { data, error } = await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL ?? "noreply@shieldai.app",
from: process.env.RESEND_FROM_EMAIL ?? "noreply@kordant.ai",
to,
subject,
html,

View File

@@ -93,7 +93,7 @@ export async function generateReport(
const periodEnd = periodEndStr ? new Date(periodEndStr) : undefined;
const reportLabel = getReportTypeLabel(reportType);
const title = `ShieldAI ${reportLabel} Security Report`;
const title = `Kordant ${reportLabel} Security Report`;
const [report] = await db
.insert(securityReports)

View File

@@ -165,13 +165,13 @@ export async function compileData(
day: "numeric",
});
const title = `ShieldAI ${reportType === "WEEKLY_DIGEST" ? "Weekly" : reportType === "MONTHLY_PLUS" ? "Monthly" : "Annual"} Security Report`;
const title = `Kordant ${reportType === "WEEKLY_DIGEST" ? "Weekly" : reportType === "MONTHLY_PLUS" ? "Monthly" : "Annual"} Security Report`;
return {
title,
periodStart: ps.toLocaleDateString(),
periodEnd: pe.toLocaleDateString(),
summary: `During this period, ShieldAI detected ${alertCount} security alerts, ${exposureCount} data exposures, ${voiceAnalysisCount} voice analysis events, ${spamDetectionCount} spam detections, and ${propTotal.count} property changes.`,
summary: `During this period, Kordant detected ${alertCount} security alerts, ${exposureCount} data exposures, ${voiceAnalysisCount} voice analysis events, ${spamDetectionCount} spam detections, and ${propTotal.count} property changes.`,
threatScore,
threatLevel,
threatTrend,
@@ -206,7 +206,7 @@ function compileRecommendations(
}
if (voiceAnalysisCount > 0) {
items.push(
`<div class="recommendation">🟢 <strong>Voice Security:</strong> Monitor voice call activity regularly. ShieldAI flagged ${voiceAnalysisCount} analysis event(s) this period.</div>`,
`<div class="recommendation">🟢 <strong>Voice Security:</strong> Monitor voice call activity regularly. Kordant flagged ${voiceAnalysisCount} analysis event(s) this period.</div>`,
);
}
if (spamDetectionCount > 5) {
@@ -215,7 +215,7 @@ function compileRecommendations(
);
}
items.push(
`<div class="recommendation"> <strong>Stay Proactive:</strong> Regularly review your ShieldAI dashboard for real-time security updates and run DarkWatch scans weekly.</div>`,
`<div class="recommendation"> <strong>Stay Proactive:</strong> Regularly review your Kordant dashboard for real-time security updates and run DarkWatch scans weekly.</div>`,
);
return items.join("\n");