reduced nesting

This commit is contained in:
2026-05-25 23:08:11 -04:00
parent 3d246af3f7
commit 8ac2ce5273
157 changed files with 2618 additions and 666 deletions

1
android/app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,92 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.kordant.android"
compileSdk {
version = release(36) {
minorApiLevel = 1
}
}
defaultConfig {
applicationId = "com.kordant.android"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:3000\"")
buildConfigField("String", "API_STAGING_URL", "\"https://staging.api.kordant.com\"")
buildConfigField("String", "API_PRODUCTION_URL", "\"https://api.kordant.com\"")
}
buildTypes {
debug {
buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:3000\"")
}
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField("String", "API_BASE_URL", "\"https://api.kordant.com\"")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
buildConfig = true
}
lint {
baseline = file("lint-baseline.xml")
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
implementation("androidx.compose.material:material-icons-core")
implementation(libs.coil.compose)
implementation(libs.lottie.compose)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.biometric)
implementation(libs.okhttp)
implementation(libs.okhttp.logging.interceptor)
implementation(libs.gson)
implementation(libs.play.services.auth)
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization.converter)
implementation(libs.kotlinx.serialization.json)
implementation(libs.work.runtime.ktx)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.truth)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.work.testing)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}

View File

@@ -0,0 +1,521 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 9.1.1" type="baseline" client="gradle" dependencies="false" name="AGP (9.1.1)" variant="all" version="9.1.1">
<issue
id="RedundantLabel"
message="Redundant label can be removed"
errorLine1=" android:label=&quot;@string/app_name&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="20"
column="13"/>
</issue>
<issue
id="AndroidGradlePluginVersion"
message="A newer version of Gradle than 9.3.1 is available: 9.5.1"
errorLine1="distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/wrapper/gradle-wrapper.properties"
line="5"
column="17"/>
</issue>
<issue
id="AndroidGradlePluginVersion"
message="A newer version of com.android.application than 9.1.1 is available: 9.2.1"
errorLine1="agp = &quot;9.1.1&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="2"
column="7"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.core:core-ktx than 1.10.1 is available: 1.18.0"
errorLine1="coreKtx = &quot;1.10.1&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="3"
column="11"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.test.ext:junit than 1.1.5 is available: 1.3.0"
errorLine1="junitVersion = &quot;1.1.5&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="5"
column="16"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.test.espresso:espresso-core than 3.5.1 is available: 3.7.0"
errorLine1="espressoCore = &quot;3.5.1&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="6"
column="16"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.lifecycle:lifecycle-runtime-ktx than 2.6.1 is available: 2.10.0"
errorLine1="lifecycleRuntimeKtx = &quot;2.6.1&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="7"
column="23"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.activity:activity-compose than 1.8.0 is available: 1.13.0"
errorLine1="activityCompose = &quot;1.8.0&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="8"
column="19"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.navigation:navigation-compose than 2.7.7 is available: 2.9.8"
errorLine1="navigationCompose = &quot;2.7.7&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="9"
column="21"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.compose:compose-bom than 2025.12.00 is available: 2026.05.01"
errorLine1="composeBom = &quot;2025.12.00&quot;"
errorLine2=" ~~~~~~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="11"
column="14"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.security:security-crypto than 1.1.0-alpha06 is available: 1.1.0"
errorLine1="securityCrypto = &quot;1.1.0-alpha06&quot;"
errorLine2=" ~~~~~~~~~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="13"
column="18"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of com.google.android.gms:play-services-auth than 21.0.0 is available: 21.5.1"
errorLine1="playServicesAuth = &quot;21.0.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="15"
column="20"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.work:work-runtime-ktx than 2.9.1 is available: 2.11.2"
errorLine1="work = &quot;2.9.1&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="23"
column="8"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.work:work-testing than 2.9.1 is available: 2.11.2"
errorLine1="work = &quot;2.9.1&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="23"
column="8"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlin.plugin.compose than 2.2.10 is available: 2.3.21"
errorLine1="kotlin = &quot;2.2.10&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="10"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlin.plugin.serialization than 2.2.10 is available: 2.3.21"
errorLine1="kotlin = &quot;2.2.10&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="10"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.squareup.okhttp3:logging-interceptor than 4.12.0 is available: 5.3.2"
errorLine1="okhttp = &quot;4.12.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="16"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.squareup.okhttp3:okhttp than 4.12.0 is available: 5.3.2"
errorLine1="okhttp = &quot;4.12.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="16"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.google.code.gson:gson than 2.10.1 is available: 2.14.0"
errorLine1="gson = &quot;2.10.1&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="17"
column="8"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.airbnb.android:lottie-compose than 6.4.0 is available: 6.7.1"
errorLine1="lottieCompose = &quot;6.4.0&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="18"
column="17"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlinx:kotlinx-coroutines-test than 1.7.3 is available: 1.11.0"
errorLine1="coroutinesTest = &quot;1.7.3&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="19"
column="18"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.squareup.retrofit2:retrofit than 2.11.0 is available: 3.0.0"
errorLine1="retrofit = &quot;2.11.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="20"
column="12"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlinx:kotlinx-serialization-json than 1.7.3 is available: 1.11.0"
errorLine1="kotlinxSerializationJson = &quot;1.7.3&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="22"
column="28"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.google.truth:truth than 1.4.4 is available: 1.4.5"
errorLine1="truth = &quot;1.4.4&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="24"
column="9"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.squareup.okhttp3:mockwebserver than 4.12.0 is available: 5.3.2"
errorLine1="mockwebserver = &quot;4.12.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/Kordant/android/Kordant/gradle/libs.versions.toml"
line="25"
column="17"/>
</issue>
<issue
id="LocalContextGetResourceValueCall"
message="Querying resource values using LocalContext.current"
errorLine1=" .requestIdToken(context.getString(com.kordant.android.R.string.default_web_client_id))"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/kordant/android/ui/screens/auth/LoginScreen.kt"
line="56"
column="29"/>
</issue>
<issue
id="StaticFieldLeak"
message="Do not place Android context classes in static fields (static reference to `UserRepository` which has field `context` pointing to `Context`); this is a memory leak"
errorLine1=" private var userRepository: UserRepository? = null"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/kordant/android/di/RepositoryModule.kt"
line="11"
column="5"/>
</issue>
<issue
id="StaticFieldLeak"
message="Do not place Android context classes in static fields (static reference to `DarkWatchRepository` which has field `context` pointing to `Context`); this is a memory leak"
errorLine1=" private var darkWatchRepository: DarkWatchRepository? = null"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/kordant/android/di/RepositoryModule.kt"
line="12"
column="5"/>
</issue>
<issue
id="StaticFieldLeak"
message="Do not place Android context classes in static fields (static reference to `VoicePrintRepository` which has field `context` pointing to `Context`); this is a memory leak"
errorLine1=" private var voicePrintRepository: VoicePrintRepository? = null"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/kordant/android/di/RepositoryModule.kt"
line="13"
column="5"/>
</issue>
<issue
id="StaticFieldLeak"
message="Do not place Android context classes in static fields (static reference to `AlertRepository` which has field `context` pointing to `Context`); this is a memory leak"
errorLine1=" private var alertRepository: AlertRepository? = null"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/kordant/android/di/RepositoryModule.kt"
line="14"
column="5"/>
</issue>
<issue
id="StaticFieldLeak"
message="Do not place Android context classes in static fields (static reference to `SubscriptionRepository` which has field `context` pointing to `Context`); this is a memory leak"
errorLine1=" private var subscriptionRepository: SubscriptionRepository? = null"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/kordant/android/di/RepositoryModule.kt"
line="15"
column="5"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.brand_primary` appears to be unused"
errorLine1=" &lt;color name=&quot;brand_primary&quot;>#FF4F46E5&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="3"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.brand_primary_light` appears to be unused"
errorLine1=" &lt;color name=&quot;brand_primary_light&quot;>#FF818CF8&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="4"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.brand_accent` appears to be unused"
errorLine1=" &lt;color name=&quot;brand_accent&quot;>#FF06B6D4&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="5"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.bg_primary` appears to be unused"
errorLine1=" &lt;color name=&quot;bg_primary&quot;>#FFFFFFFF&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="6"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.bg_primary_dark` appears to be unused"
errorLine1=" &lt;color name=&quot;bg_primary_dark&quot;>#FF0F172A&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="7"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.text_primary` appears to be unused"
errorLine1=" &lt;color name=&quot;text_primary&quot;>#FF0F172A&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="8"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.text_primary_dark` appears to be unused"
errorLine1=" &lt;color name=&quot;text_primary_dark&quot;>#FFF1F5F9&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="9"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.success` appears to be unused"
errorLine1=" &lt;color name=&quot;success&quot;>#FF22C55E&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="10"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.warning` appears to be unused"
errorLine1=" &lt;color name=&quot;warning&quot;>#FFF59E0B&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="11"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.error` appears to be unused"
errorLine1=" &lt;color name=&quot;error&quot;>#FFEF4444&lt;/color>"
errorLine2=" ~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="12"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.info` appears to be unused"
errorLine1=" &lt;color name=&quot;info&quot;>#FF3B82F6&lt;/color>"
errorLine2=" ~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="13"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.ic_home` appears to be unused"
errorLine1="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/ic_home.xml"
line="1"
column="1"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" securePrefs.edit()"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/kordant/android/data/repository/AuthRepository.kt"
line="144"
column="9"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" securePrefs.edit()"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/kordant/android/data/repository/AuthRepository.kt"
line="155"
column="9"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" prefs.edit().putBoolean(&quot;biometric_enabled&quot;, enabled).apply()"
errorLine2=" ~~~~~~~~~~~~">
<location
file="src/main/java/com/kordant/android/ui/screens/auth/BiometricAuthScreen.kt"
line="88"
column="5"/>
</issue>
<issue
id="UseTomlInstead"
message="Use version catalog instead"
errorLine1=" implementation(&quot;androidx.compose.material:material-icons-core&quot;)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="build.gradle.kts"
line="66"
column="20"/>
</issue>
</issues>

21
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,220 @@
package com.kordant.android
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import com.kordant.android.ui.components.BadgeVariant
import com.kordant.android.ui.components.ComponentShowcase
import com.kordant.android.ui.components.InputType
import com.kordant.android.ui.components.ShieldBadge
import com.kordant.android.ui.components.ShieldButton
import com.kordant.android.ui.components.ShieldButtonSize
import com.kordant.android.ui.components.ShieldButtonVariant
import com.kordant.android.ui.components.ShieldTextField
import com.kordant.android.ui.theme.KordantTheme
import org.junit.Rule
import org.junit.Test
class ComponentTests {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun shieldButton_rendersWithText() {
composeTestRule.setContent {
KordantTheme {
ShieldButton(text = "Click Me", onClick = {})
}
}
composeTestRule.onNodeWithText("Click Me").assertIsDisplayed()
}
@Test
fun shieldButton_clickHandlerFires() {
var clicked = false
composeTestRule.setContent {
KordantTheme {
ShieldButton(text = "Click Me", onClick = { clicked = true })
}
}
composeTestRule.onNodeWithText("Click Me").performClick()
assert(clicked) { "Button click handler was not invoked" }
}
@Test
fun shieldButton_disabledDoesNotFireClick() {
var clicked = false
composeTestRule.setContent {
KordantTheme {
ShieldButton(text = "Click Me", onClick = { clicked = true }, enabled = false)
}
}
composeTestRule.onNodeWithText("Click Me").performClick()
assert(!clicked) { "Disabled button should not fire click handler" }
}
@Test
fun shieldButton_showsLoadingIndicator() {
composeTestRule.setContent {
KordantTheme {
ShieldButton(text = "Saving", onClick = {}, loading = true)
}
}
composeTestRule.onNodeWithTag("CircularProgressIndicator").assertExists()
}
@Test
fun shieldButton_variantsRender() {
composeTestRule.setContent {
KordantTheme {
ShieldButton(text = "Primary", onClick = {}, variant = ShieldButtonVariant.Primary)
ShieldButton(text = "Secondary", onClick = {}, variant = ShieldButtonVariant.Secondary)
ShieldButton(text = "Ghost", onClick = {}, variant = ShieldButtonVariant.Ghost)
ShieldButton(text = "Danger", onClick = {}, variant = ShieldButtonVariant.Danger)
}
}
composeTestRule.onNodeWithText("Primary").assertIsDisplayed()
composeTestRule.onNodeWithText("Secondary").assertIsDisplayed()
composeTestRule.onNodeWithText("Ghost").assertIsDisplayed()
composeTestRule.onNodeWithText("Danger").assertIsDisplayed()
}
@Test
fun shieldButton_sizesRender() {
composeTestRule.setContent {
KordantTheme {
ShieldButton(text = "Small", onClick = {}, size = ShieldButtonSize.Small)
ShieldButton(text = "Medium", onClick = {}, size = ShieldButtonSize.Medium)
ShieldButton(text = "Large", onClick = {}, size = ShieldButtonSize.Large)
}
}
composeTestRule.onNodeWithText("Small").assertIsDisplayed()
composeTestRule.onNodeWithText("Medium").assertIsDisplayed()
composeTestRule.onNodeWithText("Large").assertIsDisplayed()
}
@Test
fun shieldButton_fullWidthRenders() {
composeTestRule.setContent {
KordantTheme {
ShieldButton(text = "Full Width", onClick = {}, fullWidth = true, modifier = Modifier.fillMaxWidth())
}
}
composeTestRule.onNodeWithText("Full Width").assertIsDisplayed()
}
@Test
fun shieldTextField_rendersWithLabel() {
composeTestRule.setContent {
KordantTheme {
ShieldTextField(value = "", onValueChange = {}, label = "Email")
}
}
composeTestRule.onNodeWithText("Email").assertIsDisplayed()
}
@Test
fun shieldTextField_showsErrorState() {
composeTestRule.setContent {
KordantTheme {
ShieldTextField(
value = "bad",
onValueChange = {},
label = "Input",
isError = true,
errorMessage = "Invalid input"
)
}
}
composeTestRule.onNodeWithText("Invalid input").assertIsDisplayed()
}
@Test
fun shieldTextField_helperTextDisplayed() {
composeTestRule.setContent {
KordantTheme {
ShieldTextField(
value = "",
onValueChange = {},
label = "Input",
helperText = "Enter your name"
)
}
}
composeTestRule.onNodeWithText("Enter your name").assertIsDisplayed()
}
@Test
fun shieldTextField_passwordToggleExists() {
composeTestRule.setContent {
KordantTheme {
ShieldTextField(
value = "",
onValueChange = {},
label = "Password",
inputType = InputType.Password
)
}
}
composeTestRule.onNodeWithText("Show").assertIsDisplayed()
}
@Test
fun shieldBadge_variantsRender() {
composeTestRule.setContent {
KordantTheme {
ShieldBadge(text = "Success", variant = BadgeVariant.Success)
ShieldBadge(text = "Error", variant = BadgeVariant.Error)
ShieldBadge(text = "Warning", variant = BadgeVariant.Warning)
ShieldBadge(text = "Info", variant = BadgeVariant.Info)
ShieldBadge(text = "Default", variant = BadgeVariant.Default)
}
}
composeTestRule.onNodeWithText("Success").assertIsDisplayed()
composeTestRule.onNodeWithText("Error").assertIsDisplayed()
composeTestRule.onNodeWithText("Warning").assertIsDisplayed()
composeTestRule.onNodeWithText("Info").assertIsDisplayed()
composeTestRule.onNodeWithText("Default").assertIsDisplayed()
}
@Test
fun shieldTextField_acceptsInput() {
composeTestRule.setContent {
KordantTheme {
ShieldTextField(
value = "",
onValueChange = {},
label = "Name"
)
}
}
composeTestRule.onNodeWithText("Name").assertIsDisplayed()
}
@Test
fun componentShowcase_renders() {
composeTestRule.setContent {
KordantTheme {
ComponentShowcase()
}
}
composeTestRule.onNodeWithText("Kordant Design System").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldButton").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldCard").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldBadge").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldAvatar").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldProgressBar").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldEmptyState").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldSkeleton").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldToast").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldModal").assertIsDisplayed()
}
}

View File

