reduced nesting
This commit is contained in:
1
android/app/.gitignore
vendored
Normal file
1
android/app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
92
android/app/build.gradle.kts
Normal file
92
android/app/build.gradle.kts
Normal 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)
|
||||
}
|
||||
521
android/app/lint-baseline.xml
Normal file
521
android/app/lint-baseline.xml
Normal 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="@string/app_name""
|
||||
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 = "9.1.1""
|
||||
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 = "1.10.1""
|
||||
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 = "1.1.5""
|
||||
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 = "3.5.1""
|
||||
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 = "2.6.1""
|
||||
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 = "1.8.0""
|
||||
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 = "2.7.7""
|
||||
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 = "2025.12.00""
|
||||
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 = "1.1.0-alpha06""
|
||||
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 = "21.0.0""
|
||||
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 = "2.9.1""
|
||||
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 = "2.9.1""
|
||||
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 = "2.2.10""
|
||||
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 = "2.2.10""
|
||||
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 = "4.12.0""
|
||||
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 = "4.12.0""
|
||||
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 = "2.10.1""
|
||||
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 = "6.4.0""
|
||||
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 = "1.7.3""
|
||||
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 = "2.11.0""
|
||||
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 = "1.7.3""
|
||||
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 = "1.4.4""
|
||||
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 = "4.12.0""
|
||||
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=" <color name="brand_primary">#FF4F46E5</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=" <color name="brand_primary_light">#FF818CF8</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=" <color name="brand_accent">#FF06B6D4</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=" <color name="bg_primary">#FFFFFFFF</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=" <color name="bg_primary_dark">#FF0F172A</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=" <color name="text_primary">#FF0F172A</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=" <color name="text_primary_dark">#FFF1F5F9</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=" <color name="success">#FF22C55E</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=" <color name="warning">#FFF59E0B</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=" <color name="error">#FFEF4444</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=" <color name="info">#FF3B82F6</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="<vector xmlns:android="http://schemas.android.com/apk/res/android""
|
||||
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("biometric_enabled", 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("androidx.compose.material:material-icons-core")"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="build.gradle.kts"
|
||||
line="66"
|
||||
column="20"/>
|
||||
</issue>
|
||||
|
||||
</issues>
|
||||
21
android/app/proguard-rules.pro
vendored
Normal file
21
android/app/proguard-rules.pro
vendored
Normal 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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
29
android/app/src/main/AndroidManifest.xml
Normal file
29
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
android/app/src/main/java/com/kordant/android/ShieldAIApp.kt
Normal file
21
android/app/src/main/java/com/kordant/android/ShieldAIApp.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
109
android/app/src/main/java/com/kordant/android/ui/theme/Type.kt
Normal file
109
android/app/src/main/java/com/kordant/android/ui/theme/Type.kt
Normal 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
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
android/app/src/main/res/drawable/ic_account_box.xml
Normal file
9
android/app/src/main/res/drawable/ic_account_box.xml
Normal 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>
|
||||
9
android/app/src/main/res/drawable/ic_alerts.xml
Normal file
9
android/app/src/main/res/drawable/ic_alerts.xml
Normal 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>
|
||||
9
android/app/src/main/res/drawable/ic_dashboard.xml
Normal file
9
android/app/src/main/res/drawable/ic_dashboard.xml
Normal 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>
|
||||
9
android/app/src/main/res/drawable/ic_home.xml
Normal file
9
android/app/src/main/res/drawable/ic_home.xml
Normal 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>
|
||||
170
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
170
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
30
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
30
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
9
android/app/src/main/res/drawable/ic_services.xml
Normal file
9
android/app/src/main/res/drawable/ic_services.xml
Normal 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>
|
||||
9
android/app/src/main/res/drawable/ic_settings.xml
Normal file
9
android/app/src/main/res/drawable/ic_settings.xml
Normal 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>
|
||||
6
android/app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
6
android/app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal 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>
|
||||
@@ -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>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
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
Reference in New Issue
Block a user