@@ -0,0 +1,16 @@
package com.kordant.android
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.kordant.android", appContext.packageName)
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".KordantApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Kordant">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Kordant">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,20 @@
package com.shieldai.android
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.shieldai.android.navigation.AppNavigation
import com.shieldai.android.ui.theme.KordantTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
KordantTheme {
AppNavigation()
}
}
}
}

View File

@@ -0,0 +1,21 @@
package com.shieldai.android
import android.app.Application
import com.shieldai.android.data.repository.AuthRepository
import com.shieldai.android.data.repository.AuthRepositoryImpl
class KordantApp : Application() {
lateinit var authRepository: AuthRepository
private set
override fun onCreate() {
super.onCreate()
instance = this
authRepository = AuthRepositoryImpl(this)
}
companion object {
lateinit var instance: KordantApp
private set
}
}

View File

@@ -0,0 +1,77 @@
package com.shieldai.android.data.local
import android.content.Context
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
@Serializable
data class CacheEntry<T>(
val data: T,
val cachedAt: Long = System.currentTimeMillis(),
val ttlMs: Long = CacheManager.DEFAULT_TTL_MS,
) {
fun isExpired(): Boolean = System.currentTimeMillis() - cachedAt > ttlMs
}
object CacheManager {
const val DEFAULT_TTL_MS = 5 * 60 * 1000L
private val ttlOverrides = mutableMapOf<String, Long>()
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
fun setTtl(tableName: String, ttlMs: Long) {
ttlOverrides[tableName] = ttlMs
}
fun getTtl(tableName: String): Long = ttlOverrides[tableName] ?: DEFAULT_TTL_MS
fun <T> save(context: Context, key: String, data: T) {
val entry = CacheEntry(
data = data,
cachedAt = System.currentTimeMillis(),
ttlMs = getTtl(key),
)
val file = File(context.cacheDir, "$key.cache")
file.writeText(json.encodeToString(entry))
}
@Suppress("UNCHECKED_CAST")
fun <T> load(context: Context, key: String): T? {
val file = File(context.cacheDir, "$key.cache")
if (!file.exists()) return null
return try {
val text = file.readText()
val entry = json.decodeFromString<CacheEntry<Map<String, Any>>>(text)
if (entry.isExpired()) {
file.delete()
null
} else {
json.decodeFromString<CacheEntry<T>>(text).data
}
} catch (_: Exception) {
file.delete()
null
}
}
fun clear(context: Context, key: String) {
File(context.cacheDir, "$key.cache").delete()
}
fun clearAll(context: Context) {
context.cacheDir.listFiles { _, name -> name.endsWith(".cache") }?.forEach { it.delete() }
}
fun isExpired(cachedAt: Long, tableName: String): Boolean {
val ttl = getTtl(tableName)
return System.currentTimeMillis() - cachedAt > ttl
}
fun isFresh(cachedAt: Long, tableName: String): Boolean = !isExpired(cachedAt, tableName)
fun clearOverrides() = ttlOverrides.clear()
}

View File

@@ -0,0 +1,17 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Alert(
val id: String,
val type: String,
val title: String,
val message: String,
val severity: String,
val read: Boolean = false,
val date: String? = null,
@SerialName("action_url") val actionUrl: String? = null,
@SerialName("created_at") val createdAt: String? = null,
)

View File

@@ -0,0 +1,17 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class BrokerListing(
val id: String,
@SerialName("broker_name") val brokerName: String,
@SerialName("property_address") val propertyAddress: String? = null,
val url: String? = null,
val status: String = "active",
@SerialName("date_found") val dateFound: String? = null,
@SerialName("removal_request_id") val removalRequestId: String? = null,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
)

View File

@@ -0,0 +1,18 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Exposure(
val id: String,
val type: String,
val source: String,
val severity: String,
val details: String? = null,
val date: String? = null,
@SerialName("watchlist_item_id") val watchlistItemId: String? = null,
val resolved: Boolean = false,
@SerialName("resolved_at") val resolvedAt: String? = null,
@SerialName("created_at") val createdAt: String? = null,
)

View File

@@ -0,0 +1,17 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Property(
val id: String,
val address: String,
val type: String,
@SerialName("owner_name") val ownerName: String? = null,
val county: String? = null,
@SerialName("document_id") val documentId: String? = null,
val status: String = "monitored",
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
)

View File

@@ -0,0 +1,16 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class RemovalRequest(
val id: String,
@SerialName("listing_id") val listingId: String,
val status: String,
@SerialName("submitted_date") val submittedDate: String? = null,
@SerialName("resolved_date") val resolvedDate: String? = null,
val notes: String? = null,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
)

View File

@@ -0,0 +1,16 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SpamRule(
val id: String,
val pattern: String,
val action: String,
val enabled: Boolean = true,
val description: String? = null,
val priority: Int = 0,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
)

View File

@@ -0,0 +1,17 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Subscription(
val id: String,
val plan: String,
val status: String,
@SerialName("start_date") val startDate: String? = null,
@SerialName("end_date") val endDate: String? = null,
val features: List<String> = emptyList(),
@SerialName("auto_renew") val autoRenew: Boolean = true,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
)

View File

@@ -0,0 +1,19 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class User(
val id: String,
val name: String,
val email: String,
val phone: String? = null,
@SerialName("avatar_url") val avatarUrl: String? = null,
@SerialName("subscription_tier") val subscriptionTier: String? = null,
@SerialName("email_verified") val emailVerified: Boolean = false,
@SerialName("phone_verified") val phoneVerified: Boolean = false,
@SerialName("is_new_user") val isNewUser: Boolean = false,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
)

View File

@@ -0,0 +1,14 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class VoiceAnalysis(
val id: String,
@SerialName("enrollment_id") val enrollmentId: String,
val confidence: Double = 0.0,
val result: String? = null,
val status: String = "pending",
@SerialName("created_at") val createdAt: String? = null,
)

View File

@@ -0,0 +1,14 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class VoiceEnrollment(
val id: String,
val name: String,
@SerialName("sample_count") val sampleCount: Int = 0,
val status: String = "pending",
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
)

View File

@@ -0,0 +1,16 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class WatchlistItem(
val id: String,
val type: String,
val value: String,
val label: String? = null,
val status: String = "active",
@SerialName("date_added") val dateAdded: String? = null,
@SerialName("last_checked") val lastChecked: String? = null,
@SerialName("alerts_enabled") val alertsEnabled: Boolean = true,
)

View File

@@ -0,0 +1,31 @@
package com.shieldai.android.data.remote
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import okhttp3.Interceptor
import okhttp3.Response
class AuthInterceptor(context: Context) : Interceptor {
private val securePrefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
"shieldai_auth_prefs",
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
override fun intercept(chain: Interceptor.Chain): Response {
val token = securePrefs.getString("access_token", null)
val request = if (token != null) {
chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
return chain.proceed(request)
}
}

View File

@@ -0,0 +1,63 @@
package com.shieldai.android.data.remote
import kotlinx.coroutines.delay
import kotlin.math.min
import kotlin.math.pow
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val message: String, val code: Int = -1) : ApiResult<Nothing>()
}
object ErrorHandler {
private const val MAX_RETRIES = 3
private const val BASE_DELAY_MS = 1000L
private const val MAX_DELAY_MS = 10000L
suspend fun <T> executeWithRetry(
maxRetries: Int = MAX_RETRIES,
block: suspend () -> T,
): ApiResult<T> {
var lastError: Exception? = null
for (attempt in 0..maxRetries) {
try {
val result = block()
return ApiResult.Success(result)
} catch (e: Exception) {
lastError = e
if (attempt < maxRetries && shouldRetry(e)) {
val delayMs = calculateBackoff(attempt)
delay(delayMs)
}
}
}
return ApiResult.Error(lastError?.message ?: "Unknown error")
}
private fun shouldRetry(e: Exception): Boolean {
return when {
e is java.net.SocketTimeoutException -> true
e is java.net.ConnectException -> true
e is java.net.UnknownHostException -> true
e is java.io.IOException -> true
e.message?.contains("503") == true -> true
e.message?.contains("429") == true -> true
else -> false
}
}
private fun calculateBackoff(attempt: Int): Long {
val exponential = BASE_DELAY_MS * 2.0.pow(attempt.toDouble())
return min(exponential.toLong(), MAX_DELAY_MS)
}
fun parseError(throwable: Throwable): String {
return when (throwable) {
is java.net.UnknownHostException -> "No internet connection"
is java.net.SocketTimeoutException -> "Request timed out"
is java.net.ConnectException -> "Connection refused"
is java.io.IOException -> "Network error: ${throwable.message}"
else -> throwable.message ?: "Unknown error"
}
}
}

View File

@@ -0,0 +1,78 @@
package com.shieldai.android.data.remote
import com.shieldai.android.data.model.Alert
import com.shieldai.android.data.model.BrokerListing
import com.shieldai.android.data.model.Exposure
import com.shieldai.android.data.model.Property
import com.shieldai.android.data.model.RemovalRequest
import com.shieldai.android.data.model.SpamRule
import com.shieldai.android.data.model.Subscription
import com.shieldai.android.data.model.User
import com.shieldai.android.data.model.VoiceAnalysis
import com.shieldai.android.data.model.VoiceEnrollment
import com.shieldai.android.data.model.WatchlistItem
import kotlinx.serialization.json.JsonObject
import retrofit2.http.Body
import retrofit2.http.POST
interface TRPCApiService {
@POST("api/trpc/user.me")
suspend fun userMe(@Body body: JsonObject): TRPCResponse<User>
@POST("api/trpc/user.updateProfile")
suspend fun userUpdateProfile(@Body body: JsonObject): TRPCResponse<User>
@POST("api/trpc/subscription.get")
suspend fun subscriptionGet(@Body body: JsonObject): TRPCResponse<Subscription>
@POST("api/trpc/subscription.update")
suspend fun subscriptionUpdate(@Body body: JsonObject): TRPCResponse<Subscription>
@POST("api/trpc/darkwatch.getWatchlist")
suspend fun darkWatchGetWatchlist(@Body body: JsonObject): TRPCResponse<List<WatchlistItem>>
@POST("api/trpc/darkwatch.addWatchlistItem")
suspend fun darkWatchAddWatchlistItem(@Body body: JsonObject): TRPCResponse<WatchlistItem>
@POST("api/trpc/darkwatch.removeWatchlistItem")
suspend fun darkWatchRemoveWatchlistItem(@Body body: JsonObject): TRPCResponse<Unit>
@POST("api/trpc/darkwatch.getExposures")
suspend fun darkWatchGetExposures(@Body body: JsonObject): TRPCResponse<List<Exposure>>
@POST("api/trpc/alerts.list")
suspend fun alertsList(@Body body: JsonObject): TRPCResponse<List<Alert>>
@POST("api/trpc/alerts.markRead")
suspend fun alertsMarkRead(@Body body: JsonObject): TRPCResponse<Alert>
@POST("api/trpc/voice.enrollments")
suspend fun voiceEnrollments(@Body body: JsonObject): TRPCResponse<List<VoiceEnrollment>>
@POST("api/trpc/voice.createEnrollment")
suspend fun voiceCreateEnrollment(@Body body: JsonObject): TRPCResponse<VoiceEnrollment>
@POST("api/trpc/voice.analyze")
suspend fun voiceAnalyze(@Body body: JsonObject): TRPCResponse<VoiceAnalysis>
@POST("api/trpc/spam.listRules")
suspend fun spamListRules(@Body body: JsonObject): TRPCResponse<List<SpamRule>>
@POST("api/trpc/spam.createRule")
suspend fun spamCreateRule(@Body body: JsonObject): TRPCResponse<SpamRule>
@POST("api/trpc/property.list")
suspend fun propertyList(@Body body: JsonObject): TRPCResponse<List<Property>>
@POST("api/trpc/property.add")
suspend fun propertyAdd(@Body body: JsonObject): TRPCResponse<Property>
@POST("api/trpc/removal.list")
suspend fun removalList(@Body body: JsonObject): TRPCResponse<List<RemovalRequest>>
@POST("api/trpc/removal.create")
suspend fun removalCreate(@Body body: JsonObject): TRPCResponse<RemovalRequest>
@POST("api/trpc/broker.listListings")
suspend fun brokerListListings(@Body body: JsonObject): TRPCResponse<List<BrokerListing>>
}

View File

@@ -0,0 +1,47 @@
package com.shieldai.android.data.remote
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
@Serializable
data class TRPCResponse<T>(
val result: TRPCResult<T>,
)
@Serializable
data class TRPCResult<T>(
val data: T,
)
data class TRPCErrorResponse(
val error: TRPCError,
)
data class TRPCError(
val message: String,
val code: Int = -1,
) {
companion object {
fun fromJson(json: JsonObject): TRPCError {
val errorObj = json["error"]?.jsonObject
val message = errorObj?.get("message")?.jsonPrimitive?.content ?: "Unknown error"
val code = errorObj?.get("code")?.jsonPrimitive?.content?.toIntOrNull() ?: -1
return TRPCError(message = message, code = code)
}
}
}
object TRPCRequest {
fun body(json: JsonObject): JsonObject {
return buildJsonObject {
put("0", buildJsonObject {
put("json", json)
})
}
}
}

View File

@@ -0,0 +1,47 @@
package com.shieldai.android.data.repository
import android.content.Context
import com.shieldai.android.data.local.CacheManager
import com.shieldai.android.data.model.Alert
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 AlertRepository(
private val api: TRPCApiService,
private val context: Context,
) {
private val _alerts = MutableStateFlow<List<Alert>>(emptyList())
suspend fun getAlerts(): ApiResult<List<Alert>> {
val cached: List<Alert>? = CacheManager.load(context, "alerts")
if (cached != null) {
_alerts.value = cached
return ApiResult.Success(cached)
}
return ErrorHandler.executeWithRetry {
val response = api.alertsList(TRPCRequest.body(buildJsonObject {}))
val alerts = response.result.data
CacheManager.save(context, "alerts", alerts)
_alerts.value = alerts
alerts
}
}
suspend fun markRead(id: String): ApiResult<Alert> {
return ErrorHandler.executeWithRetry {
val body = buildJsonObject { put("id", id) }
val response = api.alertsMarkRead(TRPCRequest.body(body))
val alert = response.result.data
_alerts.value = _alerts.value.map { if (it.id == id) alert else it }
alert
}
}
fun observeAlerts(): Flow<List<Alert>> = _alerts
}

View File

@@ -0,0 +1,162 @@
package com.shieldai.android.data.repository
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.concurrent.TimeUnit
data class AuthToken(
val accessToken: String,
val refreshToken: String? = null
)
data class User(
val id: String,
val name: String,
val email: String,
val isNewUser: Boolean = false
)
interface AuthRepository {
suspend fun login(email: String, password: String): Result<User>
suspend fun signup(name: String, email: String, password: String): Result<User>
suspend fun forgotPassword(email: String): Result<Unit>
suspend fun resetPassword(email: String, code: String, password: String): Result<Unit>
suspend fun signInWithGoogle(idToken: String): Result<User>
fun saveToken(accessToken: String, refreshToken: String?)
fun getAccessToken(): String?
fun getRefreshToken(): String?
fun clearTokens()
fun isLoggedIn(): Boolean
}
class AuthRepositoryImpl(
context: Context,
private val baseUrl: String = "https://api.shieldai.com"
) : AuthRepository {
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val securePrefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
"shieldai_auth_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
private fun post(path: String, body: Map<String, String>): JSONObject {
val jsonBody = JSONObject(body).toString()
val request = Request.Builder()
.url("$baseUrl$path")
.post(jsonBody.toRequestBody(JSON_MEDIA_TYPE))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: throw Exception("Empty response")
if (!response.isSuccessful) {
val errorJson = try {
JSONObject(responseBody)
} catch (_: Exception) {
null
}
val message = errorJson?.optString("message", "Request failed") ?: "Request failed"
throw Exception(message)
}
return JSONObject(responseBody)
}
override suspend fun login(email: String, password: String): Result<User> = runCatching {
val json = post("/api/auth/login", mapOf(
"email" to email,
"password" to password
))
val token = json.getString("accessToken")
val refreshToken = if (json.has("refreshToken") && !json.isNull("refreshToken")) json.getString("refreshToken") else null
saveToken(token, refreshToken)
User(
id = json.getString("id"),
name = json.getString("name"),
email = json.getString("email"),
isNewUser = json.optBoolean("isNewUser", false)
)
}
override suspend fun signup(name: String, email: String, password: String): Result<User> = runCatching {
val json = post("/api/auth/signup", mapOf(
"name" to name,
"email" to email,
"password" to password
))
val token = json.getString("accessToken")
val refreshToken = if (json.has("refreshToken") && !json.isNull("refreshToken")) json.getString("refreshToken") else null
saveToken(token, refreshToken)
User(
id = json.getString("id"),
name = json.getString("name"),
email = json.getString("email"),
isNewUser = json.optBoolean("isNewUser", true)
)
}
override suspend fun forgotPassword(email: String): Result<Unit> = runCatching {
post("/api/auth/forgot-password", mapOf("email" to email))
Unit
}
override suspend fun resetPassword(email: String, code: String, password: String): Result<Unit> = runCatching {
post("/api/auth/reset-password", mapOf(
"email" to email,
"code" to code,
"password" to password
))
Unit
}
override suspend fun signInWithGoogle(idToken: String): Result<User> = runCatching {
val json = post("/api/auth/google", mapOf("idToken" to idToken))
val token = json.getString("accessToken")
val refreshToken = if (json.has("refreshToken") && !json.isNull("refreshToken")) json.getString("refreshToken") else null
saveToken(token, refreshToken)
User(
id = json.getString("id"),
name = json.getString("name"),
email = json.getString("email"),
isNewUser = json.optBoolean("isNewUser", false)
)
}
override fun saveToken(accessToken: String, refreshToken: String?) {
securePrefs.edit()
.putString("access_token", accessToken)
.putString("refresh_token", refreshToken)
.apply()
}
override fun getAccessToken(): String? = securePrefs.getString("access_token", null)
override fun getRefreshToken(): String? = securePrefs.getString("refresh_token", null)
override fun clearTokens() {
securePrefs.edit()
.remove("access_token")
.remove("refresh_token")
.apply()
}
override fun isLoggedIn(): Boolean = getAccessToken() != null
}

View File

@@ -0,0 +1,84 @@
package com.shieldai.android.data.repository
import android.content.Context
import com.shieldai.android.data.local.CacheManager
import com.shieldai.android.data.model.Exposure
import com.shieldai.android.data.model.WatchlistItem
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 DarkWatchRepository(
private val api: TRPCApiService,
private val context: Context,
) {
private val _watchlist = MutableStateFlow<List<WatchlistItem>>(emptyList())
suspend fun getWatchlist(forceRefresh: Boolean = false): ApiResult<List<WatchlistItem>> {
if (!forceRefresh) {
val cached: List<WatchlistItem>? = CacheManager.load(context, "watchlist")
if (cached != null) {
_watchlist.value = cached
return ApiResult.Success(cached)
}
}
return ErrorHandler.executeWithRetry {
val response = api.darkWatchGetWatchlist(TRPCRequest.body(buildJsonObject {}))
val items = response.result.data
CacheManager.save(context, "watchlist", items)
_watchlist.value = items
items
}
}
suspend fun addWatchlistItem(type: String, value: String, label: String? = null): ApiResult<WatchlistItem> {
return ErrorHandler.executeWithRetry {
val body = buildJsonObject {
put("type", type)
put("value", value)
label?.let { put("label", it) }
}
val response = api.darkWatchAddWatchlistItem(TRPCRequest.body(body))
val item = response.result.data
refreshCache()
item
}
}
suspend fun removeWatchlistItem(id: String): ApiResult<Unit> {
return ErrorHandler.executeWithRetry {
val body = buildJsonObject { put("id", id) }
api.darkWatchRemoveWatchlistItem(TRPCRequest.body(body))
refreshCache()
}
}
suspend fun getExposures(forceRefresh: Boolean = false): ApiResult<List<Exposure>> {
if (!forceRefresh) {
val cached: List<Exposure>? = CacheManager.load(context, "exposures")
if (cached != null) return ApiResult.Success(cached)
}
return ErrorHandler.executeWithRetry {
val response = api.darkWatchGetExposures(TRPCRequest.body(buildJsonObject {}))
val exposures = response.result.data
CacheManager.save(context, "exposures", exposures)
exposures
}
}
fun observeWatchlist(): Flow<List<WatchlistItem>> = _watchlist
private suspend fun refreshCache() {
ErrorHandler.executeWithRetry {
val response = api.darkWatchGetWatchlist(TRPCRequest.body(buildJsonObject {}))
val items = response.result.data
CacheManager.save(context, "watchlist", items)
_watchlist.value = items
}
}
}

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

@@ -0,0 +1,38 @@
package com.shieldai.android.data.repository
import android.content.Context
import com.shieldai.android.data.local.CacheManager
import com.shieldai.android.data.model.Subscription
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.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
class SubscriptionRepository(
private val api: TRPCApiService,
private val context: Context,
) {
suspend fun getSubscription(): ApiResult<Subscription> {
val cached: Subscription? = CacheManager.load(context, "subscription")
if (cached != null) return ApiResult.Success(cached)
return ErrorHandler.executeWithRetry {
val response = api.subscriptionGet(TRPCRequest.body(buildJsonObject {}))
val subscription = response.result.data
CacheManager.save(context, "subscription", subscription)
subscription
}
}
suspend fun updateSubscription(plan: String): ApiResult<Subscription> {
return ErrorHandler.executeWithRetry {
val body = buildJsonObject { put("plan", plan) }
val response = api.subscriptionUpdate(TRPCRequest.body(body))
val subscription = response.result.data
CacheManager.save(context, "subscription", subscription)
subscription
}
}
}

View File

@@ -0,0 +1,52 @@
package com.shieldai.android.data.repository
import android.content.Context
import com.shieldai.android.data.local.CacheManager
import com.shieldai.android.data.model.User
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
class UserRepository(
private val api: TRPCApiService,
private val context: Context,
) {
private val _currentUser = MutableStateFlow<User?>(null)
suspend fun getMe(forceRefresh: Boolean = false): ApiResult<User> {
if (!forceRefresh) {
val cached: User? = CacheManager.load(context, "current_user")
if (cached != null) {
_currentUser.value = cached
return ApiResult.Success(cached)
}
}
return ErrorHandler.executeWithRetry {
val response = api.userMe(TRPCRequest.body(buildJsonObject {}))
val user = response.result.data
CacheManager.save(context, "current_user", user)
_currentUser.value = user
user
}
}
suspend fun updateProfile(name: String? = null, phone: String? = null): ApiResult<User> {
return ErrorHandler.executeWithRetry {
val body = buildJsonObject {
name?.let { put("name", kotlinx.serialization.json.JsonPrimitive(it)) }
phone?.let { put("phone", kotlinx.serialization.json.JsonPrimitive(it)) }
}
val response = api.userUpdateProfile(TRPCRequest.body(body))
val user = response.result.data
CacheManager.save(context, "current_user", user)
_currentUser.value = user
user
}
}
fun observeCurrentUser(): Flow<User?> = _currentUser
}

View File

@@ -0,0 +1,68 @@
package com.shieldai.android.data.repository
import android.content.Context
import com.shieldai.android.data.local.CacheManager
import com.shieldai.android.data.model.VoiceAnalysis
import com.shieldai.android.data.model.VoiceEnrollment
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 VoicePrintRepository(
private val api: TRPCApiService,
private val context: Context,
) {
private val _enrollments = MutableStateFlow<List<VoiceEnrollment>>(emptyList())
suspend fun getEnrollments(): ApiResult<List<VoiceEnrollment>> {
val cached: List<VoiceEnrollment>? = CacheManager.load(context, "voice_enrollments")
if (cached != null) {
_enrollments.value = cached
return ApiResult.Success(cached)
}
return ErrorHandler.executeWithRetry {
val response = api.voiceEnrollments(TRPCRequest.body(buildJsonObject {}))
val enrollments = response.result.data
CacheManager.save(context, "voice_enrollments", enrollments)
_enrollments.value = enrollments
enrollments
}
}
suspend fun createEnrollment(name: String): ApiResult<VoiceEnrollment> {
return ErrorHandler.executeWithRetry {
val body = buildJsonObject { put("name", name) }
val response = api.voiceCreateEnrollment(TRPCRequest.body(body))
val enrollment = response.result.data
refreshEnrollmentsCache()
enrollment
}
}
suspend fun analyze(enrollmentId: String, audioData: String): ApiResult<VoiceAnalysis> {
return ErrorHandler.executeWithRetry {
val body = buildJsonObject {
put("enrollmentId", enrollmentId)
put("audioData", audioData)
}
val response = api.voiceAnalyze(TRPCRequest.body(body))
response.result.data
}
}
fun observeEnrollments(): Flow<List<VoiceEnrollment>> = _enrollments
private suspend fun refreshEnrollmentsCache() {
ErrorHandler.executeWithRetry {
val response = api.voiceEnrollments(TRPCRequest.body(buildJsonObject {}))
val enrollments = response.result.data
CacheManager.save(context, "voice_enrollments", enrollments)
_enrollments.value = enrollments
}
}
}

View File

@@ -0,0 +1,52 @@
package com.shieldai.android.data.sync
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
class OfflineWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val queue = PendingRequestQueue(applicationContext)
val pendingRequests = queue.getAll()
if (pendingRequests.isEmpty()) return Result.success()
val client = OkHttpClient.Builder().build()
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
for (request in pendingRequests) {
if (request.retryCount >= request.maxRetries) {
queue.deleteById(request.id)
continue
}
try {
val body = request.body.toRequestBody(jsonMediaType)
val httpRequest = Request.Builder()
.url("https://api.shieldai.com/${request.endpoint}")
.method(request.method, body)
.build()
val response = client.newCall(httpRequest).execute()
if (response.isSuccessful) {
queue.deleteById(request.id)
} else {
queue.incrementRetry(request.id)
if (response.code == 422 || response.code == 400) {
queue.deleteById(request.id)
}
}
} catch (_: Exception) {
queue.incrementRetry(request.id)
return Result.retry()
}
}
queue.deleteExpired()
return if (queue.count() == 0) Result.success() else Result.retry()
}
}

View File

@@ -0,0 +1,71 @@
package com.shieldai.android.data.sync
import android.content.Context
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
@Serializable
data class PendingRequest(
val id: Long = 0,
val endpoint: String,
val method: String = "POST",
val body: String,
val timestamp: Long = System.currentTimeMillis(),
val retryCount: Int = 0,
val maxRetries: Int = 5,
)
class PendingRequestQueue(private val context: Context) {
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
private val file: File get() = File(context.cacheDir, "pending_requests.json")
fun getAll(): List<PendingRequest> {
if (!file.exists()) return emptyList()
return try {
json.decodeFromString<List<PendingRequest>>(file.readText())
} catch (_: Exception) {
file.delete()
emptyList()
}
}
private fun saveAll(requests: List<PendingRequest>) {
file.writeText(json.encodeToString(requests))
}
fun insert(request: PendingRequest) {
val requests = getAll().toMutableList()
val newId = (requests.maxOfOrNull { it.id } ?: 0) + 1
requests.add(request.copy(id = newId))
saveAll(requests)
}
fun incrementRetry(id: Long) {
val requests = getAll().map {
if (it.id == id) it.copy(retryCount = it.retryCount + 1) else it
}
saveAll(requests)
}
fun deleteById(id: Long) {
val requests = getAll().filter { it.id != id }
saveAll(requests)
}
fun deleteExpired() {
val requests = getAll().filter { it.retryCount < it.maxRetries }
saveAll(requests)
}
fun deleteAll() {
file.delete()
}
fun count(): Int = getAll().size
}

View File

@@ -0,0 +1,65 @@
package com.shieldai.android.data.sync
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import androidx.work.BackoffPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
class SyncManager(private val context: Context) {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val queue = PendingRequestQueue(context)
fun enqueueRequest(endpoint: String, body: String, method: String = "POST") {
val request = PendingRequest(
endpoint = endpoint,
method = method,
body = body,
)
queue.insert(request)
scheduleSync()
}
fun scheduleSync(delayMinutes: Long = 0) {
val workRequest = OneTimeWorkRequestBuilder<OfflineWorker>()
.setInitialDelay(delayMinutes, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
"offline_sync",
ExistingWorkPolicy.REPLACE,
workRequest,
)
}
fun queueSize(): Int = queue.count()
fun startMonitoring() {
val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
if (queueSize() > 0) {
scheduleSync()
}
}
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, networkCallback)
}
fun isOnline(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}

View File

@@ -0,0 +1,16 @@
package com.shieldai.android.di
import android.content.Context
import com.shieldai.android.data.local.CacheManager
object DatabaseModule {
fun initializeCache(context: Context) {
CacheManager.setTtl("users", 5 * 60 * 1000L)
CacheManager.setTtl("current_user", 5 * 60 * 1000L)
CacheManager.setTtl("watchlist", 5 * 60 * 1000L)
CacheManager.setTtl("exposures", 5 * 60 * 1000L)
CacheManager.setTtl("alerts", 5 * 60 * 1000L)
CacheManager.setTtl("subscription", 5 * 60 * 1000L)
CacheManager.setTtl("voice_enrollments", 10 * 60 * 1000L)
}
}

View File

@@ -0,0 +1,61 @@
package com.shieldai.android.di
import android.content.Context
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.shieldai.android.data.remote.AuthInterceptor
import com.shieldai.android.data.remote.TRPCApiService
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit
object NetworkModule {
private var baseUrl: String = "http://10.0.2.2:3000/"
private var retrofit: Retrofit? = null
private var apiService: TRPCApiService? = null
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
fun setBaseUrl(url: String) {
baseUrl = if (url.endsWith("/")) url else "$url/"
retrofit = null
apiService = null
}
fun getBaseUrl(): String = baseUrl
fun provideOkHttpClient(context: Context): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(context))
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}
fun provideRetrofit(context: Context): Retrofit {
return retrofit ?: synchronized(this) {
retrofit ?: Retrofit.Builder()
.baseUrl(baseUrl)
.client(provideOkHttpClient(context))
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.also { retrofit = it }
}
}
fun provideApiService(context: Context): TRPCApiService {
return apiService ?: synchronized(this) {
apiService ?: provideRetrofit(context).create(TRPCApiService::class.java)
.also { apiService = it }
}
}
}

View File

@@ -0,0 +1,94 @@
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
object RepositoryModule {
private var userRepository: UserRepository? = null
private var darkWatchRepository: DarkWatchRepository? = null
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) {
UserRepository(
api = NetworkModule.provideApiService(context),
context = context,
).also { userRepository = it }
}
}
fun provideDarkWatchRepository(context: Context): DarkWatchRepository {
return darkWatchRepository ?: synchronized(this) {
DarkWatchRepository(
api = NetworkModule.provideApiService(context),
context = context,
).also { darkWatchRepository = it }
}
}
fun provideVoicePrintRepository(context: Context): VoicePrintRepository {
return voicePrintRepository ?: synchronized(this) {
VoicePrintRepository(
api = NetworkModule.provideApiService(context),
context = context,
).also { voicePrintRepository = it }
}
}
fun provideAlertRepository(context: Context): AlertRepository {
return alertRepository ?: synchronized(this) {
AlertRepository(
api = NetworkModule.provideApiService(context),
context = context,
).also { alertRepository = it }
}
}
fun provideSubscriptionRepository(context: Context): SubscriptionRepository {
return subscriptionRepository ?: synchronized(this) {
SubscriptionRepository(
api = NetworkModule.provideApiService(context),
context = context,
).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,75 @@
package com.shieldai.android.navigation
import android.app.Application
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.shieldai.android.KordantApp
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun AppNavigation() {
val context = LocalContext.current
val app = context.applicationContext as KordantApp
val viewModel: AuthViewModel = viewModel(
factory = AuthViewModel.Factory
)
val isAuthenticated by viewModel.isAuthenticated.collectAsState()
val isNewUser by viewModel.isNewUser.collectAsState()
if (isAuthenticated) {
if (isNewUser) {
OnboardingNavHost(
viewModel = viewModel,
onComplete = {
viewModel.completeOnboarding()
}
)
} else {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val bottomNavScreens = setOf(
Screen.Dashboard.route,
Screen.Services.route,
Screen.Alerts.route,
Screen.Settings.route,
Screen.Account.route
)
val showBottomBar = currentRoute in bottomNavScreens
Scaffold(
bottomBar = {
if (showBottomBar) {
BottomNavBar(
currentRoute = currentRoute,
onNavigate = { screen ->
navController.navigate(screen.route) {
popUpTo(navController.graph.startDestinationId)
launchSingleTop = true
restoreState = true
}
}
)
}
}
) { innerPadding ->
NavGraph(
navController = navController,
viewModel = viewModel,
modifier = Modifier.padding(innerPadding)
)
}
}
} else {
AuthNavHost(viewModel = viewModel)
}
}

View File

@@ -0,0 +1,41 @@
package com.shieldai.android.navigation
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import com.shieldai.android.R
data class BottomNavItem(
val screen: Screen,
val label: String,
val icon: ImageVector
)
@Composable
fun BottomNavBar(
currentRoute: String?,
onNavigate: (Screen) -> Unit
) {
val items = listOf(
BottomNavItem(Screen.Dashboard, "Dashboard", ImageVector.vectorResource(R.drawable.ic_dashboard)),
BottomNavItem(Screen.Services, "Services", ImageVector.vectorResource(R.drawable.ic_services)),
BottomNavItem(Screen.Alerts, "Alerts", ImageVector.vectorResource(R.drawable.ic_alerts)),
BottomNavItem(Screen.Settings, "Settings", ImageVector.vectorResource(R.drawable.ic_settings)),
BottomNavItem(Screen.Account, "Account", ImageVector.vectorResource(R.drawable.ic_account_box))
)
NavigationBar {
items.forEach { item ->
NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = currentRoute == item.screen.route,
onClick = { onNavigate(item.screen) }
)
}
}
}

View File

@@ -0,0 +1,292 @@
package com.shieldai.android.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.shieldai.android.R
import com.shieldai.android.ui.screens.auth.AuthScreen
import com.shieldai.android.ui.screens.auth.ForgotPasswordScreen
import com.shieldai.android.ui.screens.auth.ResetPasswordScreen
import com.shieldai.android.ui.screens.dashboard.AlertDetailScreen
import com.shieldai.android.ui.screens.dashboard.DashboardScreen
import com.shieldai.android.ui.screens.onboarding.OnboardingScreen
import com.shieldai.android.ui.screens.services.DarkWatchScreen
import com.shieldai.android.ui.screens.services.HomeTitleScreen
import com.shieldai.android.ui.screens.services.RemoveBrokersScreen
import com.shieldai.android.ui.screens.services.SpamShieldScreen
import com.shieldai.android.ui.screens.services.VoicePrintScreen
import com.shieldai.android.ui.screens.settings.SettingsScreen
import com.shieldai.android.viewmodel.AuthViewModel
data class ServiceNavCard(
val title: String,
val route: String,
val description: String
)
@Composable
fun NavGraph(
navController: NavHostController,
viewModel: AuthViewModel,
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = Screen.Dashboard.route,
modifier = modifier
) {
composable(Screen.Dashboard.route) {
DashboardScreen(
onNavigateToAlert = { alertId ->
navController.navigate(Screen.AlertDetail.createRoute(alertId))
},
onNavigateToService = { serviceRoute ->
navController.navigate(serviceRoute)
}
)
}
composable(Screen.Alerts.route) {
AlertsScreen(
onNavigateToAlert = { alertId ->
navController.navigate(Screen.AlertDetail.createRoute(alertId))
}
)
}
composable(
route = Screen.AlertDetail.ROUTE,
arguments = listOf(navArgument("alertId") { type = NavType.StringType })
) { backStackEntry ->
val alertId = backStackEntry.arguments?.getString("alertId") ?: ""
AlertDetailScreen(
alertId = alertId,
onBack = { navController.popBackStack() }
)
}
composable(Screen.Services.route) {
ServicesHubScreen(
onNavigateToService = { route ->
navController.navigate(route)
}
)
}
composable(Screen.DarkWatch.route) {
DarkWatchScreen(
onBack = { navController.popBackStack() }
)
}
composable(Screen.VoicePrint.route) {
VoicePrintScreen(
onBack = { navController.popBackStack() }
)
}
composable(Screen.SpamShield.route) {
SpamShieldScreen(
onBack = { navController.popBackStack() }
)
}
composable(Screen.HomeTitle.route) {
HomeTitleScreen(
onBack = { navController.popBackStack() }
)
}
composable(Screen.RemoveBrokers.route) {
RemoveBrokersScreen(
onBack = { navController.popBackStack() }
)
}
composable(Screen.Settings.route) {
SettingsScreen(
onBack = { navController.popBackStack(Screen.Dashboard.route, inclusive = false) }
)
}
composable(Screen.Account.route) {
PlaceholderScreen(title = "Account")
}
composable(
route = Screen.ServiceDetail.ROUTE,
arguments = listOf(navArgument("serviceId") { type = NavType.StringType })
) { backStackEntry ->
val serviceId = backStackEntry.arguments?.getString("serviceId") ?: ""
PlaceholderScreen(title = "Service: $serviceId")
}
}
}
@Composable
fun AuthNavHost(viewModel: AuthViewModel) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Screen.Auth.route
) {
composable(Screen.Auth.route) {
AuthScreen(
viewModel = viewModel,
onNavigateToForgotPassword = {
navController.navigate(Screen.ForgotPassword.route)
},
onNavigateToResetPassword = {
navController.navigate(Screen.ResetPassword.createRoute(""))
}
)
}
composable(Screen.ForgotPassword.route) {
ForgotPasswordScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
composable(
route = Screen.ResetPassword.route,
arguments = listOf(navArgument("email") { type = NavType.StringType; defaultValue = "" })
) { backStackEntry ->
val email = backStackEntry.arguments?.getString("email") ?: ""
ResetPasswordScreen(
viewModel = viewModel,
email = email,
onBack = { navController.popBackStack(Screen.Auth.route, false) }
)
}
}
}
@Composable
fun OnboardingNavHost(
viewModel: AuthViewModel,
onComplete: () -> Unit
) {
OnboardingScreen(
viewModel = viewModel,
onComplete = onComplete
)
}
@Composable
private fun ServicesHubScreen(
onNavigateToService: (String) -> Unit
) {
val services = listOf(
ServiceNavCard("DarkWatch", Screen.DarkWatch.route, "Monitor data exposures"),
ServiceNavCard("VoicePrint", Screen.VoicePrint.route, "Voice authentication"),
ServiceNavCard("SpamShield", Screen.SpamShield.route, "Spam call protection"),
ServiceNavCard("HomeTitle", Screen.HomeTitle.route, "Property title monitoring"),
ServiceNavCard("RemoveBrokers", Screen.RemoveBrokers.route, "Broker listing removal")
)
Column(
modifier = Modifier.fillMaxSize().padding(16.dp)
) {
Text(
text = "Services",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
LazyVerticalGrid(
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(services.size) { index ->
val service = services[index]
com.shieldai.android.ui.components.ShieldCard(
onClick = { onNavigateToService(service.route) },
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_services),
contentDescription = service.title,
tint = MaterialTheme.colorScheme.primary
)
Text(
text = service.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 12.dp)
)
Text(
text = service.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
}
}
}
@Composable
private fun AlertsScreen(
onNavigateToAlert: (String) -> Unit
) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp)
) {
Text(
text = "Alerts",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
com.shieldai.android.ui.components.ShieldEmptyState(
title = "No alerts",
description = "You have no recent alerts"
)
}
}
@Composable
private fun PlaceholderScreen(title: String) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onBackground
)
}
}

View File

@@ -0,0 +1,30 @@
package com.shieldai.android.navigation
sealed class Screen(val route: String) {
data object Dashboard : Screen("dashboard")
data object Services : Screen("services")
data object Alerts : Screen("alerts")
data object Settings : Screen("settings")
data object Account : Screen("account")
data object Auth : Screen("auth")
data object ForgotPassword : Screen("forgot_password")
data object ResetPassword : Screen("reset_password/{email}") {
fun createRoute(email: String) = "reset_password/$email"
}
data object Onboarding : Screen("onboarding")
data class ServiceDetail(val serviceId: String) : Screen("service_detail/{serviceId}") {
companion object {
const val ROUTE = "service_detail/{serviceId}"
fun createRoute(serviceId: String) = "service_detail/$serviceId"
}
}
object AlertDetail {
const val ROUTE = "alert_detail/{alertId}"
fun createRoute(alertId: String) = "alert_detail/$alertId"
}
data object DarkWatch : Screen("darkwatch")
data object VoicePrint : Screen("voiceprint")
data object SpamShield : Screen("spamshield")
data object HomeTitle : Screen("hometitle")
data object RemoveBrokers : Screen("removebrokers")
}

View File

@@ -0,0 +1,269 @@
package com.shieldai.android.ui.components
import android.content.res.Configuration
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.KordantTheme
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ComponentShowcase(modifier: Modifier = Modifier) {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var textFieldValue by remember { mutableStateOf("") }
var showSheet by remember { mutableStateOf(false) }
var showDialog by remember { mutableStateOf(false) }
Box(modifier = modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Kordant Design System",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
SectionTitle("ShieldButton")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ShieldButton(text = "Primary", onClick = {}, variant = ShieldButtonVariant.Primary, size = ShieldButtonSize.Small)
ShieldButton(text = "Secondary", onClick = {}, variant = ShieldButtonVariant.Secondary, size = ShieldButtonSize.Small)
ShieldButton(text = "Ghost", onClick = {}, variant = ShieldButtonVariant.Ghost, size = ShieldButtonSize.Small)
ShieldButton(text = "Danger", onClick = {}, variant = ShieldButtonVariant.Danger, size = ShieldButtonSize.Small)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ShieldButton(text = "Loading", onClick = {}, loading = true)
ShieldButton(text = "Disabled", onClick = {}, enabled = false)
}
ShieldButton(text = "Full Width", onClick = {}, variant = ShieldButtonVariant.Primary, fullWidth = true)
HorizontalDivider()
SectionTitle("ShieldCard")
ShieldCard(
modifier = Modifier.fillMaxWidth(),
header = { Text("Card Header", style = MaterialTheme.typography.titleMedium) },
footer = {
ShieldButton(text = "Action", onClick = {}, size = ShieldButtonSize.Small)
},
content = {
Spacer(modifier = Modifier.height(8.dp))
Text("This is the card content area. It uses a gradient background matching the web theme.", style = MaterialTheme.typography.bodyMedium)
}
)
HorizontalDivider()
SectionTitle("ShieldTextField")
ShieldTextField(
value = textFieldValue,
onValueChange = { textFieldValue = it },
label = "Email",
placeholder = "Enter your email",
inputType = InputType.Email
)
ShieldTextField(
value = "",
onValueChange = {},
label = "Password",
inputType = InputType.Password
)
ShieldTextField(
value = "invalid",
onValueChange = {},
label = "With Error",
isError = true,
errorMessage = "This field is required"
)
HorizontalDivider()
SectionTitle("ShieldBadge")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ShieldBadge(text = "Default", variant = BadgeVariant.Default)
ShieldBadge(text = "Success", variant = BadgeVariant.Success)
ShieldBadge(text = "Warning", variant = BadgeVariant.Warning)
ShieldBadge(text = "Error", variant = BadgeVariant.Error)
ShieldBadge(text = "Info", variant = BadgeVariant.Info)
}
HorizontalDivider()
SectionTitle("ShieldAvatar")
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
ShieldAvatar(imageUrl = null, name = "John Doe", size = AvatarSize.Small)
ShieldAvatar(imageUrl = null, name = "Jane Smith", size = AvatarSize.Medium, isOnline = true)
ShieldAvatar(imageUrl = null, name = "Alice", size = AvatarSize.Large, isOnline = true)
}
HorizontalDivider()
SectionTitle("ShieldProgressBar")
ShieldProgressBar(progress = 0.3f, color = ProgressColor.Primary, showPercentage = true)
ShieldProgressBar(progress = 0.6f, color = ProgressColor.Accent, showPercentage = true)
ShieldProgressBar(progress = 0.9f, color = ProgressColor.Success, showPercentage = true)
HorizontalDivider()
SectionTitle("ShieldEmptyState")
ShieldEmptyState(
title = "No items found",
description = "Try adjusting your search or filters to find what you're looking for.",
actionButton = {
ShieldButton(text = "Clear Filters", onClick = {}, variant = ShieldButtonVariant.Secondary)
}
)
HorizontalDivider()
SectionTitle("ShieldSkeleton")
ShieldSkeletonCard(modifier = Modifier.fillMaxWidth(), lines = 3)
HorizontalDivider()
SectionTitle("ShieldToast")
ShieldButton(
text = "Show Success Toast",
onClick = {
scope.launch {
snackbarHostState.showSnackbar(
ShieldSnackbarVisuals(
message = "Operation completed successfully!",
variant = ToastVariant.Success,
duration = SnackbarDuration.Short
)
)
}
},
variant = ShieldButtonVariant.Primary,
fullWidth = true
)
ShieldButton(
text = "Show Error Toast with Action",
onClick = {
scope.launch {
snackbarHostState.showSnackbar(
ShieldSnackbarVisuals(
message = "Something went wrong.",
actionLabel = "Retry",
variant = ToastVariant.Error,
duration = SnackbarDuration.Long
)
)
}
},
variant = ShieldButtonVariant.Danger,
fullWidth = true
)
HorizontalDivider()
SectionTitle("ShieldModal")
ShieldButton(
text = "Show Bottom Sheet",
onClick = { showSheet = true },
variant = ShieldButtonVariant.Secondary,
fullWidth = true
)
ShieldButton(
text = "Show Alert Dialog",
onClick = { showDialog = true },
variant = ShieldButtonVariant.Ghost,
fullWidth = true
)
Spacer(modifier = Modifier.height(80.dp))
}
ShieldToastHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter)
)
if (showSheet) {
ShieldBottomSheet(
onDismiss = { showSheet = false },
title = "Bottom Sheet Title",
actions = listOf(
ModalAction(text = "Save", onClick = { showSheet = false }, isPrimary = true),
ModalAction(text = "Cancel", onClick = { showSheet = false })
)
) {
Text("This is the bottom sheet content area.")
}
}
if (showDialog) {
ShieldAlertDialog(
onDismiss = { showDialog = false },
onConfirm = { showDialog = false },
title = "Confirm Action",
message = "Are you sure you want to proceed?",
confirmText = "Yes, Continue",
dismissText = "Cancel"
)
}
}
}
@Composable
private fun SectionTitle(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
}
@Preview(showBackground = true, name = "Light Mode")
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode")
@Composable
fun ComponentShowcasePreview() {
KordantTheme {
ComponentShowcase()
}
}

View File

@@ -0,0 +1,97 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.ui.theme.Success
enum class AvatarSize(val dimension: Dp, val fontSize: TextUnit) {
Small(32.dp, 12.sp),
Medium(40.dp, 16.sp),
Large(56.dp, 24.sp)
}
@Composable
fun ShieldAvatar(
imageUrl: String?,
name: String,
modifier: Modifier = Modifier,
size: AvatarSize = AvatarSize.Medium,
isOnline: Boolean = false
) {
val initials = remember(name) {
name.split(" ")
.take(2)
.mapNotNull { it.firstOrNull()?.uppercase() }
.joinToString("")
}
val statusDotSize = (size.dimension / 4).coerceAtLeast(8.dp)
Box(
modifier = modifier.size(size.dimension),
contentAlignment = Alignment.BottomEnd
) {
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = name,
modifier = Modifier
.size(size.dimension)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier
.size(size.dimension)
.clip(CircleShape)
.background(BrandPrimary),
contentAlignment = Alignment.Center
) {
Text(
text = initials,
color = Color.White,
fontSize = size.fontSize,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
}
}
if (isOnline) {
Canvas(
modifier = Modifier.size(statusDotSize)
) {
val radius = statusDotSize.toPx() / 2
drawCircle(
color = Color.White,
radius = radius
)
drawCircle(
color = Success,
radius = radius - 1.5.dp.toPx()
)
}
}
}
}

View File

@@ -0,0 +1,92 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.Error
import com.shieldai.android.ui.theme.Info
import com.shieldai.android.ui.theme.Success
import com.shieldai.android.ui.theme.TextPrimaryLight
import com.shieldai.android.ui.theme.TextSecondaryLight
import com.shieldai.android.ui.theme.Warning
enum class BadgeVariant {
Default, Success, Warning, Error, Info
}
data class BadgeColors(
val background: Color,
val content: Color
)
fun badgeColors(variant: BadgeVariant): BadgeColors = when (variant) {
BadgeVariant.Default -> BadgeColors(
background = Color(0xFFF1F5F9),
content = TextSecondaryLight
)
BadgeVariant.Success -> BadgeColors(
background = Success.copy(alpha = 0.15f),
content = Success
)
BadgeVariant.Warning -> BadgeColors(
background = Warning.copy(alpha = 0.15f),
content = Warning
)
BadgeVariant.Error -> BadgeColors(
background = Error.copy(alpha = 0.15f),
content = Error
)
BadgeVariant.Info -> BadgeColors(
background = Info.copy(alpha = 0.15f),
content = Info
)
}
@Composable
fun ShieldBadge(
text: String,
modifier: Modifier = Modifier,
variant: BadgeVariant = BadgeVariant.Default,
icon: Painter? = null
) {
val colors = badgeColors(variant)
Surface(
modifier = modifier,
shape = RoundedCornerShape(50),
color = colors.background,
contentColor = colors.content
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = colors.content
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = text,
style = MaterialTheme.typography.labelSmall
)
}
}
}

View File

@@ -0,0 +1,146 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
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.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.ui.theme.Error
enum class ShieldButtonVariant {
Primary, Secondary, Ghost, Danger
}
enum class ShieldButtonSize {
Small, Medium, Large
}
@Composable
fun ShieldButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
variant: ShieldButtonVariant = ShieldButtonVariant.Primary,
size: ShieldButtonSize = ShieldButtonSize.Medium,
enabled: Boolean = true,
loading: Boolean = false,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
fullWidth: Boolean = false
) {
val buttonModifier = if (fullWidth) modifier.fillMaxWidth() else modifier
val sizeModifier = when (size) {
ShieldButtonSize.Small -> Modifier.height(32.dp)
ShieldButtonSize.Medium -> Modifier.height(40.dp)
ShieldButtonSize.Large -> Modifier.height(48.dp)
}
val paddingModifier = when (size) {
ShieldButtonSize.Small -> Modifier.padding(horizontal = 12.dp)
ShieldButtonSize.Medium -> Modifier.padding(horizontal = 16.dp)
ShieldButtonSize.Large -> Modifier.padding(horizontal = 20.dp)
}
val indicatorSize = when (size) {
ShieldButtonSize.Small -> 16.dp
ShieldButtonSize.Medium -> 20.dp
ShieldButtonSize.Large -> 24.dp
}
val contentColor = when {
variant == ShieldButtonVariant.Ghost -> BrandPrimary
variant == ShieldButtonVariant.Secondary -> BrandPrimary
else -> Color.White
}
val containerColor = when (variant) {
ShieldButtonVariant.Primary -> BrandPrimary
ShieldButtonVariant.Danger -> Error
else -> Color.Transparent
}
val mergedEnabled = enabled && !loading
val content: @Composable RowScope.() -> Unit = {
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(indicatorSize),
color = if (variant == ShieldButtonVariant.Ghost || variant == ShieldButtonVariant.Secondary)
BrandPrimary else Color.White,
strokeWidth = 2.dp
)
} else {
leadingIcon?.let {
it()
Spacer(modifier = Modifier.width(8.dp))
}
Text(
text = text,
style = when (size) {
ShieldButtonSize.Small -> MaterialTheme.typography.labelSmall
ShieldButtonSize.Medium -> MaterialTheme.typography.labelLarge
ShieldButtonSize.Large -> MaterialTheme.typography.titleSmall
}
)
trailingIcon?.let {
Spacer(modifier = Modifier.width(8.dp))
it()
}
}
}
when (variant) {
ShieldButtonVariant.Primary, ShieldButtonVariant.Danger -> {
Button(
onClick = onClick,
modifier = buttonModifier.then(sizeModifier).then(paddingModifier),
enabled = mergedEnabled,
colors = ButtonDefaults.buttonColors(
containerColor = containerColor,
contentColor = contentColor,
disabledContainerColor = containerColor.copy(alpha = 0.4f),
disabledContentColor = contentColor.copy(alpha = 0.4f)
),
shape = MaterialTheme.shapes.small,
content = content
)
}
ShieldButtonVariant.Secondary -> {
OutlinedButton(
onClick = onClick,
modifier = buttonModifier.then(sizeModifier).then(paddingModifier),
enabled = mergedEnabled,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = BrandPrimary,
disabledContentColor = BrandPrimary.copy(alpha = 0.4f)
),
border = ButtonDefaults.outlinedButtonBorder(enabled = mergedEnabled),
shape = MaterialTheme.shapes.small,
content = content
)
}
ShieldButtonVariant.Ghost -> {
TextButton(
onClick = onClick,
modifier = buttonModifier.then(sizeModifier).then(paddingModifier),
enabled = mergedEnabled,
colors = ButtonDefaults.textButtonColors(
contentColor = BrandPrimary,
disabledContentColor = BrandPrimary.copy(alpha = 0.4f)
),
shape = MaterialTheme.shapes.small,
content = content
)
}
}
}

View File

@@ -0,0 +1,55 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandAccent
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.ui.theme.OutlineLight
val GradientCardBrush = Brush.linearGradient(
colors = listOf(
BrandPrimary.copy(alpha = 0.08f),
BrandAccent.copy(alpha = 0.05f)
)
)
@Composable
fun ShieldCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
header: @Composable ColumnScope.() -> Unit = {},
footer: @Composable ColumnScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit
) {
Card(
onClick = onClick ?: {},
enabled = onClick != null,
modifier = modifier,
shape = MaterialTheme.shapes.large,
border = BorderStroke(1.dp, OutlineLight),
colors = CardDefaults.cardColors(
containerColor = Color.Transparent
)
) {
Column(
modifier = Modifier
.background(GradientCardBrush)
.padding(16.dp)
) {
header()
content()
footer()
}
}
}

View File

@@ -0,0 +1,62 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
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.graphics.painter.Painter
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun ShieldEmptyState(
title: String,
description: String,
modifier: Modifier = Modifier,
icon: Painter? = null,
actionButton: @Composable (() -> Unit)? = null
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
if (icon != null) {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(16.dp))
}
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (actionButton != null) {
Spacer(modifier = Modifier.height(24.dp))
actionButton()
}
}
}

View File

@@ -0,0 +1,125 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandPrimary
data class ModalAction(
val text: String,
val onClick: () -> Unit,
val isPrimary: Boolean = false
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShieldBottomSheet(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
title: String? = null,
actions: List<ModalAction> = emptyList(),
content: @Composable () -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
modifier = modifier,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp)
) {
if (title != null) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Start
)
Spacer(modifier = Modifier.height(16.dp))
}
content()
if (actions.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
actions.forEach { action ->
TextButton(
onClick = action.onClick,
modifier = Modifier.fillMaxWidth(),
colors = if (action.isPrimary) {
ButtonDefaults.textButtonColors(contentColor = BrandPrimary)
} else {
ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurfaceVariant)
}
) {
Text(text = action.text)
}
}
}
}
}
}
@Composable
fun ShieldAlertDialog(
onDismiss: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
title: String,
message: String,
confirmText: String = "Confirm",
dismissText: String = "Cancel",
isDestructive: Boolean = false
) {
AlertDialog(
onDismissRequest = onDismiss,
modifier = modifier,
title = {
Text(
text = title,
style = MaterialTheme.typography.titleLarge
)
},
text = {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(
text = confirmText,
color = if (isDestructive) MaterialTheme.colorScheme.error else BrandPrimary
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = dismissText)
}
},
shape = MaterialTheme.shapes.large
)
}

View File

@@ -0,0 +1,66 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandAccent
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.ui.theme.Error
import com.shieldai.android.ui.theme.OutlineLight
import com.shieldai.android.ui.theme.Success
import com.shieldai.android.ui.theme.Warning
enum class ProgressColor {
Primary, Accent, Success, Warning, Error
}
@Composable
fun ShieldProgressBar(
progress: Float,
modifier: Modifier = Modifier,
color: ProgressColor = ProgressColor.Primary,
showPercentage: Boolean = false
) {
val progressColor = when (color) {
ProgressColor.Primary -> BrandPrimary
ProgressColor.Accent -> BrandAccent
ProgressColor.Success -> Success
ProgressColor.Warning -> Warning
ProgressColor.Error -> Error
}
Column(modifier = modifier.fillMaxWidth()) {
LinearProgressIndicator(
progress = { progress.coerceIn(0f, 1f) },
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
color = progressColor,
trackColor = OutlineLight,
strokeCap = StrokeCap.Round
)
if (showPercentage) {
Text(
text = "${(progress * 100).toInt()}%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
textAlign = TextAlign.End
)
}
}
}

View File

@@ -0,0 +1,122 @@
package com.shieldai.android.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.OutlineLight
@Composable
fun ShieldSkeletonLine(
modifier: Modifier = Modifier,
widthFraction: Float = 1f
) {
val shimmerColors = listOf(
OutlineLight.copy(alpha = 0.6f),
Color.White.copy(alpha = 0.4f),
OutlineLight.copy(alpha = 0.6f)
)
val transition = rememberInfiniteTransition(label = "shimmer")
val translateAnimation by transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "shimmerOffset"
)
val brush = Brush.linearGradient(
colors = shimmerColors,
start = Offset(translateAnimation - 200f, 0f),
end = Offset(translateAnimation, 0f)
)
Box(
modifier = modifier
.fillMaxWidth(fraction = widthFraction)
.height(14.dp)
.clip(RoundedCornerShape(4.dp))
.background(brush)
)
}
@Composable
fun ShieldSkeletonRectangle(
modifier: Modifier = Modifier,
height: Int = 100
) {
val shimmerColors = listOf(
OutlineLight.copy(alpha = 0.6f),
Color.White.copy(alpha = 0.4f),
OutlineLight.copy(alpha = 0.6f)
)
val transition = rememberInfiniteTransition(label = "shimmerRect")
val translateAnimation by transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "shimmerRectOffset"
)
val brush = Brush.linearGradient(
colors = shimmerColors,
start = Offset(translateAnimation - 200f, 0f),
end = Offset(translateAnimation, 0f)
)
Box(
modifier = modifier
.fillMaxWidth()
.height(height.dp)
.clip(RoundedCornerShape(8.dp))
.background(brush)
)
}
@Composable
fun ShieldSkeletonCard(
modifier: Modifier = Modifier,
lines: Int = 3
) {
Column(modifier = modifier.padding(16.dp)) {
ShieldSkeletonRectangle(height = 120)
Spacer(modifier = Modifier.height(12.dp))
repeat(lines) { index ->
ShieldSkeletonLine(
widthFraction = when (index) {
0 -> 0.9f
lines - 1 -> 0.5f
else -> 0.75f
}
)
if (index < lines - 1) {
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}

View File

@@ -0,0 +1,118 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.Error
enum class InputType {
Text, Email, Password, Number, Phone
}
@Composable
fun ShieldTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
placeholder: String = "",
inputType: InputType = InputType.Text,
isError: Boolean = false,
errorMessage: String? = null,
helperText: String? = null,
enabled: Boolean = true,
readOnly: Boolean = false
) {
var passwordVisible by remember { mutableStateOf(false) }
val keyboardType = when (inputType) {
InputType.Email -> KeyboardType.Email
InputType.Password -> KeyboardType.Password
InputType.Number -> KeyboardType.Number
InputType.Phone -> KeyboardType.Phone
else -> KeyboardType.Text
}
val visualTransformation = if (inputType == InputType.Password && !passwordVisible) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
}
val trailingIcon = if (inputType == InputType.Password) {
@Composable {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Text(
text = if (passwordVisible) "Hide" else "Show",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
} else null
Column(modifier = modifier.fillMaxWidth()) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
placeholder = if (placeholder.isNotEmpty()) {{ Text(placeholder) }} else null,
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
readOnly = readOnly,
singleLine = true,
isError = isError,
visualTransformation = visualTransformation,
trailingIcon = trailingIcon,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
imeAction = ImeAction.Next
),
shape = MaterialTheme.shapes.medium,
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedIndicatorColor = MaterialTheme.colorScheme.primary,
unfocusedIndicatorColor = MaterialTheme.colorScheme.outline,
errorIndicatorColor = Error,
focusedLabelColor = MaterialTheme.colorScheme.primary,
unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
cursorColor = MaterialTheme.colorScheme.primary
)
)
if (isError && errorMessage != null) {
Text(
text = errorMessage,
color = Error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
} else if (helperText != null) {
Text(
text = helperText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
}
}

View File

@@ -0,0 +1,92 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarData
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarVisuals
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.Error
import com.shieldai.android.ui.theme.Info
import com.shieldai.android.ui.theme.Success
import com.shieldai.android.ui.theme.TextPrimaryDark
import com.shieldai.android.ui.theme.Warning
enum class ToastVariant {
Success, Error, Warning, Info
}
data class ToastColors(
val container: Color,
val content: Color,
val action: Color
)
fun toastColors(variant: ToastVariant): ToastColors = when (variant) {
ToastVariant.Success -> ToastColors(
container = Success,
content = TextPrimaryDark,
action = TextPrimaryDark
)
ToastVariant.Error -> ToastColors(
container = Error,
content = TextPrimaryDark,
action = TextPrimaryDark
)
ToastVariant.Warning -> ToastColors(
container = Warning,
content = TextPrimaryDark,
action = TextPrimaryDark
)
ToastVariant.Info -> ToastColors(
container = Info,
content = TextPrimaryDark,
action = TextPrimaryDark
)
}
@Composable
fun ShieldToastHost(
hostState: SnackbarHostState,
modifier: Modifier = Modifier
) {
SnackbarHost(
hostState = hostState,
modifier = modifier,
snackbar = { data: SnackbarData ->
val visuals = data.visuals as? ShieldSnackbarVisuals
val colors = visuals?.let { toastColors(it.variant) }
?: toastColors(ToastVariant.Info)
Snackbar(
snackbarData = data,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
shape = RoundedCornerShape(12.dp),
containerColor = colors.container,
contentColor = colors.content,
actionColor = colors.action
)
}
)
}
class ShieldSnackbarVisuals(
message: String,
actionLabel: String? = null,
duration: SnackbarDuration = SnackbarDuration.Short,
val variant: ToastVariant = ToastVariant.Info
) : SnackbarVisuals {
override val message: String = message
override val actionLabel: String? = actionLabel
override val duration: SnackbarDuration = duration
override val withDismissAction: Boolean = false
}

View File

@@ -0,0 +1,94 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
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.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.Error
import com.shieldai.android.ui.theme.Success
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.toPx() / 2, size.toPx() / 2)
val radius = center.x - 16.dp.toPx()
val strokeWidth = 16.dp.toPx()
drawArc(
color = Color(0xFFE2E8F0).copy(alpha = 0.3f),
startAngle = -135f,
sweepAngle = 270f,
useCenter = false,
topLeft = Offset(center.x - radius, center.y - radius),
size = Size(radius * 2, radius * 2),
style = Stroke(width = strokeWidth)
)
val sweepAngle = (score.coerceIn(0, 100) / 100f) * 270f
if (sweepAngle > 0) {
drawArc(
color = startColor,
startAngle = -135f,
sweepAngle = sweepAngle,
useCenter = false,
topLeft = Offset(center.x - radius, center.y - radius),
size = Size(radius * 2, radius * 2),
style = Stroke(width = strokeWidth)
)
}
}
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)
)
}
}

View File

@@ -0,0 +1,112 @@
package com.shieldai.android.ui.screens.auth
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.R
import com.shieldai.android.ui.components.ShieldCard
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun AuthScreen(
viewModel: AuthViewModel,
onNavigateToForgotPassword: () -> Unit,
onNavigateToResetPassword: () -> Unit
) {
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf("Login", "Sign Up")
val uiState by viewModel.uiState.collectAsState()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(64.dp))
Image(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = "Kordant Logo",
modifier = Modifier.size(72.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Kordant",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Your all-in-one digital protection",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
TabRow(
selectedTabIndex = selectedTab,
modifier = Modifier.fillMaxWidth()
) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) }
)
}
}
Spacer(modifier = Modifier.height(16.dp))
ShieldCard(
modifier = Modifier.fillMaxWidth()
) {
if (selectedTab == 0) {
LoginScreen(
viewModel = viewModel,
onNavigateToForgotPassword = onNavigateToForgotPassword,
uiState = uiState
)
} else {
SignupScreen(
viewModel = viewModel,
uiState = uiState
)
}
}
}
}
}

View File

@@ -0,0 +1,89 @@
package com.shieldai.android.ui.screens.auth
import android.content.Context
import android.security.identity.IdentityCredentialException
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.FragmentActivity
@Composable
fun BiometricAuthScreen(
onAuthenticated: () -> Unit,
onError: (String) -> Unit,
title: String = "Biometric Authentication",
subtitle: String = "Authenticate to access Kordant",
description: String = "Use your fingerprint or face to sign in"
) {
val context = LocalContext.current
val activity = context as? FragmentActivity
val biometricManager = remember {
BiometricManager.from(context)
}
val authenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.BIOMETRIC_WEAK or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
val canAuthenticate = biometricManager.canAuthenticate(authenticators)
val promptInfo = remember {
BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setDescription(description)
.setAllowedAuthenticators(authenticators)
.build()
}
DisposableEffect(activity) {
if (activity != null && canAuthenticate == BiometricManager.BIOMETRIC_SUCCESS) {
val biometricPrompt = BiometricPrompt(
activity,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onAuthenticated()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON &&
errorCode != BiometricPrompt.ERROR_USER_CANCELED
) {
onError(errString.toString())
}
}
override fun onAuthenticationFailed() {
onError("Authentication failed")
}
}
)
biometricPrompt.authenticate(promptInfo)
}
onDispose { }
}
}
fun canUseBiometric(context: Context): Boolean {
val biometricManager = BiometricManager.from(context)
val authenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.BIOMETRIC_WEAK or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
return biometricManager.canAuthenticate(authenticators) == BiometricManager.BIOMETRIC_SUCCESS
}
fun isBiometricEnabled(context: Context): Boolean {
val prefs = context.getSharedPreferences("shieldai_biometric_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean("biometric_enabled", false)
}
fun setBiometricEnabled(context: Context, enabled: Boolean) {
val prefs = context.getSharedPreferences("shieldai_biometric_prefs", Context.MODE_PRIVATE)
prefs.edit().putBoolean("biometric_enabled", enabled).apply()
}

View File

@@ -0,0 +1,139 @@
package com.shieldai.android.ui.screens.auth
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.InputType
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.ShieldTextField
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun ForgotPasswordScreen(
viewModel: AuthViewModel,
onBack: () -> Unit
) {
var email by remember { mutableStateOf("") }
val uiState by viewModel.uiState.collectAsState()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(48.dp))
if (uiState.forgotPasswordSent) {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Check Your Email",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "We've sent password reset instructions to $email",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
ShieldButton(
text = "Back to Login",
onClick = onBack,
modifier = Modifier.fillMaxWidth(),
fullWidth = true
)
}
}
} else {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Reset Password",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Enter your email address and we'll send you instructions to reset your password.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
ShieldTextField(
value = email,
onValueChange = { email = it },
label = "Email",
inputType = InputType.Email,
placeholder = "you@example.com"
)
Spacer(modifier = Modifier.height(16.dp))
ShieldButton(
text = "Send Reset Instructions",
onClick = { viewModel.forgotPassword(email) },
modifier = Modifier.fillMaxWidth(),
loading = uiState.isLoading,
enabled = email.isNotBlank(),
fullWidth = true
)
if (uiState.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = uiState.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(12.dp))
ShieldButton(
text = "Back to Login",
onClick = onBack,
modifier = Modifier.fillMaxWidth(),
variant = ShieldButtonVariant.Ghost,
fullWidth = true
)
}
}
}
}
}
}

View File

@@ -0,0 +1,183 @@
package com.shieldai.android.ui.screens.auth
import android.app.Activity
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInClient
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldTextField
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.viewmodel.AuthUiState
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun LoginScreen(
viewModel: AuthViewModel,
onNavigateToForgotPassword: () -> Unit,
uiState: AuthUiState
) {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var rememberMe by remember { mutableStateOf(false) }
val context = LocalContext.current
val gso = remember {
GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken(context.getString(com.shieldai.android.R.string.default_web_client_id))
.requestEmail()
.build()
}
val googleSignInClient: GoogleSignInClient = remember {
GoogleSignIn.getClient(context, gso)
}
val googleSignInLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val data = result.data
val task = GoogleSignIn.getSignedInAccountFromIntent(data)
try {
val account = task.getResult(ApiException::class.java)
account.idToken?.let { token ->
viewModel.signInWithGoogle(token)
}
} catch (_: ApiException) { }
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
ShieldTextField(
value = email,
onValueChange = { email = it },
label = "Email",
inputType = InputType.Email,
placeholder = "you@example.com"
)
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = password,
onValueChange = { password = it },
label = "Password",
inputType = InputType.Password,
placeholder = "Enter your password"
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = rememberMe,
onCheckedChange = { rememberMe = it }
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Remember me",
style = MaterialTheme.typography.bodySmall
)
}
TextButton(onClick = onNavigateToForgotPassword) {
Text(
text = "Forgot password?",
style = MaterialTheme.typography.bodySmall,
color = BrandPrimary
)
}
}
Spacer(modifier = Modifier.height(16.dp))
ShieldButton(
text = "Sign In",
onClick = { viewModel.login(email, password) },
modifier = Modifier.fillMaxWidth(),
loading = uiState.isLoading,
fullWidth = true
)
if (uiState.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = uiState.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "or continue with",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = {
val signInIntent = googleSignInClient.signInIntent
googleSignInLauncher.launch(signInIntent)
},
modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface)
) {
Text(
text = "Sign in with Google",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium
)
}
}
}

View File

@@ -0,0 +1,151 @@
package com.shieldai.android.ui.screens.auth
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldCard
import com.shieldai.android.ui.components.ShieldTextField
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun ResetPasswordScreen(
viewModel: AuthViewModel,
email: String,
onBack: () -> Unit
) {
var code by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
val uiState by viewModel.uiState.collectAsState()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(48.dp))
if (uiState.resetPasswordSuccess) {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Password Reset Successful",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Your password has been reset. You can now log in with your new password.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
ShieldButton(
text = "Back to Login",
onClick = onBack,
modifier = Modifier.fillMaxWidth(),
fullWidth = true
)
}
}
} else {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Set New Password",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Enter the reset code sent to your email and your new password.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
ShieldTextField(
value = code,
onValueChange = { code = it },
label = "Reset Code",
placeholder = "Enter the code from email"
)
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = newPassword,
onValueChange = { newPassword = it },
label = "New Password",
inputType = InputType.Password,
placeholder = "Enter new password"
)
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = "Confirm New Password",
inputType = InputType.Password,
placeholder = "Re-enter new password",
isError = confirmPassword.isNotEmpty() && newPassword != confirmPassword,
errorMessage = if (confirmPassword.isNotEmpty() && newPassword != confirmPassword) "Passwords do not match" else null
)
Spacer(modifier = Modifier.height(16.dp))
ShieldButton(
text = "Reset Password",
onClick = { viewModel.resetPassword(email, code, newPassword) },
modifier = Modifier.fillMaxWidth(),
loading = uiState.isLoading,
enabled = code.isNotBlank() && newPassword.isNotBlank()
&& newPassword == confirmPassword,
fullWidth = true
)
if (uiState.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = uiState.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,153 @@
package com.shieldai.android.ui.screens.auth
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ProgressColor
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldProgressBar
import com.shieldai.android.ui.components.ShieldTextField
import com.shieldai.android.util.PasswordStrength
import com.shieldai.android.util.calculatePasswordStrength
import com.shieldai.android.util.passwordStrengthLabel
import com.shieldai.android.viewmodel.AuthUiState
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun SignupScreen(
viewModel: AuthViewModel,
uiState: AuthUiState
) {
var name by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var acceptTerms by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
ShieldTextField(
value = name,
onValueChange = { name = it },
label = "Full Name",
placeholder = "John Doe"
)
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = email,
onValueChange = { email = it },
label = "Email",
inputType = InputType.Email,
placeholder = "you@example.com"
)
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = password,
onValueChange = {
password = it
viewModel.updatePasswordStrength(it)
},
label = "Password",
inputType = InputType.Password,
placeholder = "Create a strong password"
)
if (password.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
val strength = calculatePasswordStrength(password)
ShieldProgressBar(
progress = when (strength) {
PasswordStrength.WEAK -> 0.25f
PasswordStrength.FAIR -> 0.5f
PasswordStrength.STRONG -> 0.75f
PasswordStrength.VERY_STRONG -> 1.0f
},
color = when (strength) {
PasswordStrength.WEAK -> ProgressColor.Error
PasswordStrength.FAIR -> ProgressColor.Warning
PasswordStrength.STRONG -> ProgressColor.Success
PasswordStrength.VERY_STRONG -> ProgressColor.Success
},
showPercentage = false
)
Text(
text = "Password strength: ${passwordStrengthLabel(strength)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = "Confirm Password",
inputType = InputType.Password,
placeholder = "Re-enter your password",
isError = confirmPassword.isNotEmpty() && password != confirmPassword,
errorMessage = if (confirmPassword.isNotEmpty() && password != confirmPassword) "Passwords do not match" else null
)
Spacer(modifier = Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = acceptTerms,
onCheckedChange = { acceptTerms = it }
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "I accept the Terms of Service and Privacy Policy",
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(16.dp))
ShieldButton(
text = "Create Account",
onClick = { viewModel.signup(name, email, password) },
modifier = Modifier.fillMaxWidth(),
loading = uiState.isLoading,
enabled = name.isNotBlank() && email.isNotBlank() && password.isNotBlank()
&& password == confirmPassword && acceptTerms,
fullWidth = true
)
if (uiState.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = uiState.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
}

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,333 @@
package com.shieldai.android.ui.screens.dashboard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxState
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.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.ShieldSkeletonCard
import com.shieldai.android.ui.components.ThreatGauge
import com.shieldai.android.viewmodel.DashboardViewModel
import com.shieldai.android.viewmodel.DashboardViewModel as DashboardVM
import kotlinx.coroutines.launch
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()
val scope = rememberCoroutineScope()
Box(
modifier = modifier.fillMaxSize()
) {
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 = {
ShieldButton(
text = "Retry",
onClick = { viewModel.refresh() },
variant = ShieldButtonVariant.Primary
)
}
)
} else {
ShieldEmptyState(
title = "No data",
description = "No dashboard data available"
)
}
}
else -> {
DashboardContent(
uiState = uiState,
onNavigateToAlert = onNavigateToAlert,
onNavigateToService = onNavigateToService,
onRefresh = {
scope.launch {
viewModel.refresh()
}
}
)
}
}
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 16.dp),
color = MaterialTheme.colorScheme.primary
)
}
}
}
@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() }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DashboardContent(
uiState: DashboardViewModel.DashboardUiState,
onNavigateToAlert: (String) -> Unit,
onNavigateToService: (String) -> Unit,
onRefresh: () -> Unit
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
if (value == SwipeToDismissBoxValue.EndToStart) {
onRefresh()
true
} else {
false
}
}
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
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,135 @@
package com.shieldai.android.ui.screens.onboarding
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.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.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.ui.theme.Success
@Composable
fun CompleteStep(onComplete: () -> Unit) {
val animatedProgress = remember { Animatable(0f) }
LaunchedEffect(Unit) {
animatedProgress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 1000)
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center
) {
Canvas(
modifier = Modifier.size(120.dp)
) {
val strokeWidth = 8.dp.toPx()
val radius = (size.minDimension - strokeWidth) / 2
val center = Offset(size.width / 2, size.height / 2)
drawCircle(
color = Color.LightGray.copy(alpha = 0.3f),
radius = radius,
center = center,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
drawArc(
color = Success,
startAngle = -90f,
sweepAngle = 360f * animatedProgress.value,
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 = StrokeCap.Round)
)
if (animatedProgress.value >= 0.5f) {
val checkProgress = (animatedProgress.value - 0.5f) * 2f
val startX = center.x - radius * 0.35f
val midX = center.x - radius * 0.05f
val endX = center.x + radius * 0.5f
val startY = center.y
val midY = center.y + radius * 0.35f * checkProgress
val endY = center.y - radius * 0.3f * checkProgress
drawLine(
color = Success,
start = Offset(startX, startY),
end = Offset(midX, midY.coerceAtMost(startY + radius * 0.35f)),
strokeWidth = 6.dp.toPx(),
cap = StrokeCap.Round
)
if (animatedProgress.value >= 0.75f) {
val endCheckProgress = (animatedProgress.value - 0.75f) * 4f
drawLine(
color = Success,
start = Offset(midX, midY.coerceAtMost(startY + radius * 0.35f)),
end = Offset(
startX + (endX - startX) * endCheckProgress * 0.5f + radius * 0.5f * endCheckProgress,
endY.coerceAtLeast(startY - radius * 0.3f * endCheckProgress)
),
strokeWidth = 6.dp.toPx(),
cap = StrokeCap.Round
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
Text(
text = "You're All Set!",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Your account is ready. Start monitoring your digital footprint and stay protected.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(40.dp))
ShieldButton(
text = "Get Started",
onClick = onComplete,
modifier = Modifier.fillMaxWidth(),
fullWidth = true
)
}
}

View File

@@ -0,0 +1,132 @@
package com.shieldai.android.ui.screens.onboarding
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldButtonVariant
import com.shieldai.android.ui.components.ShieldTextField
@Composable
fun FamilyInviteStep(
invites: List<String>,
onAddInvite: (String) -> Unit,
onRemoveInvite: (Int) -> Unit
) {
var emailInput by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Text(
text = "Invite Family Members",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Protect your family too. Add their emails to include them in your plan.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top
) {
ShieldTextField(
value = emailInput,
onValueChange = { emailInput = it },
label = "Family Email",
inputType = InputType.Email,
placeholder = "family@example.com",
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
ShieldButton(
text = "Invite",
onClick = {
if (emailInput.isNotBlank()) {
onAddInvite(emailInput.trim())
emailInput = ""
}
},
variant = ShieldButtonVariant.Primary,
enabled = emailInput.isNotBlank(),
modifier = Modifier.padding(top = 6.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
if (invites.isEmpty()) {
Text(
text = "No invites sent yet. You can skip this step and invite later.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp)
)
} else {
invites.forEachIndexed { index, email ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = email,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { onRemoveInvite(index) }) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "You can always invite more family members later from Settings.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}

View File

@@ -0,0 +1,120 @@
package com.shieldai.android.ui.screens.onboarding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun OnboardingScreen(
viewModel: AuthViewModel,
onComplete: () -> Unit
) {
val pagerState = rememberPagerState(pageCount = { 4 })
val onboardingData by viewModel.onboardingData.collectAsState()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier.fillMaxSize()
) {
HorizontalPager(
state = pagerState,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) { page ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp, vertical = 16.dp),
contentAlignment = Alignment.Center
) {
when (page) {
0 -> PlanSelectionStep(
selectedPlan = onboardingData.selectedPlan,
onPlanSelected = { plan ->
viewModel.updateOnboardingData {
it.copy(selectedPlan = plan)
}
}
)
1 -> WatchlistSetupStep(
watchlistItems = onboardingData.watchlistItems,
onAddItem = { item ->
viewModel.updateOnboardingData {
it.copy(watchlistItems = it.watchlistItems + item)
}
},
onRemoveItem = { index ->
viewModel.updateOnboardingData {
it.copy(watchlistItems = it.watchlistItems.toMutableList().apply { removeAt(index) })
}
}
)
2 -> FamilyInviteStep(
invites = onboardingData.familyInvites,
onAddInvite = { email ->
viewModel.updateOnboardingData {
it.copy(familyInvites = it.familyInvites + email)
}
},
onRemoveInvite = { index ->
viewModel.updateOnboardingData {
it.copy(familyInvites = it.familyInvites.toMutableList().apply { removeAt(index) })
}
}
)
3 -> CompleteStep(onComplete = onComplete)
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
repeat(4) { index ->
val isSelected = pagerState.currentPage == index
Box(
modifier = Modifier
.padding(horizontal = 4.dp)
.size(if (isSelected) 10.dp else 8.dp)
.clip(CircleShape)
.drawBehind {
drawCircle(
color = if (isSelected) BrandPrimary
else Color.Gray.copy(alpha = 0.3f)
)
}
)
}
}
}
}
}

View File

@@ -0,0 +1,181 @@
package com.shieldai.android.ui.screens.onboarding
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandPrimary
data class Plan(
val name: String,
val price: String,
val features: List<String>,
val description: String
)
private val plans = listOf(
Plan(
name = "Basic",
price = "Free",
features = listOf(
"Monitor 1 email/phone",
"Basic alerts",
"7-day data history"
),
description = "Essential protection"
),
Plan(
name = "Plus",
price = "$9.99/mo",
features = listOf(
"Monitor up to 5 emails/phones",
"Real-time alerts",
"30-day data history",
"Family sharing (2 members)"
),
description = "Enhanced protection"
),
Plan(
name = "Premium",
price = "$19.99/mo",
features = listOf(
"Unlimited monitoring",
"Priority alerts",
"90-day data history",
"Family sharing (5 members)",
"Dark web monitoring",
"Identity restoration support"
),
description = "Maximum protection"
)
)
@Composable
fun PlanSelectionStep(
selectedPlan: String,
onPlanSelected: (String) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Text(
text = "Choose Your Plan",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Select the plan that fits your needs. You can upgrade or change anytime.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
plans.forEach { plan ->
val isSelected = selectedPlan == plan.name
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.clickable { onPlanSelected(plan.name) },
shape = MaterialTheme.shapes.medium,
border = BorderStroke(
width = if (isSelected) 2.dp else 1.dp,
color = if (isSelected) BrandPrimary else MaterialTheme.colorScheme.outline
),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
BrandPrimary.copy(alpha = 0.08f)
} else {
MaterialTheme.colorScheme.surface
}
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Top
) {
RadioButton(
selected = isSelected,
onClick = { onPlanSelected(plan.name) },
colors = RadioButtonDefaults.colors(
selectedColor = BrandPrimary
)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = plan.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = plan.price,
style = MaterialTheme.typography.titleMedium,
color = BrandPrimary,
fontWeight = FontWeight.Bold
)
}
Text(
text = plan.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
plan.features.forEach { feature ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "",
color = BrandPrimary,
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = feature,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,123 @@
package com.shieldai.android.ui.screens.onboarding
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldButtonVariant
import com.shieldai.android.ui.components.ShieldTextField
@Composable
fun WatchlistSetupStep(
watchlistItems: List<String>,
onAddItem: (String) -> Unit,
onRemoveItem: (Int) -> Unit
) {
var inputValue by remember { mutableStateOf("") }
var inputType by remember { mutableStateOf(InputType.Email) }
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Text(
text = "Set Up Your Watchlist",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Add email addresses or phone numbers you want to monitor for breaches and leaks.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top
) {
ShieldTextField(
value = inputValue,
onValueChange = { inputValue = it },
label = "Email or Phone",
inputType = inputType,
placeholder = "you@example.com or +1234567890",
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
ShieldButton(
text = "Add",
onClick = {
if (inputValue.isNotBlank()) {
onAddItem(inputValue.trim())
inputValue = ""
}
},
variant = ShieldButtonVariant.Primary,
enabled = inputValue.isNotBlank(),
modifier = Modifier.padding(top = 6.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
if (watchlistItems.isEmpty()) {
Text(
text = "No items added yet. Add emails or phones to start monitoring.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp)
)
} else {
watchlistItems.forEachIndexed { index, item ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = item,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { onRemoveItem(index) }) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
}

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,264 @@
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.HomeTitleViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeTitleScreen(
onBack: () -> Unit = {},
modifier: Modifier = Modifier,
viewModel: HomeTitleViewModel = viewModel(factory = HomeTitleViewModel.Factory)
) {
val uiState by viewModel.uiState.collectAsState()
var showAddSheet by remember { mutableStateOf(false) }
var newAddress by remember { mutableStateOf("") }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState()
)
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text("HomeTitle", 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 property"
)
}
}
}
) { paddingValues ->
when {
uiState.isLoading && uiState.properties.isEmpty() -> {
androidx.compose.foundation.layout.Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
uiState.properties.isEmpty() -> {
ShieldEmptyState(
title = "No properties",
description = "Add properties to monitor for title fraud",
actionButton = {
ShieldButton(
text = "Add Property",
onClick = { showAddSheet = true },
variant = ShieldButtonVariant.Primary
)
},
modifier = Modifier.padding(paddingValues)
)
}
else -> {
HomeTitleContent(
uiState = uiState,
modifier = Modifier.padding(paddingValues)
)
}
}
if (showAddSheet) {
AddPropertySheet(
onDismiss = {
showAddSheet = false
newAddress = ""
},
onAdd = {
viewModel.addProperty(newAddress)
showAddSheet = false
newAddress = ""
},
address = newAddress,
onAddressChange = { newAddress = it },
isLoading = uiState.isAdding
)
}
}
}
@Composable
private fun HomeTitleContent(
uiState: HomeTitleViewModel.HomeTitleUiState,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Text(
text = "Properties (${uiState.properties.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
}
items(uiState.properties) { property ->
PropertyCard(property)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@Composable
private fun PropertyCard(property: com.shieldai.android.data.model.Property) {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = property.address,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
property.ownerName?.let {
Text(
text = "Owner: $it",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ShieldBadge(
text = property.type,
variant = BadgeVariant.Info
)
property.county?.let {
ShieldBadge(
text = it,
variant = BadgeVariant.Default
)
}
}
}
ShieldBadge(
text = property.status,
variant = if (property.status == "monitored") BadgeVariant.Success
else BadgeVariant.Default
)
}
property.updatedAt?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Updated: $it",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddPropertySheet(
onDismiss: () -> Unit,
onAdd: () -> Unit,
address: String,
onAddressChange: (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 Property",
style = MaterialTheme.typography.titleLarge
)
ShieldTextField(
value = address,
onValueChange = onAddressChange,
label = "Property address",
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 = address.isNotBlank(),
loading = isLoading
)
}
}
}
}

View File

@@ -0,0 +1,327 @@
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.RemoveBrokersViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RemoveBrokersScreen(
onBack: () -> Unit = {},
modifier: Modifier = Modifier,
viewModel: RemoveBrokersViewModel = viewModel(factory = RemoveBrokersViewModel.Factory)
) {
val uiState by viewModel.uiState.collectAsState()
var showCreateSheet by remember { mutableStateOf(false) }
var selectedListingId by remember { mutableStateOf("") }
var selectedListingName by remember { mutableStateOf("") }
var notes by remember { mutableStateOf("") }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState()
)
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text("RemoveBrokers", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
TextButton(onClick = onBack) { Text("Back") }
},
scrollBehavior = scrollBehavior
)
},
floatingActionButton = {
if (!showCreateSheet) {
FloatingActionButton(onClick = { showCreateSheet = true }) {
Icon(
painter = painterResource(R.drawable.ic_dashboard),
contentDescription = "Start removal"
)
}
}
}
) { paddingValues ->
when {
uiState.isLoading && uiState.listings.isEmpty() -> {
androidx.compose.foundation.layout.Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
uiState.listings.isEmpty() && uiState.removalRequests.isEmpty() -> {
ShieldEmptyState(
title = "No listings",
description = "No broker listings found. Start a removal request to get started.",
actionButton = {
ShieldButton(
text = "Start Removal",
onClick = { showCreateSheet = true },
variant = ShieldButtonVariant.Primary
)
},
modifier = Modifier.padding(paddingValues)
)
}
else -> {
RemoveBrokersContent(
uiState = uiState,
modifier = Modifier.padding(paddingValues)
)
}
}
if (showCreateSheet) {
CreateRemovalSheet(
onDismiss = {
showCreateSheet = false
selectedListingId = ""
selectedListingName = ""
notes = ""
},
onCreate = {
viewModel.createRemovalRequest(selectedListingId, notes.ifBlank { null })
showCreateSheet = false
selectedListingId = ""
selectedListingName = ""
notes = ""
},
listingName = selectedListingName,
onListingNameChange = { selectedListingName = it },
notes = notes,
onNotesChange = { notes = it },
isLoading = uiState.isCreating
)
}
}
}
@Composable
private fun RemoveBrokersContent(
uiState: RemoveBrokersViewModel.RemoveBrokersUiState,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (uiState.listings.isNotEmpty()) {
item {
Text(
text = "Broker Listings (${uiState.listings.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
}
items(uiState.listings) { listing ->
ListingCard(listing)
Spacer(modifier = Modifier.height(8.dp))
}
}
if (uiState.removalRequests.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Removal Requests (${uiState.removalRequests.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
}
items(uiState.removalRequests) { request ->
RemovalRequestCard(request)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@Composable
private fun ListingCard(listing: com.shieldai.android.data.model.BrokerListing) {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = listing.brokerName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
ShieldBadge(
text = listing.status,
variant = if (listing.status == "active") BadgeVariant.Warning
else BadgeVariant.Default
)
}
listing.propertyAddress?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
listing.dateFound?.let {
Text(
text = "Found: $it",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun RemovalRequestCard(request: com.shieldai.android.data.model.RemovalRequest) {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Request #${request.id.take(8)}",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
ShieldBadge(
text = request.status,
variant = when (request.status.lowercase()) {
"completed" -> BadgeVariant.Success
"pending" -> BadgeVariant.Warning
"in_progress" -> BadgeVariant.Info
else -> BadgeVariant.Default
}
)
}
request.submittedDate?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Submitted: $it",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
request.notes?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CreateRemovalSheet(
onDismiss: () -> Unit,
onCreate: () -> Unit,
listingName: String,
onListingNameChange: (String) -> Unit,
notes: String,
onNotesChange: (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 = "Start Removal Request",
style = MaterialTheme.typography.titleLarge
)
ShieldTextField(
value = listingName,
onValueChange = onListingNameChange,
label = "Broker / Listing name",
modifier = Modifier.fillMaxWidth()
)
ShieldTextField(
value = notes,
onValueChange = onNotesChange,
label = "Notes (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 = "Submit",
onClick = onCreate,
variant = ShieldButtonVariant.Primary,
modifier = Modifier.weight(1f),
enabled = listingName.isNotBlank(),
loading = isLoading
)
}
}
}
}

View File

@@ -0,0 +1,338 @@
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.Switch
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.SpamShieldViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SpamShieldScreen(
onBack: () -> Unit = {},
modifier: Modifier = Modifier,
viewModel: SpamShieldViewModel = viewModel(factory = SpamShieldViewModel.Factory)
) {
val uiState by viewModel.uiState.collectAsState()
var showCreateSheet by remember { mutableStateOf(false) }
var newPattern by remember { mutableStateOf("") }
var newAction by remember { mutableStateOf("block") }
var newDescription by remember { mutableStateOf("") }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState()
)
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text("SpamShield", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
TextButton(onClick = onBack) { Text("Back") }
},
scrollBehavior = scrollBehavior
)
},
floatingActionButton = {
if (!showCreateSheet) {
FloatingActionButton(onClick = { showCreateSheet = true }) {
Icon(
painter = painterResource(R.drawable.ic_dashboard),
contentDescription = "Create rule"
)
}
}
}
) { paddingValues ->
when {
uiState.isLoading && uiState.rules.isEmpty() -> {
androidx.compose.foundation.layout.Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
else -> {
SpamShieldContent(
uiState = uiState,
modifier = Modifier.padding(paddingValues)
)
}
}
if (showCreateSheet) {
CreateRuleSheet(
onDismiss = {
showCreateSheet = false
newPattern = ""
newDescription = ""
},
onCreate = {
viewModel.createRule(newPattern, newAction, newDescription.ifBlank { null })
showCreateSheet = false
newPattern = ""
newDescription = ""
},
pattern = newPattern,
onPatternChange = { newPattern = it },
action = newAction,
onActionChange = { newAction = it },
description = newDescription,
onDescriptionChange = { newDescription = it },
isLoading = uiState.isCreating
)
}
}
}
@Composable
private fun SpamShieldContent(
uiState: SpamShieldViewModel.SpamShieldUiState,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
SpamStatsRow(
blocked = uiState.totalBlocked,
flagged = uiState.totalFlagged,
active = uiState.activeRules
)
}
if (uiState.rules.isNotEmpty()) {
item {
Text(
text = "Rules (${uiState.rules.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
}
items(uiState.rules) { rule ->
RuleCard(rule) { enabled ->
viewModel.toggleRule(rule.id, enabled)
}
Spacer(modifier = Modifier.height(8.dp))
}
} else {
item {
ShieldEmptyState(
title = "No rules",
description = "Create spam filtering rules to protect your phone",
actionButton = {
ShieldButton(
text = "Create Rule",
onClick = { /* handled by parent */ },
variant = ShieldButtonVariant.Primary
)
}
)
}
}
}
}
@Composable
private fun SpamStatsRow(
blocked: Int,
flagged: Int,
active: Int
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatCard("Blocked", blocked, modifier = Modifier.weight(1f))
StatCard("Flagged", flagged, modifier = Modifier.weight(1f))
StatCard("Active", active, modifier = Modifier.weight(1f))
}
}
@Composable
private fun StatCard(
label: String,
value: Int,
modifier: Modifier = Modifier
) {
ShieldCard(
modifier = modifier
) {
Column(
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally
) {
Text(
text = "$value",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun RuleCard(
rule: com.shieldai.android.data.model.SpamRule,
onToggle: (Boolean) -> Unit
) {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = rule.pattern,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ShieldBadge(
text = rule.action,
variant = if (rule.action == "block") com.shieldai.android.ui.components.BadgeVariant.Error
else com.shieldai.android.ui.components.BadgeVariant.Warning
)
if (rule.priority > 0) {
ShieldBadge(
text = "P${rule.priority}",
variant = com.shieldai.android.ui.components.BadgeVariant.Info
)
}
}
rule.description?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Switch(
checked = rule.enabled,
onCheckedChange = onToggle
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CreateRuleSheet(
onDismiss: () -> Unit,
onCreate: () -> Unit,
pattern: String,
onPatternChange: (String) -> Unit,
action: String,
onActionChange: (String) -> Unit,
description: String,
onDescriptionChange: (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 = "Create Spam Rule",
style = MaterialTheme.typography.titleLarge
)
ShieldTextField(
value = pattern,
onValueChange = onPatternChange,
label = "Pattern (phone number or keyword)",
modifier = Modifier.fillMaxWidth()
)
ShieldTextField(
value = action,
onValueChange = onActionChange,
label = "Action (block, flag, log)",
modifier = Modifier.fillMaxWidth()
)
ShieldTextField(
value = description,
onValueChange = onDescriptionChange,
label = "Description (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 = "Create",
onClick = onCreate,
variant = ShieldButtonVariant.Primary,
modifier = Modifier.weight(1f),
enabled = pattern.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,346 @@
package com.shieldai.android.ui.screens.settings
import androidx.compose.foundation.clickable
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.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
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.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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.shieldai.android.ui.components.ShieldAvatar
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.viewmodel.AuthViewModel
import com.shieldai.android.viewmodel.SettingsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit = {},
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory),
authViewModel: AuthViewModel = viewModel(factory = AuthViewModel.Factory)
) {
val uiState by viewModel.uiState.collectAsState()
var showLogoutDialog by remember { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState()
)
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text("Settings", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
TextButton(onClick = onBack) { Text("Back") }
},
scrollBehavior = scrollBehavior
)
}
) { paddingValues ->
when {
uiState.isLoading -> {
androidx.compose.foundation.layout.Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
uiState.user == null -> {
ShieldEmptyState(
title = "Failed to load settings",
description = uiState.error ?: "Unable to load your settings",
actionButton = {
TextButton(onClick = { viewModel.refresh() }) {
Text("Retry")
}
},
modifier = Modifier.padding(paddingValues)
)
}
else -> {
SettingsContent(
uiState = uiState,
onToggleNotifications = { viewModel.toggleNotifications(it) },
onToggleDarkMode = { viewModel.toggleDarkMode(it) },
onToggleBiometric = { viewModel.toggleBiometric(it) },
onUpgradeSubscription = { viewModel.upgradeSubscription() },
onShowLogoutDialog = { showLogoutDialog = true },
modifier = Modifier.padding(paddingValues)
)
}
}
if (showLogoutDialog) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showLogoutDialog = false },
title = { Text("Logout") },
text = { Text("Are you sure you want to logout?") },
confirmButton = {
TextButton(
onClick = {
authViewModel.logout()
showLogoutDialog = false
}
) {
Text(
text = "Logout",
color = MaterialTheme.colorScheme.error
)
}
},
dismissButton = {
TextButton(onClick = { showLogoutDialog = false }) {
Text("Cancel")
}
}
)
}
}
}
@Composable
private fun SettingsContent(
uiState: SettingsViewModel.SettingsUiState,
onToggleNotifications: (Boolean) -> Unit,
onToggleDarkMode: (Boolean) -> Unit,
onToggleBiometric: (Boolean) -> Unit,
onUpgradeSubscription: () -> Unit,
onShowLogoutDialog: () -> Unit,
modifier: Modifier = Modifier
) {
val user = uiState.user!!
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
AccountSection(user)
}
item {
SubscriptionSection(
subscription = uiState.subscription,
onUpgrade = onUpgradeSubscription
)
}
item {
PreferencesSection(
notificationsEnabled = uiState.notificationsEnabled,
darkModeEnabled = uiState.darkModeEnabled,
biometricEnabled = uiState.biometricEnabled,
onToggleNotifications = onToggleNotifications,
onToggleDarkMode = onToggleDarkMode,
onToggleBiometric = onToggleBiometric
)
}
item {
Spacer(modifier = Modifier.height(16.dp))
ShieldButton(
text = "Logout",
onClick = onShowLogoutDialog,
variant = ShieldButtonVariant.Danger,
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun AccountSection(user: com.shieldai.android.data.model.User) {
Column {
Text(
text = "Account",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
ShieldAvatar(
name = user.name,
imageUrl = user.avatarUrl
)
Column {
Text(
text = user.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Text(
text = user.email,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (user.emailVerified) {
ShieldBadge(text = "Email verified", variant = com.shieldai.android.ui.components.BadgeVariant.Success)
}
if (user.phoneVerified) {
ShieldBadge(text = "Phone verified", variant = com.shieldai.android.ui.components.BadgeVariant.Success)
}
}
}
}
}
}
@Composable
private fun SubscriptionSection(
subscription: com.shieldai.android.data.model.Subscription?,
onUpgrade: () -> Unit
) {
Column {
Text(
text = "Subscription",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
ShieldCard {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = subscription?.plan ?: "Free",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Text(
text = subscription?.status ?: "No subscription",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
ShieldButton(
text = "Upgrade",
onClick = onUpgrade,
variant = ShieldButtonVariant.Secondary,
size = com.shieldai.android.ui.components.ShieldButtonSize.Small
)
}
}
}
}
@Composable
private fun PreferencesSection(
notificationsEnabled: Boolean,
darkModeEnabled: Boolean,
biometricEnabled: Boolean,
onToggleNotifications: (Boolean) -> Unit,
onToggleDarkMode: (Boolean) -> Unit,
onToggleBiometric: (Boolean) -> Unit
) {
Column {
Text(
text = "Preferences",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
ShieldCard {
Column {
SettingRow(
title = "Notifications",
description = "Receive push notifications for alerts",
checked = notificationsEnabled,
onCheckedChange = onToggleNotifications
)
Divider()
SettingRow(
title = "Dark Mode",
description = "Use dark theme",
checked = darkModeEnabled,
onCheckedChange = onToggleDarkMode
)
Divider()
SettingRow(
title = "Biometric Auth",
description = "Use fingerprint or face unlock",
checked = biometricEnabled,
onCheckedChange = onToggleBiometric
)
}
}
}
}
@Composable
private fun SettingRow(
title: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
.clickable { onCheckedChange(!checked) },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange
)
}
}

View File

@@ -0,0 +1,37 @@
package com.shieldai.android.ui.theme
import androidx.compose.ui.graphics.Color
val BrandPrimary = Color(0xFF4F46E5)
val BrandPrimaryLight = Color(0xFF818CF8)
val BrandPrimaryDark = Color(0xFF3730A3)
val BrandAccent = Color(0xFF06B6D4)
val BrandAccentLight = Color(0xFF67E8F9)
val BrandAccentDark = Color(0xFF0891B2)
val BgPrimaryLight = Color(0xFFFFFFFF)
val BgSecondaryLight = Color(0xFFF8FAFC)
val BgTertiaryLight = Color(0xFFF1F5F9)
val BgPrimaryDark = Color(0xFF0F172A)
val BgSecondaryDark = Color(0xFF1E293B)
val BgTertiaryDark = Color(0xFF334155)
val TextPrimaryLight = Color(0xFF0F172A)
val TextSecondaryLight = Color(0xFF475569)
val TextTertiaryLight = Color(0xFF94A3B8)
val TextPrimaryDark = Color(0xFFF1F5F9)
val TextSecondaryDark = Color(0xFF94A3B8)
val TextTertiaryDark = Color(0xFF64748B)
val Success = Color(0xFF22C55E)
val Warning = Color(0xFFF59E0B)
val Error = Color(0xFFEF4444)
val Info = Color(0xFF3B82F6)
val SurfaceLight = Color(0xFFFFFFFF)
val SurfaceDark = Color(0xFF1E293B)
val OutlineLight = Color(0xFFE2E8F0)
val OutlineDark = Color(0xFF475569)

View File

@@ -0,0 +1,11 @@
package com.shieldai.android.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val Shapes = Shapes(
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp)
)

View File

@@ -0,0 +1,80 @@
package com.shieldai.android.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val LightColorScheme = lightColorScheme(
primary = BrandPrimary,
onPrimary = BgPrimaryLight,
primaryContainer = BrandPrimaryLight,
onPrimaryContainer = BgPrimaryLight,
secondary = BrandAccent,
onSecondary = BgPrimaryLight,
secondaryContainer = BrandAccentLight,
onSecondaryContainer = BgPrimaryDark,
tertiary = BrandPrimaryDark,
onTertiary = BgPrimaryLight,
background = BgPrimaryLight,
onBackground = TextPrimaryLight,
surface = SurfaceLight,
onSurface = TextPrimaryLight,
surfaceVariant = BgSecondaryLight,
onSurfaceVariant = TextSecondaryLight,
outline = OutlineLight,
outlineVariant = BgTertiaryLight,
error = Error,
onError = BgPrimaryLight
)
private val DarkColorScheme = darkColorScheme(
primary = BrandPrimaryLight,
onPrimary = BgPrimaryDark,
primaryContainer = BrandPrimary,
onPrimaryContainer = TextPrimaryDark,
secondary = BrandAccentLight,
onSecondary = BgPrimaryDark,
secondaryContainer = BrandAccent,
onSecondaryContainer = TextPrimaryDark,
tertiary = BrandPrimaryDark,
onTertiary = TextPrimaryDark,
background = BgPrimaryDark,
onBackground = TextPrimaryDark,
surface = SurfaceDark,
onSurface = TextPrimaryDark,
surfaceVariant = BgSecondaryDark,
onSurfaceVariant = TextSecondaryDark,
outline = OutlineDark,
outlineVariant = BgTertiaryDark,
error = Error,
onError = BgPrimaryDark
)
@Composable
fun KordantTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
shapes = Shapes,
content = content
)
}

View File

@@ -0,0 +1,109 @@
package com.shieldai.android.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
displayMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 45.sp,
lineHeight = 52.sp
),
displaySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 36.sp,
lineHeight = 44.sp
),
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 32.sp,
lineHeight = 40.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 36.sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)

View File

@@ -0,0 +1,48 @@
package com.shieldai.android.util
import androidx.compose.ui.graphics.Color
import com.shieldai.android.ui.theme.Error
import com.shieldai.android.ui.theme.Success
import com.shieldai.android.ui.theme.Warning
enum class PasswordStrength {
WEAK, FAIR, STRONG, VERY_STRONG
}
fun calculatePasswordStrength(password: String): PasswordStrength {
if (password.length < 6) return PasswordStrength.WEAK
var score = 0
if (password.length >= 8) score++
if (password.length >= 12) score++
if (password.any { it.isUpperCase() }) score++
if (password.any { it.isLowerCase() }) score++
if (password.any { it.isDigit() }) score++
if (password.any { !it.isLetterOrDigit() }) score++
return when {
score <= 2 -> PasswordStrength.WEAK
score == 3 -> PasswordStrength.FAIR
score == 4 -> PasswordStrength.STRONG
else -> PasswordStrength.VERY_STRONG
}
}
fun passwordStrengthProgress(strength: PasswordStrength): Float = when (strength) {
PasswordStrength.WEAK -> 0.25f
PasswordStrength.FAIR -> 0.5f
PasswordStrength.STRONG -> 0.75f
PasswordStrength.VERY_STRONG -> 1.0f
}
fun passwordStrengthLabel(strength: PasswordStrength): String = when (strength) {
PasswordStrength.WEAK -> "Weak"
PasswordStrength.FAIR -> "Fair"
PasswordStrength.STRONG -> "Strong"
PasswordStrength.VERY_STRONG -> "Very Strong"
}
fun passwordStrengthColor(strength: PasswordStrength): Color = when (strength) {
PasswordStrength.WEAK -> Error
PasswordStrength.FAIR -> Warning
PasswordStrength.STRONG -> Success
PasswordStrength.VERY_STRONG -> Success
}

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.KordantApp
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(KordantApp.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 suspend 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,196 @@
package com.shieldai.android.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.shieldai.android.KordantApp
import com.shieldai.android.data.repository.AuthRepository
import com.shieldai.android.data.repository.AuthRepositoryImpl
import com.shieldai.android.data.repository.User
import com.shieldai.android.util.calculatePasswordStrength
import com.shieldai.android.util.passwordStrengthProgress
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class AuthUiState(
val isLoading: Boolean = false,
val error: String? = null,
val user: User? = null,
val forgotPasswordSent: Boolean = false,
val resetPasswordSuccess: Boolean = false,
val passwordStrength: Float = 0f
)
data class OnboardingData(
val selectedPlan: String = "Basic",
val watchlistItems: List<String> = emptyList(),
val familyInvites: List<String> = emptyList()
)
class AuthViewModel(
private val repository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState())
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
private val _isAuthenticated = MutableStateFlow(repository.isLoggedIn())
val isAuthenticated: StateFlow<Boolean> = _isAuthenticated.asStateFlow()
private val _isNewUser = MutableStateFlow(false)
val isNewUser: StateFlow<Boolean> = _isNewUser.asStateFlow()
private val _onboardingData = MutableStateFlow(OnboardingData())
val onboardingData: StateFlow<OnboardingData> = _onboardingData.asStateFlow()
fun login(email: String, password: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
val result = repository.login(email, password)
result.fold(
onSuccess = { user ->
_uiState.value = _uiState.value.copy(isLoading = false, user = user)
_isAuthenticated.value = true
_isNewUser.value = user.isNewUser
},
onFailure = { e ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Login failed"
)
}
)
}
}
fun signup(name: String, email: String, password: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
val result = repository.signup(name, email, password)
result.fold(
onSuccess = { user ->
_uiState.value = _uiState.value.copy(isLoading = false, user = user)
_isAuthenticated.value = true
_isNewUser.value = user.isNewUser
},
onFailure = { e ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Signup failed"
)
}
)
}
}
fun forgotPassword(email: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null, forgotPasswordSent = false)
val result = repository.forgotPassword(email)
result.fold(
onSuccess = {
_uiState.value = _uiState.value.copy(isLoading = false, forgotPasswordSent = true)
},
onFailure = { e ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Request failed"
)
}
)
}
}
fun resetPassword(email: String, code: String, password: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null, resetPasswordSuccess = false)
val result = repository.resetPassword(email, code, password)
result.fold(
onSuccess = {
_uiState.value = _uiState.value.copy(isLoading = false, resetPasswordSuccess = true)
},
onFailure = { e ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Reset failed"
)
}
)
}
}
fun signInWithGoogle(idToken: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
val result = repository.signInWithGoogle(idToken)
result.fold(
onSuccess = { user ->
_uiState.value = _uiState.value.copy(isLoading = false, user = user)
_isAuthenticated.value = true
_isNewUser.value = user.isNewUser
},
onFailure = { e ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Google Sign-In failed"
)
}
)
}
}
fun logout() {
repository.clearTokens()
_uiState.value = AuthUiState()
_isAuthenticated.value = false
_isNewUser.value = false
_onboardingData.value = OnboardingData()
}
fun updatePasswordStrength(password: String) {
val strength = calculatePasswordStrength(password)
_uiState.value = _uiState.value.copy(
passwordStrength = passwordStrengthProgress(strength)
)
}
fun clearError() {
_uiState.value = _uiState.value.copy(error = null)
}
fun updateOnboardingData(update: (OnboardingData) -> OnboardingData) {
_onboardingData.value = update(_onboardingData.value)
}
fun completeOnboarding() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
val data = _onboardingData.value
try {
repository.saveToken(
repository.getAccessToken() ?: throw Exception("Not authenticated"),
repository.getRefreshToken()
)
_isNewUser.value = false
_uiState.value = _uiState.value.copy(isLoading = false)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to complete onboarding"
)
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val app = KordantApp.instance
return AuthViewModel(app.authRepository) 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.KordantApp
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(KordantApp.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.KordantApp
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(KordantApp.instance)
}
private val darkWatchRepo: DarkWatchRepository by lazy {
RepositoryModule.provideDarkWatchRepository(KordantApp.instance)
}
private val voicePrintRepo: VoicePrintRepository by lazy {
RepositoryModule.provideVoicePrintRepository(KordantApp.instance)
}
private val spamShieldRepo: SpamShieldRepository by lazy {
RepositoryModule.provideSpamShieldRepository(KordantApp.instance)
}
private val homeTitleRepo: HomeTitleRepository by lazy {
RepositoryModule.provideHomeTitleRepository(KordantApp.instance)
}
private val removeBrokersRepo: RemoveBrokersRepository by lazy {
RepositoryModule.provideRemoveBrokersRepository(KordantApp.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.KordantApp
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(KordantApp.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.KordantApp
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(KordantApp.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.KordantApp
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(KordantApp.instance)
}
private val subscriptionRepo: SubscriptionRepository by lazy {
RepositoryModule.provideSubscriptionRepository(KordantApp.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.KordantApp
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(KordantApp.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.KordantApp
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(KordantApp.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

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M180,743Q240,687 315.9,652.5Q391.79,618 479.9,618Q568,618 644,652.5Q720,687 780,743L780,180Q780,180 780,180Q780,180 780,180L180,180Q180,180 180,180Q180,180 180,180L180,743ZM482,539Q540,539 580,499Q620,459 620,401Q620,343 580,303Q540,263 482,263Q424,263 384,303Q344,343 344,401Q344,459 384,499Q424,539 482,539ZM180,840Q156,840 138,822Q120,804 120,780L120,180Q120,156 138,138Q156,120 180,120L780,120Q804,120 822,138Q840,156 840,180L840,780Q840,804 822,822Q804,840 780,840L180,840ZM223,780L736,780Q736,780 736,780Q736,780 736,780Q674,727 610.5,702.5Q547,678 480,678Q413,678 349.5,702.5Q286,727 223,780Q223,780 223,780Q223,780 223,780ZM482,479Q449.5,479 426.75,456.25Q404,433.5 404,401Q404,368.5 426.75,345.75Q449.5,323 482,323Q514.5,323 537.25,345.75Q560,368.5 560,401Q560,433.5 537.25,456.25Q514.5,479 482,479ZM480,461L480,461Q480,461 480,461Q480,461 480,461L480,461Q480,461 480,461Q480,461 480,461L480,461Q480,461 480,461Q480,461 480,461Q480,461 480,461Q480,461 480,461Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M3,13h8L11,3L3,3v10zM3,21h8v-6L3,15v6zM13,21h8L21,11h-8v10zM13,3v6h8L21,3h-8z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M240,760L360,760L360,520L600,520L600,760L720,760L720,400L480,220L240,400L240,760ZM160,840L160,360L480,120L800,360L800,840L520,840L520,600L440,600L440,840L160,840ZM480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490Z" />
</vector>

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,6c1.1,0 2,0.9 2,2s-0.9,2 -2,2 -2,-0.9 -2,-2 0.9,-2 2,-2zM16,17H8v-1c0,-1.33 2.67,-2 4,-2s4,0.67 4,2v1z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6 3.6,1.62 3.6,3.6 -1.62,3.6 -3.6,3.6z" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Some files were not shown because too many files have changed in this diff Show More